import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } 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 { ChatAttachmentList } from "@/components/chat/chat-attachment-list"; import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel"; import { SearchResultsPanel } from "@/components/search/search-results-panel"; import { SybilCharacter } from "@/components/sybil-character"; import { createChat, createChatFromSearch, createSearch, deleteChat, deleteSearch, getChat, listModels, getSearch, listChats, listSearches, runCompletionStream, runSearchStream, suggestChatTitle, getMessageAttachments, type ChatAttachment, type ModelCatalogResponse, type Provider, type ChatDetail, type ChatSummary, type CompletionRequestMessage, type Message, type SearchDetail, type SearchSummary, type ToolCallEvent, } 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"; const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection"; type ProviderModelPreferences = Record; type QuickQuestionModelSelection = { provider: Provider; modelPreferences: ProviderModelPreferences; }; const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = { openai: null, anthropic: null, xai: null, }; const TRANSCRIPT_BOTTOM_GAP = 20; const REPLY_SCROLL_BUFFER_MIN = 288; const REPLY_SCROLL_BUFFER_MAX = 576; const REPLY_SCROLL_BUFFER_VIEWPORT_RATIO = 0.52; const MAX_CHAT_ATTACHMENTS = 8; const MAX_IMAGE_ATTACHMENT_BYTES = 6 * 1024 * 1024; const MAX_TEXT_ATTACHMENT_BYTES = 8 * 1024 * 1024; const MAX_TEXT_ATTACHMENT_CHARS = 200_000; const CHAT_FILE_ACCEPT = ".png,.jpg,.jpeg,.txt,.md,.markdown,.csv,.tsv,.json,.jsonl,.xml,.yaml,.yml,.html,.htm,.css,.js,.jsx,.ts,.tsx,.py,.rb,.java,.c,.cc,.cpp,.h,.hpp,.go,.rs,.sh,.sql,.log,.toml,.ini,.cfg,.conf,.swift,.kt,.m,.mm"; const TEXT_ATTACHMENT_EXTENSIONS = new Set([ ".txt", ".md", ".markdown", ".csv", ".tsv", ".json", ".jsonl", ".xml", ".yaml", ".yml", ".html", ".htm", ".css", ".js", ".jsx", ".ts", ".tsx", ".py", ".rb", ".java", ".c", ".cc", ".cpp", ".h", ".hpp", ".go", ".rs", ".sh", ".sql", ".log", ".toml", ".ini", ".cfg", ".conf", ".swift", ".kt", ".m", ".mm", ]); const TEXT_ATTACHMENT_MIME_TYPES = new Set([ "application/json", "application/ld+json", "application/sql", "application/toml", "application/x-httpd-php", "application/x-javascript", "application/x-sh", "application/xml", "application/yaml", "application/x-yaml", "image/svg+xml", ]); function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) { const providerModels = catalog[provider]?.models ?? []; if (providerModels.length) return providerModels; return PROVIDER_FALLBACK_MODELS[provider]; } function getReplyScrollBufferHeight() { if (typeof window === "undefined") return REPLY_SCROLL_BUFFER_MIN; return Math.min( REPLY_SCROLL_BUFFER_MAX, Math.max(REPLY_SCROLL_BUFFER_MIN, Math.round(window.innerHeight * REPLY_SCROLL_BUFFER_VIEWPORT_RATIO)) ); } function getFileExtension(filename: string) { const index = filename.lastIndexOf("."); return index >= 0 ? filename.slice(index).toLowerCase() : ""; } function createAttachmentId() { if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `att-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; } function inferImageMimeType(file: File) { if (file.type === "image/png" || file.type === "image/jpeg") return file.type; const extension = getFileExtension(file.name); if (extension === ".png") return "image/png"; if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"; return null; } function isTextLikeFile(file: File) { const mimeType = file.type.toLowerCase(); if (mimeType.startsWith("text/")) return true; if (TEXT_ATTACHMENT_MIME_TYPES.has(mimeType)) return true; return TEXT_ATTACHMENT_EXTENSIONS.has(getFileExtension(file.name)); } function arrayBufferToBase64(buffer: ArrayBuffer) { const bytes = new Uint8Array(buffer); const chunkSize = 0x8000; let binary = ""; for (let index = 0; index < bytes.length; index += chunkSize) { const chunk = bytes.subarray(index, index + chunkSize); binary += String.fromCharCode(...chunk); } return btoa(binary); } async function buildChatAttachment(file: File): Promise { const imageMimeType = inferImageMimeType(file); if (imageMimeType) { if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) { throw new Error(`Image '${file.name}' exceeds the 6 MB upload limit.`); } const base64 = arrayBufferToBase64(await file.arrayBuffer()); return { kind: "image", id: createAttachmentId(), filename: file.name, mimeType: imageMimeType, sizeBytes: file.size, dataUrl: `data:${imageMimeType};base64,${base64}`, }; } if (!isTextLikeFile(file)) { throw new Error(`Unsupported file type for '${file.name}'. Use PNG/JPEG images or text-based files.`); } if (file.size > MAX_TEXT_ATTACHMENT_BYTES) { throw new Error(`Text file '${file.name}' exceeds the 8 MB upload limit.`); } const normalizedText = (await file.text()).replace(/\r\n/g, "\n").replace(/\u0000/g, ""); const truncated = normalizedText.length > MAX_TEXT_ATTACHMENT_CHARS; return { kind: "text", id: createAttachmentId(), filename: file.name, mimeType: file.type || "text/plain", sizeBytes: file.size, text: truncated ? normalizedText.slice(0, MAX_TEXT_ATTACHMENT_CHARS) : normalizedText, truncated, }; } function buildAttachmentSummary(attachments: ChatAttachment[]) { if (!attachments.length) return ""; const filenames = attachments.map((attachment) => attachment.filename).join(", "); return attachments.length === 1 ? filenames : `Attached: ${filenames}`; } function getFilesFromDataTransfer(dataTransfer: DataTransfer | null) { if (!dataTransfer) return []; const fromItems = Array.from(dataTransfer.items ?? []) .filter((item) => item.kind === "file") .map((item) => item.getAsFile()) .filter((file): file is File => file instanceof File); if (fromItems.length) return fromItems; return Array.from(dataTransfer.files ?? []); } function hasFileTransfer(dataTransfer: DataTransfer | null) { if (!dataTransfer) return false; return Array.from(dataTransfer.types ?? []).includes("Files") || getFilesFromDataTransfer(dataTransfer).length > 0; } 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 normalizeStoredProvider(value: unknown): Provider { return value === "anthropic" || value === "xai" || value === "openai" ? value : "openai"; } function normalizeStoredModelPreferences(value: unknown): ProviderModelPreferences { if (!value || typeof value !== "object" || Array.isArray(value)) return EMPTY_MODEL_PREFERENCES; const parsed = value 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, }; } function loadStoredQuickQuestionModelSelection(): QuickQuestionModelSelection { if (typeof window === "undefined") { return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; } try { const raw = window.localStorage.getItem(QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY); if (!raw) return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; const parsed = JSON.parse(raw) as { provider?: unknown; modelPreferences?: unknown }; return { provider: normalizeStoredProvider(parsed.provider), modelPreferences: normalizeStoredModelPreferences(parsed.modelPreferences), }; } catch { return { provider: "openai", modelPreferences: EMPTY_MODEL_PREFERENCES }; } } function pickProviderModel(options: string[], preferred: string | null) { if (preferred?.trim()) return preferred.trim(); 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 ToolLogMetadata = { kind: "tool_call"; toolCallId?: string; toolName?: string; status?: "completed" | "failed"; summary?: string; args?: Record; startedAt?: string; completedAt?: string; durationMs?: number; error?: string | null; resultPreview?: string | null; }; function asToolLogMetadata(value: unknown): ToolLogMetadata | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; if (record.kind !== "tool_call") return null; return record as ToolLogMetadata; } function isToolCallLogMessage(message: Message) { return asToolLogMetadata(message.metadata) !== null; } function isDisplayableMessage(message: Message) { return message.role !== "system"; } function buildOptimisticToolMessage(event: ToolCallEvent): Message { return { id: `temp-tool-${event.toolCallId}`, createdAt: event.completedAt ?? new Date().toISOString(), role: "tool", content: event.summary, name: event.name, metadata: { kind: "tool_call", toolCallId: event.toolCallId, toolName: event.name, status: event.status, summary: event.summary, args: event.args, startedAt: event.startedAt, completedAt: event.completedAt, durationMs: event.durationMs, error: event.error ?? null, resultPreview: event.resultPreview ?? null, } satisfies ToolLogMetadata, }; } 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 [draftValue, setDraftValue] = useState(value); const rootRef = useRef(null); const inputRef = useRef(null); const normalizedDraftValue = draftValue.trim(); const filteredOptions = useMemo(() => { const needle = normalizedDraftValue.toLowerCase(); if (!needle) return options; return options.filter((option) => option.toLowerCase().includes(needle)); }, [normalizedDraftValue, options]); const hasExactOption = options.includes(normalizedDraftValue); useEffect(() => { if (open) return; setDraftValue(value); }, [open, value]); useEffect(() => { if (!open) return; inputRef.current?.focus(); }, [open]); const commitDraftValue = () => { onChange(normalizedDraftValue); setDraftValue(normalizedDraftValue); setOpen(false); }; useEffect(() => { if (!open) return; const handlePointerDown = (event: PointerEvent) => { if (rootRef.current?.contains(event.target as Node)) return; commitDraftValue(); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; setOpen(false); setDraftValue(value); }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleKeyDown); }; }, [commitDraftValue, open, value]); return (
{ if (disabled) return; setDraftValue(value); setOpen(true); }} onInput={(event) => { setDraftValue(event.currentTarget.value); setOpen(true); }} onKeyDown={(event) => { if (event.key !== "Enter") return; event.preventDefault(); commitDraftValue(); }} className="h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground" placeholder="Select or type model" disabled={disabled} />
{open ? (
{normalizedDraftValue && !hasExactOption ? ( ) : null} {filteredOptions.length ? ( filteredOptions.map((option) => ( )) ) : !normalizedDraftValue ? (

No models found

) : null}
) : null}
); } function getChatTitle(chat: Pick, messages?: ChatDetail["messages"]) { if (chat.title?.trim()) return chat.title.trim(); const firstUserMessage = messages?.find((message) => message.role === "user"); const firstUserText = firstUserMessage?.content.trim(); if (firstUserText) return firstUserText.slice(0, 48); const firstUserAttachments = firstUserMessage ? getMessageAttachments(firstUserMessage.metadata) : []; const attachmentSummary = buildAttachmentSummary(firstUserAttachments); if (attachmentSummary) return attachmentSummary.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)); } function getSidebarSectionLabel(value: string) { const date = new Date(value); const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfItemDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); const dayMs = 24 * 60 * 60 * 1000; const dayDelta = Math.floor((startOfToday - startOfItemDay) / dayMs); if (dayDelta <= 0) return "TODAY"; if (dayDelta < 7) return "LAST 7 DAYS"; return "EARLIER"; } function buildSidebarSections(items: SidebarItem[]) { return items.reduce>((sections, item) => { const label = getSidebarSectionLabel(item.updatedAt); const section = sections.find((candidate) => candidate.label === label); if (section) { section.items.push(item); } else { sections.push({ label, items: [item] }); } return sections; }, []); } 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 [isStartingSearchChat, setIsStartingSearchChat] = useState(false); const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null); const [composer, setComposer] = useState(""); const [pendingAttachments, setPendingAttachments] = useState([]); const [isComposerDropActive, setIsComposerDropActive] = useState(false); 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 [quickProvider, setQuickProvider] = useState(() => loadStoredQuickQuestionModelSelection().provider); const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState( () => loadStoredQuickQuestionModelSelection().modelPreferences ); const [quickModel, setQuickModel] = useState(() => { const stored = loadStoredQuickQuestionModelSelection(); return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0]; }); const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false); const [quickPrompt, setQuickPrompt] = useState(""); const [quickSubmittedPrompt, setQuickSubmittedPrompt] = useState(null); const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null); const [quickQuestionMessages, setQuickQuestionMessages] = useState([]); const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false); const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false); const [quickQuestionError, setQuickQuestionError] = useState(null); const [error, setError] = useState(null); const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP); const transcriptContainerRef = useRef(null); const transcriptEndRef = useRef(null); const contextMenuRef = useRef(null); const fileInputRef = useRef(null); const dragDepthRef = useRef(0); const pendingAttachmentsRef = useRef([]); const selectedItemRef = useRef(null); const pendingTitleGenerationRef = useRef>(new Set()); const searchRunAbortRef = useRef(null); const quickQuestionAbortRef = useRef(null); const searchRunCounterRef = useRef(0); const shouldAutoScrollRef = useRef(true); const wasSendingRef = useRef(false); const pendingReplyScrollRef = useRef(false); const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP); const [contextMenu, setContextMenu] = useState(null); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [sidebarQuery, setSidebarQuery] = useState(""); const initialRouteSelectionRef = useRef(readSidebarSelectionFromUrl()); const hasSyncedSelectionHistoryRef = useRef(false); const setTranscriptTailSpacer = (height: number) => { const nextHeight = Math.max(TRANSCRIPT_BOTTOM_GAP, Math.ceil(height)); transcriptTailSpacerHeightRef.current = nextHeight; setTranscriptTailSpacerHeight(nextHeight); }; const expandTranscriptTailSpacer = (height: number) => { const targetHeight = Math.max(TRANSCRIPT_BOTTOM_GAP, Math.ceil(height)); setTranscriptTailSpacerHeight((currentHeight) => { const nextHeight = Math.max(currentHeight, targetHeight); transcriptTailSpacerHeightRef.current = nextHeight; return nextHeight; }); }; const settleTranscriptTailSpacer = () => { const container = transcriptContainerRef.current; const currentSpacerHeight = transcriptTailSpacerHeightRef.current; if (!container) { setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); return; } const scrollHeightWithoutSpacer = container.scrollHeight - currentSpacerHeight; const requiredSpacerHeight = container.scrollTop + container.clientHeight - scrollHeightWithoutSpacer; setTranscriptTailSpacer(requiredSpacerHeight); }; 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]); useEffect(() => { pendingAttachmentsRef.current = pendingAttachments; }, [pendingAttachments]); const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]); const filteredSidebarItems = useMemo(() => { const query = sidebarQuery.trim().toLowerCase(); if (!query) return sidebarItems; return sidebarItems.filter((item) => { const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase(); return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(query)); }); }, [sidebarItems, sidebarQuery]); const sidebarSections = useMemo(() => buildSidebarSections(filteredSidebarItems), [filteredSidebarItems]); const resetWorkspaceState = () => { setChats([]); setSearches([]); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setDraftKind(null); setPendingChatState(null); setComposer(""); setPendingAttachments([]); setIsQuickQuestionOpen(false); setQuickPrompt(""); setQuickSubmittedPrompt(null); setQuickSubmittedModelSelection(null); setQuickQuestionMessages([]); setQuickQuestionError(null); 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]); const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]); useEffect(() => { if (model.trim()) return; setModel((current) => { return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]); }); }, [model, provider, providerModelOptions, providerModelPreferences]); useEffect(() => { if (typeof window === "undefined") return; window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences)); }, [providerModelPreferences]); useEffect(() => { if (quickModel.trim()) return; setQuickModel((current) => { return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]); }); }, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]); useEffect(() => { if (typeof window === "undefined") return; window.localStorage.setItem( QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY, JSON.stringify({ provider: quickProvider, modelPreferences: quickProviderModelPreferences, } satisfies QuickQuestionModelSelection) ); }, [quickProvider, quickProviderModelPreferences]); useEffect(() => { if (!isQuickQuestionOpen || typeof window === "undefined") return; window.requestAnimationFrame(() => { const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null; if (!textarea) return; textarea.focus(); textarea.style.height = "0px"; textarea.style.height = `${textarea.scrollHeight}px`; if (textarea.value.length > 0) { textarea.select(); } }); }, [isQuickQuestionOpen]); useEffect(() => { if (typeof document === "undefined") return; const textarea = document.getElementById("quick-question-input") as HTMLTextAreaElement | null; if (!textarea) return; textarea.style.height = "0px"; textarea.style.height = `${textarea.scrollHeight}px`; }, [quickPrompt, isQuickQuestionOpen]); const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null; const isChatReplyStreamingInView = isSending && draftKind !== "search" && selectedItem?.kind !== "search" && !!pendingChatState && (!pendingChatState.chatId || (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId)); useEffect(() => { shouldAutoScrollRef.current = true; if (!isSending || !isChatReplyStreamingInView) { setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP); } }, [draftKind, selectedItem?.kind, selectedKey]); 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 (isSending) return; if (wasSending) { shouldAutoScrollRef.current = false; return; } if (!shouldAutoScrollRef.current) return; transcriptEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" }); }, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind, selectedKey]); useEffect(() => { if (!isChatReplyStreamingInView || !pendingReplyScrollRef.current) return; pendingReplyScrollRef.current = false; shouldAutoScrollRef.current = true; window.requestAnimationFrame(() => { const container = transcriptContainerRef.current; if (!container) return; container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }); }); }, [isChatReplyStreamingInView, pendingChatState?.chatId]); 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; quickQuestionAbortRef.current?.abort(); quickQuestionAbortRef.current = null; }; }, []); const messages = selectedChat?.messages ?? []; const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search"; const isSearchRunning = isSending && isSearchMode; const isSendingActiveChat = isChatReplyStreamingInView; useEffect(() => { if (isSearchMode && pendingAttachments.length) { setPendingAttachments([]); } if (isSearchMode) { dragDepthRef.current = 0; setIsComposerDropActive(false); } }, [isSearchMode, pendingAttachments.length]); const displayMessages = useMemo(() => { if (!pendingChatState) return messages.filter(isDisplayableMessage); if (pendingChatState.chatId) { if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) { return pendingChatState.messages.filter(isDisplayableMessage); } return messages.filter(isDisplayableMessage); } return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage); }, [isSearchMode, messages, pendingChatState, selectedItem]); const quickAnswerText = useMemo(() => { for (let index = quickQuestionMessages.length - 1; index >= 0; index -= 1) { const message = quickQuestionMessages[index]; if (message.role === "assistant") return message.content; } return ""; }, [quickQuestionMessages]); const canConvertQuickQuestion = Boolean(quickSubmittedPrompt?.trim()) && Boolean(quickSubmittedModelSelection?.model.trim()) && Boolean(quickAnswerText.trim()) && !isQuickQuestionSending; 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]); const primaryShortcutModifier = useMemo(() => { if (typeof navigator === "undefined") return "Ctrl"; return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl"; }, []); useEffect(() => { document.title = pageTitle; }, [pageTitle]); const handleCreateChat = () => { setError(null); setContextMenu(null); setDraftKind("chat"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setPendingAttachments([]); setIsMobileSidebarOpen(false); }; const handleOpenQuickQuestion = () => { setQuickQuestionError(null); setIsQuickQuestionOpen(true); setIsMobileSidebarOpen(false); }; const handleCreateSearch = () => { setError(null); setContextMenu(null); setDraftKind("search"); setSelectedItem(null); setSelectedChat(null); setSelectedSearch(null); setPendingAttachments([]); setIsMobileSidebarOpen(false); }; const selectAdjacentSidebarItem = (direction: -1 | 1) => { if (!filteredSidebarItems.length) return; setError(null); setContextMenu(null); setDraftKind(null); setIsMobileSidebarOpen(false); setSelectedItem((current) => { const currentIndex = current ? filteredSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id) : -1; const fallbackIndex = direction > 0 ? 0 : filteredSidebarItems.length - 1; const nextIndex = currentIndex < 0 ? fallbackIndex : Math.min(filteredSidebarItems.length - 1, Math.max(0, currentIndex + direction)); const nextItem = filteredSidebarItems[nextIndex]; return { kind: nextItem.kind, id: nextItem.id }; }); }; useEffect(() => { if (!isAuthenticated) return; const handleKeyDown = (event: KeyboardEvent) => { const hasPrimaryModifier = event.metaKey || event.ctrlKey; if (!hasPrimaryModifier || event.altKey) return; const key = event.key.toLowerCase(); if (key === "i" && !event.shiftKey) { event.preventDefault(); setQuickQuestionError(null); setIsQuickQuestionOpen((current) => !current); return; } if (isQuickQuestionOpen) return; if (key === "j") { event.preventDefault(); if (event.shiftKey) { handleCreateSearch(); } else { handleCreateChat(); } focusComposer(); return; } if (event.key === "ArrowUp" || event.key === "ArrowDown") { event.preventDefault(); selectAdjacentSidebarItem(event.key === "ArrowUp" ? -1 : 1); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]); 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]); useEffect(() => { if (!isQuickQuestionOpen) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); setIsQuickQuestionOpen(false); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [isQuickQuestionOpen]); const handleOpenAttachmentPicker = () => { fileInputRef.current?.click(); }; const handleRemovePendingAttachment = (attachmentId: string) => { setPendingAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId)); }; const appendPendingAttachments = async (files: File[]) => { if (!files.length) return; if (isSearchMode) { setError("Attachments are only available in chat mode."); return; } setError(null); try { const attachments = await Promise.all(files.map((file) => buildChatAttachment(file))); if (pendingAttachmentsRef.current.length + attachments.length > MAX_CHAT_ATTACHMENTS) { throw new Error(`You can attach up to ${MAX_CHAT_ATTACHMENTS} files per message.`); } setPendingAttachments((current) => current.concat(attachments)); focusComposer(); } catch (err) { const message = err instanceof Error ? err.message : String(err); setError(message); } }; const handleFileSelection = async (event: Event) => { const input = event.currentTarget as HTMLInputElement; const files = Array.from(input.files ?? []); input.value = ""; await appendPendingAttachments(files); }; const handleComposerPaste = async (event: ClipboardEvent) => { const files = getFilesFromDataTransfer(event.clipboardData); if (!files.length) return; event.preventDefault(); await appendPendingAttachments(files); }; const handleComposerDragEnter = (event: DragEvent) => { if (!hasFileTransfer(event.dataTransfer)) return; event.preventDefault(); if (isSearchMode) return; dragDepthRef.current += 1; setIsComposerDropActive(true); }; const handleComposerDragOver = (event: DragEvent) => { if (!hasFileTransfer(event.dataTransfer)) return; event.preventDefault(); if (event.dataTransfer) { event.dataTransfer.dropEffect = isSearchMode ? "none" : "copy"; } if (!isSearchMode) { setIsComposerDropActive(true); } }; const handleComposerDragLeave = (event: DragEvent) => { if (!hasFileTransfer(event.dataTransfer)) return; event.preventDefault(); if (isSearchMode) return; dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0) { setIsComposerDropActive(false); } }; const handleComposerDrop = async (event: DragEvent) => { if (!hasFileTransfer(event.dataTransfer)) return; event.preventDefault(); dragDepthRef.current = 0; setIsComposerDropActive(false); await appendPendingAttachments(getFilesFromDataTransfer(event.dataTransfer)); }; const handleSendChat = async (content: string, attachments: ChatAttachment[]) => { pendingReplyScrollRef.current = true; expandTranscriptTailSpacer(getReplyScrollBufferHeight()); const optimisticUserMessage: Message = { id: `temp-user-${Date.now()}`, createdAt: new Date().toISOString(), role: "user", content, name: null, metadata: attachments.length ? { attachments } : null, }; const optimisticAssistantMessage: Message = { id: `temp-assistant-${Date.now()}`, createdAt: new Date().toISOString(), role: "assistant", content: "", name: null, metadata: 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 .filter((message) => !isToolCallLogMessage(message)) .map((message) => ({ role: message.role, content: message.content, ...(message.name ? { name: message.name } : {}), ...(getMessageAttachments(message.metadata).length ? { attachments: getMessageAttachments(message.metadata) } : {}), })), { role: "user", content, ...(attachments.length ? { attachments } : {}), }, ]; 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); const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files"; void suggestChatTitle({ chatId, content: titleSeed }) .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)); }, onToolCall: (payload) => { setPendingChatState((current) => { if (!current) return current; if ( current.messages.some( (message) => asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` ) ) { return current; } const toolMessage = buildOptimisticToolMessage(payload); const assistantIndex = current.messages.findIndex( (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-") ); if (assistantIndex < 0) { return { ...current, messages: current.messages.concat(toolMessage) }; } return { ...current, messages: [ ...current.messages.slice(0, assistantIndex), toolMessage, ...current.messages.slice(assistantIndex), ], }; }); }, 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); } settleTranscriptTailSpacer(); 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 handleStartChatFromSearch = async () => { if (!selectedSearch || isStartingSearchChat || isSending) return; setError(null); setIsStartingSearchChat(true); try { const chat = await createChatFromSearch(selectedSearch.id); setDraftKind(null); setPendingChatState(null); setComposer(""); setPendingAttachments([]); setChats((current) => { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); setSelectedItem({ kind: "chat", id: chat.id }); 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); await refreshCollections({ kind: "chat", id: chat.id }); await refreshChat(chat.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } } finally { setIsStartingSearchChat(false); } }; const handleSendQuickQuestion = async () => { const content = quickPrompt.trim(); if (!content || isQuickQuestionSending || isConvertingQuickQuestion) return; const selectedModel = quickModel.trim(); if (!selectedModel) { setQuickQuestionError("No model available for selected provider"); return; } const now = new Date().toISOString(); const optimisticAssistantMessage: Message = { id: `temp-assistant-quick-${Date.now()}`, createdAt: now, role: "assistant", content: "", name: null, metadata: null, }; quickQuestionAbortRef.current?.abort(); const abortController = new AbortController(); quickQuestionAbortRef.current = abortController; setQuickQuestionError(null); setQuickSubmittedPrompt(content); setQuickSubmittedModelSelection({ provider: quickProvider, model: selectedModel }); setQuickQuestionMessages([optimisticAssistantMessage]); setIsQuickQuestionSending(true); let streamErrorMessage: string | null = null; try { await runCompletionStream( { persist: false, provider: quickProvider, model: selectedModel, messages: [{ role: "user", content }], }, { onToolCall: (payload) => { setQuickQuestionMessages((current) => { if ( current.some( (message) => asToolLogMetadata(message.metadata)?.toolCallId === payload.toolCallId || message.id === `temp-tool-${payload.toolCallId}` ) ) { return current; } const toolMessage = buildOptimisticToolMessage(payload); const assistantIndex = current.findIndex( (message, index, all) => index === all.length - 1 && message.id.startsWith("temp-assistant-quick-") ); if (assistantIndex < 0) return current.concat(toolMessage); return [ ...current.slice(0, assistantIndex), toolMessage, ...current.slice(assistantIndex), ]; }); }, onDelta: (payload) => { if (!payload.text) return; setQuickQuestionMessages((current) => { let updated = false; const nextMessages = current.map((message, index, all) => { const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); if (!isTarget) return message; updated = true; return { ...message, content: message.content + payload.text }; }); return updated ? nextMessages : current; }); }, onDone: (payload) => { setQuickQuestionMessages((current) => { let updated = false; const nextMessages = current.map((message, index, all) => { const isTarget = index === all.length - 1 && message.id.startsWith("temp-assistant-quick-"); if (!isTarget) return message; updated = true; return { ...message, content: payload.text }; }); return updated ? nextMessages : current; }); }, onError: (payload) => { streamErrorMessage = payload.message; }, }, { signal: abortController.signal } ); if (streamErrorMessage) { throw new Error(streamErrorMessage); } } catch (err) { if (abortController.signal.aborted) return; const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setQuickQuestionError(message); } } finally { if (quickQuestionAbortRef.current === abortController) { quickQuestionAbortRef.current = null; } if (!abortController.signal.aborted) { setIsQuickQuestionSending(false); } } }; const handleConvertQuickQuestionToChat = async () => { const question = quickSubmittedPrompt?.trim(); const answer = quickAnswerText.trim(); const selection = quickSubmittedModelSelection; if (!question || !answer || !selection || isQuickQuestionSending || isConvertingQuickQuestion) return; setQuickQuestionError(null); setIsConvertingQuickQuestion(true); try { const title = question.split(/\r?\n/)[0]?.trim().slice(0, 48) || "Quick question"; const chat = await createChat({ title, provider: selection.provider, model: selection.model, messages: [ { role: "user", content: question }, { role: "assistant", content: answer }, ], }); setDraftKind(null); setPendingChatState(null); setComposer(""); setPendingAttachments([]); setIsQuickQuestionOpen(false); setProvider(selection.provider); setModel(selection.model); setChats((current) => { const withoutExisting = current.filter((existing) => existing.id !== chat.id); return [chat, ...withoutExisting]; }); setSelectedItem({ kind: "chat", id: chat.id }); 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); await refreshCollections({ kind: "chat", id: chat.id }); await refreshChat(chat.id); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setQuickQuestionError(message); } } finally { setIsConvertingQuickQuestion(false); } }; const handleSend = async () => { const content = composer.trim(); const attachments = pendingAttachments; if ((!content && !attachments.length) || isSending) return; if (isSearchMode && attachments.length) { setError("Attachments are only available in chat mode."); return; } setComposer(""); setPendingAttachments([]); setError(null); setIsSending(true); try { if (isSearchMode) { await handleSendSearch(content); } else { await handleSendChat(content, attachments); } } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message.includes("bearer token")) { handleAuthFailure(message); } else { setError(message); } if (!isSearchMode) { setComposer(content); setPendingAttachments(attachments); 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 ? (
{primaryShortcutModifier}+i
setSidebarQuery(event.currentTarget.value)} placeholder="Search chats" className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70" />
{isLoadingCollections && sidebarItems.length === 0 ?

Loading conversations...

: null} {!isLoadingCollections && sidebarItems.length === 0 ? (
Start a chat or run your first search.
) : null} {!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (

No chats found.

) : null} {sidebarSections.map((section) => (

{section.label}

{section.items.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}

{!isSearchMode ? ( <> { const normalizedModel = nextModel.trim(); setModel(normalizedModel); setProviderModelPreferences((current) => ({ ...current, [provider]: normalizedModel || null, })); }} /> ) : (
Search mode
)}
{ const container = transcriptContainerRef.current; if (!container) return; const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; shouldAutoScrollRef.current = distanceFromBottom < 96; }} > {!isSearchMode ? ( ) : ( )}