Files
Sybil-2/web/src/App.tsx

1337 lines
46 KiB
TypeScript

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,
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<Provider, string[]> = {
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<Provider, string | null>;
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<Record<Provider, string>>;
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<ChatSummary, "lastUsedProvider" | "lastUsedModel"> | Pick<ChatDetail, "lastUsedProvider" | "lastUsedModel"> | 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<string, unknown>;
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<string, unknown>;
if (record.kind !== "tool_call") return null;
return record as ToolLogMetadata;
}
function isToolCallLogMessage(message: Message) {
return asToolLogMetadata(message.metadata) !== null;
}
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 [query, setQuery] = useState("");
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div className="relative" ref={rootRef}>
<button
type="button"
className="flex h-9 min-w-56 items-center justify-between rounded-md border border-input bg-background px-2 text-sm"
onClick={() => {
if (disabled) return;
setOpen((current) => !current);
}}
disabled={disabled}
>
<span className="truncate text-left">{value || "Select model"}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-muted-foreground" />
</button>
{open ? (
<div className="absolute right-0 z-50 mt-1 w-full rounded-md border border-border bg-background p-1 shadow-md">
<input
ref={inputRef}
value={query}
onInput={(event) => 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"
/>
<div className="max-h-64 overflow-y-auto">
{filteredOptions.length ? (
filteredOptions.map((option) => (
<button
key={option}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm hover:bg-muted"
onClick={() => {
onChange(option);
setOpen(false);
setQuery("");
}}
>
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">{option}</span>
</button>
))
) : (
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
)}
</div>
</div>
) : null}
</div>
);
}
function getChatTitle(chat: Pick<ChatSummary, "title">, 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<SearchSummary, "title" | "query">) {
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<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
const [draftKind, setDraftKind] = useState<DraftSelectionKind | null>(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<Provider>("openai");
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
const [model, setModel] = useState(() => {
const stored = loadStoredModelPreferences();
return stored.openai ?? PROVIDER_FALLBACK_MODELS.openai[0];
});
const [error, setError] = useState<string | null>(null);
const transcriptContainerRef = useRef<HTMLDivElement>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<SidebarSelection | null>(null);
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
const searchRunAbortRef = useRef<AbortController | null>(null);
const searchRunCounterRef = useRef(0);
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const initialRouteSelectionRef = useRef<SidebarSelection | null>(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(() => {
shouldAutoScrollRef.current = true;
}, [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 (wasSending && !isSending) return;
if (!shouldAutoScrollRef.current) 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,
metadata: 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 } : {}),
})),
{
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));
},
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);
}
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 (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Checking session...</p>
</div>
);
}
if (!isAuthenticated) {
return (
<AuthScreen
authTokenInput={authTokenInput}
setAuthTokenInput={setAuthTokenInput}
isSigningIn={isSigningIn}
authError={authError}
onSignIn={handleSignIn}
/>
);
}
return (
<div className="h-full">
<div className="flex h-full w-full overflow-hidden bg-background">
{isMobileSidebarOpen ? (
<button
type="button"
className="fixed inset-0 z-30 bg-black/45 md:hidden"
onClick={() => setIsMobileSidebarOpen(false)}
aria-label="Close sidebar"
/>
) : null}
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-[85vw] max-w-80 shrink-0 flex-col border-r bg-[hsl(272_34%_14%)] transition-transform md:static md:z-auto md:w-80 md:max-w-none",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
<div className="grid grid-cols-2 gap-2 p-3">
<Button className="justify-start gap-2" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
</Button>
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
<Search className="h-4 w-4" />
New search
</Button>
</div>
<Separator />
<div className="flex-1 overflow-y-auto p-2">
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isLoadingCollections && sidebarItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
<MessageSquare className="h-5 w-5" />
Start a chat or run your first search.
</div>
) : 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 (
<button
key={`${item.kind}-${item.id}`}
className={cn(
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
active ? "bg-violet-500/30 text-violet-100" : "text-violet-200/85 hover:bg-violet-500/15"
)}
onClick={() => {
setContextMenu(null);
setDraftKind(null);
setSelectedItem({ kind: item.kind, id: item.id });
setIsMobileSidebarOpen(false);
}}
onContextMenu={(event) => openContextMenu(event, { kind: item.kind, id: item.id })}
type="button"
>
<div className="flex items-center gap-2">
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
<p className="truncate text-sm font-medium">{item.title}</p>
</div>
<div className="mt-1 flex items-center gap-2 text-xs">
<p className={cn("shrink-0", active ? "text-violet-100/90" : "text-violet-300/60")}>{formatDate(item.updatedAt)}</p>
{initiatedLabel ? (
<p className={cn("ml-auto truncate text-right", active ? "text-violet-200/65" : "text-violet-300/45")}>{initiatedLabel}</p>
) : null}
</div>
</button>
);
})}
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
<header className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-start gap-2">
<Button
type="button"
size="icon"
variant="ghost"
className="-ml-1 h-8 w-8 md:hidden"
onClick={() => setIsMobileSidebarOpen(true)}
aria-label="Open sidebar"
>
<Menu className="h-4 w-4" />
</Button>
<div>
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
<p className="text-xs text-muted-foreground">
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
{isSearchMode ? " • Exa Search" : ""}
</p>
</div>
</div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
{!isSearchMode ? (
<>
<select
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
value={provider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setModel((current) => pickProviderModel(options, providerModelPreferences[nextProvider], current));
}}
disabled={isSending}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<ModelCombobox
options={providerModelOptions}
value={model}
disabled={isSending || providerModelOptions.length === 0}
onChange={(nextModel) => {
setModel(nextModel);
setProviderModelPreferences((current) => ({
...current,
[provider]: nextModel,
}));
}}
/>
</>
) : (
<div className="flex h-9 items-center rounded-md border border-input px-3 text-sm text-muted-foreground">
<Globe2 className="mr-2 h-4 w-4" />
Search mode
</div>
)}
</div>
</header>
<div
ref={transcriptContainerRef}
className={cn("flex-1 overflow-y-auto px-3 pt-6 md:px-10", isSearchMode ? "pb-6" : "pb-28 md:pb-40")}
onScroll={() => {
const container = transcriptContainerRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
shouldAutoScrollRef.current = distanceFromBottom < 96;
}}
>
{!isSearchMode ? (
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
) : (
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
)}
<div ref={transcriptEndRef} />
</div>
<footer className="border-t p-3 md:p-4">
<div className="mx-auto max-w-3xl rounded-xl border bg-background p-2 shadow-sm">
<Textarea
id="composer-input"
rows={1}
value={composer}
onInput={(event) => {
const textarea = event.currentTarget;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
setComposer(textarea.value);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 shadow-none focus-visible:ring-0"
disabled={isSending}
/>
<div className={cn("flex items-center px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="text-xs text-red-600">{error}</p> : null}
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</Button>
</div>
</div>
</footer>
</main>
</div>
{contextMenu ? (
<div
ref={contextMenuRef}
className="fixed z-50 min-w-40 rounded-md border border-border bg-background p-1 shadow-md"
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-red-600 transition hover:bg-muted disabled:text-muted-foreground"
onClick={() => void handleDeleteFromContextMenu()}
disabled={isSending}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
) : null}
</div>
);
}