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

2440 lines
89 KiB
TypeScript
Raw Normal View History

2026-02-13 23:15:12 -08:00
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
2026-05-02 23:48:01 -07:00
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Rabbit, Search, SendHorizontal, Trash2, X } from "lucide-preact";
2026-02-13 23:15:12 -08:00
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
2026-02-14 00:22:19 -08:00
import { AuthScreen } from "@/components/auth/auth-screen";
2026-05-02 19:21:06 -07:00
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
2026-02-14 00:22:19 -08:00
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
2026-02-13 23:15:12 -08:00
import {
createChat,
2026-05-02 16:48:01 -07:00
createChatFromSearch,
2026-02-13 23:49:55 -08:00
createSearch,
2026-02-14 01:10:27 -08:00
deleteChat,
deleteSearch,
2026-02-13 23:15:12 -08:00
getChat,
2026-02-14 21:00:30 -08:00
listModels,
2026-02-13 23:49:55 -08:00
getSearch,
2026-02-13 23:15:12 -08:00
listChats,
2026-02-13 23:49:55 -08:00
listSearches,
2026-02-14 21:15:54 -08:00
runCompletionStream,
2026-02-14 01:53:34 -08:00
runSearchStream,
2026-02-14 21:27:44 -08:00
suggestChatTitle,
2026-05-02 19:21:06 -07:00
getMessageAttachments,
type ChatAttachment,
2026-02-14 21:00:30 -08:00
type ModelCatalogResponse,
type Provider,
2026-02-13 23:15:12 -08:00
type ChatDetail,
type ChatSummary,
type CompletionRequestMessage,
2026-02-14 20:51:52 -08:00
type Message,
2026-02-13 23:49:55 -08:00
type SearchDetail,
type SearchSummary,
type ToolCallEvent,
2026-02-13 23:15:12 -08:00
} from "@/lib/api";
2026-02-14 00:22:19 -08:00
import { useSessionAuth } from "@/hooks/use-session-auth";
2026-02-13 23:15:12 -08:00
import { cn } from "@/lib/utils";
2026-02-13 23:49:55 -08:00
type SidebarSelection = { kind: "chat" | "search"; id: string };
2026-02-14 00:09:06 -08:00
type DraftSelectionKind = "chat" | "search";
2026-02-13 23:49:55 -08:00
type SidebarItem = SidebarSelection & {
title: string;
updatedAt: string;
createdAt: string;
2026-02-14 22:06:30 -08:00
initiatedProvider: Provider | null;
initiatedModel: string | null;
lastUsedProvider: Provider | null;
lastUsedModel: string | null;
2026-02-13 23:49:55 -08:00
};
2026-02-14 01:10:27 -08:00
type ContextMenuState = {
item: SidebarSelection;
x: number;
y: number;
};
2026-02-13 23:15:12 -08:00
2026-02-15 22:57:10 -08:00
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}` : ""}`;
}
2026-02-14 21:00:30 -08:00
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
2026-02-13 23:15:12 -08:00
};
2026-02-14 21:00:30 -08:00
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";
2026-05-02 23:48:01 -07:00
const QUICK_QUESTION_MODEL_SELECTION_STORAGE_KEY = "sybil:quickQuestionModelSelection";
2026-02-14 21:00:30 -08:00
type ProviderModelPreferences = Record<Provider, string | null>;
2026-05-02 23:48:01 -07:00
type QuickQuestionModelSelection = {
provider: Provider;
modelPreferences: ProviderModelPreferences;
};
2026-02-14 21:00:30 -08:00
const EMPTY_MODEL_PREFERENCES: ProviderModelPreferences = {
openai: null,
anthropic: null,
xai: null,
};
2026-05-02 18:25:20 -07:00
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;
2026-05-02 19:21:06 -07:00
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",
]);
2026-05-02 18:25:20 -07:00
2026-02-14 21:00:30 -08:00
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
const providerModels = catalog[provider]?.models ?? [];
if (providerModels.length) return providerModels;
return PROVIDER_FALLBACK_MODELS[provider];
}
2026-05-02 18:25:20 -07:00
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))
);
}
2026-05-02 19:21:06 -07:00
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<ChatAttachment> {
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;
}
2026-02-14 21:00:30 -08:00
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;
}
}
2026-05-02 23:48:01 -07:00
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<Record<Provider, unknown>>;
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();
2026-02-14 21:00:30 -08:00
return options[0] ?? "";
}
2026-02-14 22:06:30 -08:00
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;
}
2026-05-02 16:48:01 -07:00
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,
};
}
2026-02-14 21:00:30 -08:00
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);
2026-02-14 21:00:30 -08:00
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const normalizedDraftValue = draftValue.trim();
2026-02-14 21:00:30 -08:00
const filteredOptions = useMemo(() => {
const needle = normalizedDraftValue.toLowerCase();
2026-02-14 21:00:30 -08:00
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]);
2026-02-14 21:00:30 -08:00
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
}, [open]);
const commitDraftValue = () => {
onChange(normalizedDraftValue);
setDraftValue(normalizedDraftValue);
setOpen(false);
};
2026-02-14 21:00:30 -08:00
useEffect(() => {
if (!open) return;
const handlePointerDown = (event: PointerEvent) => {
if (rootRef.current?.contains(event.target as Node)) return;
commitDraftValue();
2026-02-14 21:00:30 -08:00
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
setDraftValue(value);
2026-02-14 21:00:30 -08:00
};
window.addEventListener("pointerdown", handlePointerDown);
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("pointerdown", handlePointerDown);
window.removeEventListener("keydown", handleKeyDown);
};
}, [commitDraftValue, open, value]);
2026-02-14 21:00:30 -08:00
return (
<div className="relative" ref={rootRef}>
2026-05-02 15:44:31 -07:00
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
<input
ref={inputRef}
value={draftValue}
onFocus={() => {
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}
/>
<button
type="button"
className="ml-2 shrink-0 text-muted-foreground disabled:opacity-50"
onClick={() => {
if (disabled) return;
if (open) {
commitDraftValue();
return;
}
setDraftValue(value);
setOpen(true);
}}
disabled={disabled}
aria-label="Toggle model options"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
2026-02-14 21:00:30 -08:00
{open ? (
2026-05-02 15:44:31 -07:00
<div className="absolute right-0 z-50 mt-2 w-full rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45">
2026-02-14 21:00:30 -08:00
<div className="max-h-64 overflow-y-auto">
{normalizedDraftValue && !hasExactOption ? (
<button
type="button"
2026-05-02 15:44:31 -07:00
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
onClick={commitDraftValue}
>
<Check className={cn("h-4 w-4", normalizedDraftValue === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">Use "{normalizedDraftValue}"</span>
</button>
) : null}
2026-02-14 21:00:30 -08:00
{filteredOptions.length ? (
filteredOptions.map((option) => (
<button
key={option}
type="button"
2026-05-02 15:44:31 -07:00
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-violet-400/12"
2026-02-14 21:00:30 -08:00
onClick={() => {
onChange(option);
setOpen(false);
setDraftValue(option);
2026-02-14 21:00:30 -08:00
}}
>
<Check className={cn("h-4 w-4", option === value ? "opacity-100" : "opacity-0")} />
<span className="truncate">{option}</span>
</button>
))
) : !normalizedDraftValue ? (
2026-02-14 21:00:30 -08:00
<p className="px-2 py-2 text-sm text-muted-foreground">No models found</p>
) : null}
2026-02-14 21:00:30 -08:00
</div>
</div>
) : null}
</div>
);
}
2026-02-13 23:15:12 -08:00
function getChatTitle(chat: Pick<ChatSummary, "title">, messages?: ChatDetail["messages"]) {
if (chat.title?.trim()) return chat.title.trim();
2026-05-02 19:21:06 -07:00
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);
2026-02-13 23:15:12 -08:00
return "New chat";
}
2026-02-13 23:49:55 -08:00
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,
2026-02-14 22:06:30 -08:00
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
2026-02-13 23:49:55 -08:00
})),
...searches.map((search) => ({
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
2026-02-14 22:06:30 -08:00
initiatedProvider: null,
initiatedModel: null,
lastUsedProvider: null,
lastUsedModel: null,
2026-02-13 23:49:55 -08:00
})),
];
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
2026-02-13 23:15:12 -08:00
function formatDate(value: string) {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(new Date(value));
}
2026-05-02 15:44:31 -07:00
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<Array<{ label: string; items: SidebarItem[] }>>((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;
}, []);
}
2026-02-13 23:15:12 -08:00
export default function App() {
2026-02-14 00:22:19 -08:00
const {
authTokenInput,
setAuthTokenInput,
isCheckingSession,
isSigningIn,
isAuthenticated,
authMode,
authError,
handleAuthFailure: baseHandleAuthFailure,
handleSignIn,
logout,
} = useSessionAuth();
2026-02-13 23:15:12 -08:00
const [chats, setChats] = useState<ChatSummary[]>([]);
2026-02-13 23:49:55 -08:00
const [searches, setSearches] = useState<SearchSummary[]>([]);
const [selectedItem, setSelectedItem] = useState<SidebarSelection | null>(null);
2026-02-13 23:15:12 -08:00
const [selectedChat, setSelectedChat] = useState<ChatDetail | null>(null);
2026-02-13 23:49:55 -08:00
const [selectedSearch, setSelectedSearch] = useState<SearchDetail | null>(null);
2026-02-14 00:09:06 -08:00
const [draftKind, setDraftKind] = useState<DraftSelectionKind | null>(null);
2026-02-13 23:49:55 -08:00
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [isLoadingSelection, setIsLoadingSelection] = useState(false);
2026-02-13 23:15:12 -08:00
const [isSending, setIsSending] = useState(false);
2026-05-02 16:48:01 -07:00
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
2026-02-14 20:51:52 -08:00
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
2026-02-13 23:15:12 -08:00
const [composer, setComposer] = useState("");
2026-05-02 19:21:06 -07:00
const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([]);
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
2026-02-13 23:15:12 -08:00
const [provider, setProvider] = useState<Provider>("openai");
2026-02-14 21:00:30 -08:00
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];
});
2026-05-02 23:48:01 -07:00
const [quickProvider, setQuickProvider] = useState<Provider>(() => loadStoredQuickQuestionModelSelection().provider);
const [quickProviderModelPreferences, setQuickProviderModelPreferences] = useState<ProviderModelPreferences>(
() => 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<string | null>(null);
const [quickSubmittedModelSelection, setQuickSubmittedModelSelection] = useState<{ provider: Provider; model: string } | null>(null);
const [quickQuestionMessages, setQuickQuestionMessages] = useState<Message[]>([]);
const [isQuickQuestionSending, setIsQuickQuestionSending] = useState(false);
const [isConvertingQuickQuestion, setIsConvertingQuickQuestion] = useState(false);
const [quickQuestionError, setQuickQuestionError] = useState<string | null>(null);
2026-02-13 23:15:12 -08:00
const [error, setError] = useState<string | null>(null);
2026-05-02 18:25:20 -07:00
const [transcriptTailSpacerHeight, setTranscriptTailSpacerHeight] = useState(TRANSCRIPT_BOTTOM_GAP);
2026-02-19 22:19:43 -08:00
const transcriptContainerRef = useRef<HTMLDivElement>(null);
2026-02-13 23:15:12 -08:00
const transcriptEndRef = useRef<HTMLDivElement>(null);
2026-02-14 01:10:27 -08:00
const contextMenuRef = useRef<HTMLDivElement>(null);
2026-05-02 19:21:06 -07:00
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
2026-02-14 21:15:54 -08:00
const selectedItemRef = useRef<SidebarSelection | null>(null);
2026-02-14 21:27:44 -08:00
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
2026-02-14 01:53:34 -08:00
const searchRunAbortRef = useRef<AbortController | null>(null);
2026-05-02 23:48:01 -07:00
const quickQuestionAbortRef = useRef<AbortController | null>(null);
2026-02-14 01:53:34 -08:00
const searchRunCounterRef = useRef(0);
2026-02-19 22:19:43 -08:00
const shouldAutoScrollRef = useRef(true);
const wasSendingRef = useRef(false);
2026-05-02 17:53:45 -07:00
const pendingReplyScrollRef = useRef(false);
2026-05-02 18:25:20 -07:00
const transcriptTailSpacerHeightRef = useRef(TRANSCRIPT_BOTTOM_GAP);
2026-02-14 01:10:27 -08:00
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
2026-02-14 22:22:05 -08:00
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
2026-05-02 15:44:31 -07:00
const [sidebarQuery, setSidebarQuery] = useState("");
2026-02-15 22:57:10 -08:00
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
const hasSyncedSelectionHistoryRef = useRef(false);
2026-02-13 23:15:12 -08:00
2026-05-02 18:25:20 -07:00
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();
});
};
2026-02-14 21:07:31 -08:00
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]);
2026-05-02 19:21:06 -07:00
useEffect(() => {
pendingAttachmentsRef.current = pendingAttachments;
}, [pendingAttachments]);
2026-02-13 23:49:55 -08:00
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
2026-05-02 15:44:31 -07:00
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]);
2026-02-13 23:49:55 -08:00
2026-02-14 00:22:19 -08:00
const resetWorkspaceState = () => {
2026-02-13 23:15:12 -08:00
setChats([]);
2026-02-13 23:49:55 -08:00
setSearches([]);
setSelectedItem(null);
2026-02-13 23:15:12 -08:00
setSelectedChat(null);
2026-02-13 23:49:55 -08:00
setSelectedSearch(null);
2026-02-14 00:09:06 -08:00
setDraftKind(null);
2026-02-14 20:51:52 -08:00
setPendingChatState(null);
2026-02-14 00:22:19 -08:00
setComposer("");
2026-05-02 19:21:06 -07:00
setPendingAttachments([]);
2026-05-02 23:48:01 -07:00
setIsQuickQuestionOpen(false);
setQuickPrompt("");
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
2026-02-14 00:22:19 -08:00
setError(null);
};
const handleAuthFailure = (message: string) => {
baseHandleAuthFailure(message);
resetWorkspaceState();
2026-02-13 23:15:12 -08:00
};
2026-02-13 23:49:55 -08:00
const refreshCollections = async (preferredSelection?: SidebarSelection) => {
setIsLoadingCollections(true);
2026-02-13 23:15:12 -08:00
try {
2026-02-13 23:49:55 -08:00
const [nextChats, nextSearches] = await Promise.all([listChats(), listSearches()]);
const nextItems = buildSidebarItems(nextChats, nextSearches);
2026-02-13 23:15:12 -08:00
setChats(nextChats);
2026-02-13 23:49:55 -08:00
setSearches(nextSearches);
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
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;
2026-02-13 23:15:12 -08:00
}
2026-02-13 23:49:55 -08:00
if (hasItem(current)) {
2026-02-13 23:15:12 -08:00
return current;
}
2026-02-13 23:49:55 -08:00
const first = nextItems[0];
return first ? { kind: first.kind, id: first.id } : null;
2026-02-13 23:15:12 -08:00
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
2026-02-13 23:49:55 -08:00
setIsLoadingCollections(false);
2026-02-13 23:15:12 -08:00
}
};
2026-02-14 21:00:30 -08:00
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);
}
}
};
2026-02-13 23:15:12 -08:00
const refreshChat = async (chatId: string) => {
2026-02-13 23:49:55 -08:00
setIsLoadingSelection(true);
2026-02-13 23:15:12 -08:00
try {
const chat = await getChat(chatId);
setSelectedChat(chat);
2026-02-13 23:49:55 -08:00
setSelectedSearch(null);
2026-02-13 23:15:12 -08:00
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
} finally {
2026-02-13 23:49:55 -08:00
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);
2026-02-13 23:15:12 -08:00
}
};
useEffect(() => {
if (!isAuthenticated) return;
2026-02-15 22:57:10 -08:00
const preferredSelection = initialRouteSelectionRef.current;
initialRouteSelectionRef.current = null;
void Promise.all([refreshCollections(preferredSelection ?? undefined), refreshModels()]);
2026-02-13 23:15:12 -08:00
}, [isAuthenticated]);
2026-02-15 22:57:10 -08:00
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]);
2026-02-14 21:00:30 -08:00
const providerModelOptions = useMemo(() => getModelOptions(modelCatalog, provider), [modelCatalog, provider]);
2026-05-02 23:48:01 -07:00
const quickProviderModelOptions = useMemo(() => getModelOptions(modelCatalog, quickProvider), [modelCatalog, quickProvider]);
2026-02-14 21:00:30 -08:00
useEffect(() => {
if (model.trim()) return;
2026-02-14 21:00:30 -08:00
setModel((current) => {
return current.trim() || pickProviderModel(providerModelOptions, providerModelPreferences[provider]);
2026-02-14 21:00:30 -08:00
});
}, [model, provider, providerModelOptions, providerModelPreferences]);
2026-02-14 21:00:30 -08:00
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(MODEL_PREFERENCES_STORAGE_KEY, JSON.stringify(providerModelPreferences));
}, [providerModelPreferences]);
2026-05-02 23:48:01 -07:00
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]);
2026-02-13 23:49:55 -08:00
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
2026-05-02 17:53:45 -07:00
const isChatReplyStreamingInView =
isSending &&
draftKind !== "search" &&
selectedItem?.kind !== "search" &&
!!pendingChatState &&
(!pendingChatState.chatId || (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId));
2026-02-13 23:49:55 -08:00
2026-02-19 22:19:43 -08:00
useEffect(() => {
shouldAutoScrollRef.current = true;
2026-05-02 18:25:20 -07:00
if (!isSending || !isChatReplyStreamingInView) {
setTranscriptTailSpacer(TRANSCRIPT_BOTTOM_GAP);
}
2026-02-19 22:19:43 -08:00
}, [draftKind, selectedItem?.kind, selectedKey]);
2026-02-14 21:15:54 -08:00
useEffect(() => {
selectedItemRef.current = selectedItem;
}, [selectedItem]);
2026-02-13 23:15:12 -08:00
useEffect(() => {
if (!isAuthenticated) {
setSelectedChat(null);
2026-02-13 23:49:55 -08:00
setSelectedSearch(null);
2026-02-13 23:15:12 -08:00
return;
}
2026-02-13 23:49:55 -08:00
if (!selectedItem) {
2026-02-13 23:15:12 -08:00
setSelectedChat(null);
2026-02-13 23:49:55 -08:00
setSelectedSearch(null);
2026-02-13 23:15:12 -08:00
return;
}
2026-02-13 23:49:55 -08:00
if (selectedItem.kind === "chat") {
void refreshChat(selectedItem.id);
return;
}
void refreshSearch(selectedItem.id);
}, [isAuthenticated, selectedKey]);
2026-02-13 23:15:12 -08:00
useEffect(() => {
2026-02-14 00:09:06 -08:00
if (draftKind === "search" || selectedItem?.kind === "search") return;
const wasSending = wasSendingRef.current;
wasSendingRef.current = isSending;
2026-05-02 17:53:45 -07:00
if (isSending) return;
if (wasSending) {
shouldAutoScrollRef.current = false;
return;
}
2026-02-19 22:19:43 -08:00
if (!shouldAutoScrollRef.current) return;
2026-05-02 17:53:45 -07:00
transcriptEndRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
}, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind, selectedKey]);
2026-05-02 17:53:45 -07:00
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]);
2026-02-13 23:15:12 -08:00
2026-02-14 01:53:34 -08:00
useEffect(() => {
return () => {
searchRunAbortRef.current?.abort();
searchRunAbortRef.current = null;
2026-05-02 23:48:01 -07:00
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
2026-02-14 01:53:34 -08:00
};
}, []);
2026-02-13 23:15:12 -08:00
const messages = selectedChat?.messages ?? [];
2026-02-14 20:51:52 -08:00
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isSearchRunning = isSending && isSearchMode;
2026-05-02 17:53:45 -07:00
const isSendingActiveChat = isChatReplyStreamingInView;
2026-05-02 19:21:06 -07:00
useEffect(() => {
if (isSearchMode && pendingAttachments.length) {
setPendingAttachments([]);
}
if (isSearchMode) {
dragDepthRef.current = 0;
setIsComposerDropActive(false);
}
}, [isSearchMode, pendingAttachments.length]);
2026-02-14 20:51:52 -08:00
const displayMessages = useMemo(() => {
2026-05-02 16:48:01 -07:00
if (!pendingChatState) return messages.filter(isDisplayableMessage);
2026-02-14 20:51:52 -08:00
if (pendingChatState.chatId) {
if (selectedItem?.kind === "chat" && selectedItem.id === pendingChatState.chatId) {
2026-05-02 16:48:01 -07:00
return pendingChatState.messages.filter(isDisplayableMessage);
2026-02-14 20:51:52 -08:00
}
2026-05-02 16:48:01 -07:00
return messages.filter(isDisplayableMessage);
2026-02-14 20:51:52 -08:00
}
2026-05-02 16:48:01 -07:00
return (isSearchMode ? messages : pendingChatState.messages).filter(isDisplayableMessage);
2026-02-14 20:51:52 -08:00
}, [isSearchMode, messages, pendingChatState, selectedItem]);
2026-05-02 23:48:01 -07:00
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;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
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]);
2026-02-14 22:06:30 -08:00
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]);
2026-02-13 23:49:55 -08:00
const selectedTitle = useMemo(() => {
2026-02-14 00:09:06 -08:00
if (draftKind === "chat") return "New chat";
if (draftKind === "search") return "New search";
2026-02-13 23:49:55 -08:00
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";
2026-02-14 00:09:06 -08:00
}, [draftKind, selectedChat, selectedChatSummary, selectedItem, selectedSearch, selectedSearchSummary]);
2026-02-13 23:49:55 -08:00
2026-02-14 21:31:09 -08:00
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]);
2026-05-02 22:45:15 -07:00
const primaryShortcutModifier = useMemo(() => {
if (typeof navigator === "undefined") return "Ctrl";
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) ? "Cmd" : "Ctrl";
}, []);
2026-02-14 21:31:09 -08:00
useEffect(() => {
document.title = pageTitle;
}, [pageTitle]);
2026-02-14 00:09:06 -08:00
const handleCreateChat = () => {
2026-02-13 23:15:12 -08:00
setError(null);
2026-02-14 01:10:27 -08:00
setContextMenu(null);
2026-02-14 00:09:06 -08:00
setDraftKind("chat");
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
2026-05-02 19:21:06 -07:00
setPendingAttachments([]);
2026-02-14 22:22:05 -08:00
setIsMobileSidebarOpen(false);
2026-02-13 23:15:12 -08:00
};
2026-05-02 23:48:01 -07:00
const handleOpenQuickQuestion = () => {
setQuickQuestionError(null);
setIsQuickQuestionOpen(true);
setIsMobileSidebarOpen(false);
};
2026-02-14 00:09:06 -08:00
const handleCreateSearch = () => {
2026-02-13 23:15:12 -08:00
setError(null);
2026-02-14 01:10:27 -08:00
setContextMenu(null);
2026-02-14 00:09:06 -08:00
setDraftKind("search");
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
2026-05-02 19:21:06 -07:00
setPendingAttachments([]);
2026-02-14 22:22:05 -08:00
setIsMobileSidebarOpen(false);
2026-02-13 23:49:55 -08:00
};
2026-02-13 23:15:12 -08:00
2026-05-02 22:45:15 -07:00
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();
2026-05-02 23:48:01 -07:00
if (key === "i" && !event.shiftKey) {
event.preventDefault();
setQuickQuestionError(null);
setIsQuickQuestionOpen((current) => !current);
return;
}
if (isQuickQuestionOpen) return;
2026-05-02 22:45:15 -07:00
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);
2026-05-02 23:48:01 -07:00
}, [filteredSidebarItems, isAuthenticated, isQuickQuestionOpen]);
2026-05-02 22:45:15 -07:00
2026-02-14 01:10:27 -08:00
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]);
2026-05-02 23:48:01 -07:00
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]);
2026-05-02 19:21:06 -07:00
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[]) => {
2026-05-02 17:53:45 -07:00
pendingReplyScrollRef.current = true;
2026-05-02 18:25:20 -07:00
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
2026-05-02 17:53:45 -07:00
2026-02-14 20:51:52 -08:00
const optimisticUserMessage: Message = {
id: `temp-user-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "user",
content,
name: null,
2026-05-02 19:21:06 -07:00
metadata: attachments.length ? { attachments } : null,
2026-02-14 20:51:52 -08:00
};
const optimisticAssistantMessage: Message = {
id: `temp-assistant-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "assistant",
content: "",
name: null,
metadata: null,
2026-02-14 20:51:52 -08:00
};
setPendingChatState({
chatId: selectedItem?.kind === "chat" ? selectedItem.id : null,
messages: (selectedChat?.messages ?? []).concat(optimisticUserMessage, optimisticAssistantMessage),
});
2026-02-14 00:09:06 -08:00
let chatId = draftKind === "chat" ? null : selectedItem?.kind === "chat" ? selectedItem.id : null;
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
if (!chatId) {
const chat = await createChat();
chatId = chat.id;
2026-02-14 00:09:06 -08:00
setDraftKind(null);
2026-02-14 21:27:44 -08:00
setChats((current) => {
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
return [chat, ...withoutExisting];
});
2026-02-13 23:49:55 -08:00
setSelectedItem({ kind: "chat", id: chatId });
2026-02-14 20:51:52 -08:00
setPendingChatState((current) => (current ? { ...current, chatId } : current));
2026-02-13 23:49:55 -08:00
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
2026-02-14 22:06:30 -08:00
initiatedProvider: chat.initiatedProvider,
initiatedModel: chat.initiatedModel,
lastUsedProvider: chat.lastUsedProvider,
lastUsedModel: chat.lastUsedModel,
2026-02-13 23:49:55 -08:00
messages: [],
});
setSelectedSearch(null);
}
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
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) => ({
2026-05-02 19:21:06 -07:00
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
...(getMessageAttachments(message.metadata).length ? { attachments: getMessageAttachments(message.metadata) } : {}),
})),
2026-02-13 23:49:55 -08:00
{
role: "user",
content,
2026-05-02 19:21:06 -07:00
...(attachments.length ? { attachments } : {}),
2026-02-13 23:49:55 -08:00
},
];
2026-02-14 21:00:30 -08:00
const selectedModel = model.trim();
if (!selectedModel) {
throw new Error("No model available for selected provider");
}
2026-02-14 21:27:44 -08:00
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);
2026-05-02 19:21:06 -07:00
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
void suggestChatTitle({ chatId, content: titleSeed })
2026-02-14 21:27:44 -08:00
.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);
});
}
2026-02-14 21:15:54 -08:00
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),
],
};
});
},
2026-02-14 21:15:54 -08:00
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);
}
2026-02-13 23:15:12 -08:00
2026-02-14 21:15:54 -08:00
await refreshCollections();
const currentSelection = selectedItemRef.current;
if (currentSelection?.kind === "chat" && currentSelection.id === chatId) {
await refreshChat(chatId);
}
2026-05-02 18:25:20 -07:00
settleTranscriptTailSpacer();
2026-02-14 20:51:52 -08:00
setPendingChatState(null);
2026-02-13 23:49:55 -08:00
};
const handleSendSearch = async (query: string) => {
2026-02-14 01:53:34 -08:00
const runId = ++searchRunCounterRef.current;
searchRunAbortRef.current?.abort();
const abortController = new AbortController();
searchRunAbortRef.current = abortController;
2026-02-14 00:09:06 -08:00
let searchId = draftKind === "search" ? null : selectedItem?.kind === "search" ? selectedItem.id : null;
2026-02-13 23:49:55 -08:00
if (!searchId) {
2026-02-14 00:09:06 -08:00
const search = await createSearch({
query,
title: query.slice(0, 80),
});
2026-02-13 23:49:55 -08:00
searchId = search.id;
2026-02-14 00:09:06 -08:00
setDraftKind(null);
2026-02-13 23:49:55 -08:00
setSelectedItem({ kind: "search", id: searchId });
}
if (!searchId) {
throw new Error("Unable to initialize search");
}
2026-02-13 23:56:35 -08:00
const nowIso = new Date().toISOString();
2026-02-13 23:49:55 -08:00
setSelectedSearch((current) => {
2026-02-13 23:56:35 -08:00
if (!current || current.id !== searchId) {
return {
id: searchId,
title: query.slice(0, 80),
query,
createdAt: nowIso,
updatedAt: nowIso,
requestId: null,
latencyMs: null,
error: null,
2026-02-14 00:14:10 -08:00
answerText: null,
answerRequestId: null,
answerCitations: null,
answerError: null,
2026-02-13 23:56:35 -08:00
results: [],
};
}
2026-02-13 23:49:55 -08:00
return {
...current,
title: query.slice(0, 80),
query,
error: null,
2026-02-13 23:56:35 -08:00
latencyMs: null,
2026-02-14 00:14:10 -08:00
answerText: null,
answerRequestId: null,
answerCitations: null,
answerError: null,
2026-02-13 23:49:55 -08:00
results: [],
2026-02-13 23:15:12 -08:00
};
2026-02-13 23:49:55 -08:00
});
2026-02-13 23:15:12 -08:00
2026-02-14 01:53:34 -08:00
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;
}
}
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
await refreshCollections({ kind: "search", id: searchId });
};
2026-05-02 16:48:01 -07:00
const handleStartChatFromSearch = async () => {
if (!selectedSearch || isStartingSearchChat || isSending) return;
setError(null);
setIsStartingSearchChat(true);
try {
const chat = await createChatFromSearch(selectedSearch.id);
setDraftKind(null);
setPendingChatState(null);
setComposer("");
2026-05-02 19:21:06 -07:00
setPendingAttachments([]);
2026-05-02 16:48:01 -07:00
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);
}
};
2026-05-02 23:48:01 -07:00
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);
}
};
2026-02-13 23:49:55 -08:00
const handleSend = async () => {
const content = composer.trim();
2026-05-02 19:21:06 -07:00
const attachments = pendingAttachments;
if ((!content && !attachments.length) || isSending) return;
if (isSearchMode && attachments.length) {
setError("Attachments are only available in chat mode.");
return;
}
2026-02-13 23:49:55 -08:00
setComposer("");
2026-05-02 19:21:06 -07:00
setPendingAttachments([]);
2026-02-13 23:49:55 -08:00
setError(null);
setIsSending(true);
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
try {
if (isSearchMode) {
await handleSendSearch(content);
} else {
2026-05-02 19:21:06 -07:00
await handleSendChat(content, attachments);
2026-02-13 23:49:55 -08:00
}
2026-02-13 23:15:12 -08:00
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("bearer token")) {
handleAuthFailure(message);
} else {
setError(message);
}
2026-02-13 23:49:55 -08:00
2026-02-14 20:51:52 -08:00
if (!isSearchMode) {
2026-05-02 19:21:06 -07:00
setComposer(content);
setPendingAttachments(attachments);
2026-02-14 20:51:52 -08:00
setPendingChatState(null);
}
2026-02-13 23:49:55 -08:00
if (selectedItem?.kind === "chat") {
await refreshChat(selectedItem.id);
}
if (selectedItem?.kind === "search") {
await refreshSearch(selectedItem.id);
2026-02-13 23:15:12 -08:00
}
} finally {
setIsSending(false);
focusComposer();
2026-02-13 23:15:12 -08:00
}
};
if (isCheckingSession) {
return (
2026-02-13 23:20:57 -08:00
<div className="flex h-full items-center justify-center">
2026-02-13 23:15:12 -08:00
<p className="text-sm text-muted-foreground">Checking session...</p>
</div>
);
}
if (!isAuthenticated) {
return (
2026-02-14 00:22:19 -08:00
<AuthScreen
authTokenInput={authTokenInput}
setAuthTokenInput={setAuthTokenInput}
isSigningIn={isSigningIn}
authError={authError}
onSignIn={handleSignIn}
/>
2026-02-13 23:15:12 -08:00
);
}
return (
2026-05-02 15:44:31 -07:00
<div className="app-grid-surface h-full p-0 md:p-2">
<div className="flex h-full w-full overflow-hidden bg-transparent md:gap-2">
2026-02-14 22:22:05 -08:00
{isMobileSidebarOpen ? (
<button
type="button"
2026-05-02 15:44:31 -07:00
className="fixed inset-0 z-30 bg-black/70 backdrop-blur-sm md:hidden"
2026-02-14 22:22:05 -08:00
onClick={() => setIsMobileSidebarOpen(false)}
aria-label="Close sidebar"
/>
) : null}
<aside
className={cn(
2026-05-02 15:44:31 -07:00
"glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
2026-02-14 22:22:05 -08:00
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
2026-05-03 17:00:45 -07:00
<div className="relative min-h-24 px-4 pb-4 pt-5">
<div className="pr-24">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
2026-05-02 15:44:31 -07:00
</div>
2026-05-03 17:00:45 -07:00
<img
aria-hidden="true"
alt=""
className="absolute right-4 top-3 aspect-square h-[calc(100%-1.5rem)] rounded-xl border border-violet-200/24 bg-white/6 object-cover p-1 shadow-[inset_0_1px_0_hsl(252_90%_86%/0.12),0_10px_24px_hsl(240_80%_2%/0.3)]"
draggable={false}
src="/character-idle.gif"
/>
2026-05-02 15:44:31 -07:00
</div>
<div className="space-y-3 px-3 pb-3">
2026-05-02 23:48:01 -07:00
<div className="flex gap-2">
<Button className="h-11 min-w-0 flex-1 justify-start gap-3 text-[15px]" onClick={handleCreateChat}>
<Plus className="h-4 w-4" />
New chat
<span className="ml-auto rounded-md border border-violet-100/12 bg-white/5 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/52">
{primaryShortcutModifier} J
</span>
</Button>
<div className="group relative">
<Button
className="h-11 w-11 rounded-lg"
onClick={handleOpenQuickQuestion}
size="icon"
variant="secondary"
title={`${primaryShortcutModifier}+i`}
aria-label="Quick question"
>
<Rabbit className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute left-1/2 top-full z-50 mt-2 -translate-x-1/2 whitespace-nowrap rounded-md border border-violet-300/22 bg-[hsl(238_48%_7%)] px-2 py-1 text-xs font-semibold text-violet-100/90 opacity-0 shadow-xl shadow-black/35 transition group-hover:opacity-100 group-focus-within:opacity-100">
{primaryShortcutModifier}+i
</span>
</div>
</div>
2026-05-02 15:44:31 -07:00
<Button className="h-10 w-full justify-start gap-3" variant="secondary" onClick={handleCreateSearch}>
2026-02-13 23:49:55 -08:00
<Search className="h-4 w-4" />
New search
2026-05-02 22:45:15 -07:00
<span className="ml-auto rounded-md border border-violet-100/10 bg-white/[0.035] px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-violet-100/44">
Shift {primaryShortcutModifier} J
</span>
2026-02-13 23:49:55 -08:00
</Button>
2026-05-02 15:44:31 -07:00
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
<input
value={sidebarQuery}
onInput={(event) => 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"
/>
</div>
2026-02-13 23:15:12 -08:00
</div>
2026-05-02 15:44:31 -07:00
<Separator className="bg-violet-300/10" />
<div className="flex-1 overflow-y-auto px-2 py-3">
2026-02-14 00:22:19 -08:00
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
2026-02-13 23:49:55 -08:00
{!isLoadingCollections && sidebarItems.length === 0 ? (
2026-02-13 23:15:12 -08:00
<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" />
2026-02-13 23:49:55 -08:00
Start a chat or run your first search.
2026-02-13 23:15:12 -08:00
</div>
) : null}
2026-05-02 15:44:31 -07:00
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
) : null}
{sidebarSections.map((section) => (
<div key={section.label} className="mb-4">
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
{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 (
<button
key={`${item.kind}-${item.id}`}
className={cn(
"mb-1 w-full rounded-lg border px-3 py-2.5 text-left transition",
active
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
)}
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">
<span
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border",
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
)}
>
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
</span>
<p className="truncate text-sm font-semibold">{item.title}</p>
<p className={cn("ml-auto shrink-0 text-xs", active ? "text-violet-100/86" : "text-violet-200/50")}>{formatDate(item.updatedAt)}</p>
</div>
{initiatedLabel ? (
<p className={cn("mt-1 truncate text-right text-xs", active ? "text-violet-100/62" : "text-violet-200/42")}>{initiatedLabel}</p>
) : null}
</button>
);
})}
</div>
))}
2026-02-13 23:15:12 -08:00
</div>
</aside>
2026-05-02 15:44:31 -07:00
<main className="glass-panel relative flex min-w-0 flex-1 flex-col overflow-hidden border-violet-300/18 md:rounded-2xl md:border">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:px-7">
2026-02-14 22:22:05 -08:00
<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>
2026-05-02 15:44:31 -07:00
<h1 className="text-sm font-semibold text-violet-50 md:text-base">{selectedTitle}</h1>
2026-02-14 22:22:05 -08:00
</div>
2026-02-13 23:15:12 -08:00
</div>
<div className="flex w-full max-w-xl items-center gap-2 md:w-auto">
2026-02-13 23:49:55 -08:00
{!isSearchMode ? (
<>
<select
2026-05-02 15:44:31 -07:00
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
2026-02-13 23:49:55 -08:00
value={provider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setProvider(nextProvider);
2026-02-14 21:00:30 -08:00
const options = getModelOptions(modelCatalog, nextProvider);
setModel(pickProviderModel(options, providerModelPreferences[nextProvider]));
2026-02-13 23:49:55 -08:00
}}
disabled={isSending}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
2026-02-14 21:00:30 -08:00
<ModelCombobox
options={providerModelOptions}
value={model}
disabled={isSending}
2026-02-14 21:00:30 -08:00
onChange={(nextModel) => {
const normalizedModel = nextModel.trim();
setModel(normalizedModel);
2026-02-14 21:00:30 -08:00
setProviderModelPreferences((current) => ({
...current,
[provider]: normalizedModel || null,
2026-02-14 21:00:30 -08:00
}));
}}
/>
2026-02-13 23:49:55 -08:00
</>
) : (
2026-05-02 15:44:31 -07:00
<div className="flex h-10 items-center rounded-lg border border-cyan-300/22 bg-cyan-300/8 px-3 text-sm text-cyan-100">
2026-02-13 23:49:55 -08:00
<Globe2 className="mr-2 h-4 w-4" />
Search mode
</div>
)}
2026-02-13 23:15:12 -08:00
</div>
</header>
2026-02-19 22:19:43 -08:00
<div
ref={transcriptContainerRef}
2026-05-02 17:53:45 -07:00
className="flex-1 overflow-y-auto px-4 pt-8 md:px-10 lg:px-14 pb-36 md:pb-44 [overflow-anchor:none]"
2026-02-19 22:19:43 -08:00
onScroll={() => {
const container = transcriptContainerRef.current;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
shouldAutoScrollRef.current = distanceFromBottom < 96;
}}
>
2026-02-13 23:49:55 -08:00
{!isSearchMode ? (
2026-02-14 21:15:54 -08:00
<ChatMessagesPanel messages={displayMessages} isLoading={isLoadingSelection} isSending={isSendingActiveChat} />
2026-02-13 23:49:55 -08:00
) : (
2026-05-02 16:48:01 -07:00
<SearchResultsPanel
search={selectedSearch}
isLoading={isLoadingSelection}
isRunning={isSearchRunning}
isStartingChat={isStartingSearchChat}
onStartChat={selectedSearch ? handleStartChatFromSearch : undefined}
/>
2026-02-13 23:49:55 -08:00
)}
2026-05-02 18:25:20 -07:00
<div
className="mx-auto max-w-4xl"
style={{ height: `${transcriptTailSpacerHeight}px` }}
aria-hidden="true"
/>
2026-02-13 23:15:12 -08:00
<div ref={transcriptEndRef} />
</div>
2026-05-02 15:44:31 -07:00
<footer className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-[linear-gradient(to_top,hsl(235_50%_4%)_0%,hsl(235_50%_4%_/_0.92)_58%,transparent)] p-3 pt-14 md:p-6 md:pt-20">
2026-05-02 19:21:06 -07:00
<div
className={cn(
"pointer-events-auto mx-auto max-w-4xl rounded-2xl border bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20 transition",
isComposerDropActive
? "border-cyan-300/70 shadow-cyan-500/20"
: "border-violet-300/30"
)}
onDragEnter={handleComposerDragEnter}
onDragOver={handleComposerDragOver}
onDragLeave={handleComposerDragLeave}
onDrop={(event) => {
void handleComposerDrop(event);
}}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={CHAT_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
void handleFileSelection(event);
}}
/>
{!isSearchMode && pendingAttachments.length ? (
<div className="px-2 pb-2 pt-1">
<ChatAttachmentList attachments={pendingAttachments} onRemove={handleRemovePendingAttachment} />
</div>
) : null}
{!isSearchMode && isComposerDropActive ? (
<div className="px-3 pb-2">
<div className="rounded-xl border border-dashed border-cyan-300/55 bg-cyan-300/8 px-4 py-3 text-sm text-cyan-100">
Drop files to attach them
</div>
</div>
) : null}
2026-02-13 23:15:12 -08:00
<Textarea
2026-02-14 21:07:31 -08:00
id="composer-input"
rows={1}
2026-02-13 23:15:12 -08:00
value={composer}
2026-02-14 21:07:31 -08:00
onInput={(event) => {
const textarea = event.currentTarget;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
setComposer(textarea.value);
}}
2026-05-02 19:21:06 -07:00
onPaste={(event) => {
void handleComposerPaste(event);
}}
2026-02-13 23:15:12 -08:00
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
2026-05-03 17:00:45 -07:00
placeholder={isSearchMode ? "Search the web" : "Enter prompt..."}
2026-05-02 15:44:31 -07:00
className="max-h-40 min-h-0 resize-none overflow-y-auto border-0 bg-transparent px-3 py-3 text-base text-violet-50 shadow-none placeholder:text-violet-200/45 focus-visible:ring-0"
2026-02-13 23:15:12 -08:00
disabled={isSending}
/>
2026-05-02 15:44:31 -07:00
<div className={cn("flex items-center gap-3 px-2 pb-1", error ? "justify-between" : "justify-end")}>
{error ? <p className="min-w-0 truncate text-xs text-rose-300">{error}</p> : null}
2026-05-02 19:21:06 -07:00
{!isSearchMode ? (
<Button
className="h-10 w-10 rounded-lg"
onClick={handleOpenAttachmentPicker}
size="icon"
variant="secondary"
disabled={isSending || pendingAttachments.length >= MAX_CHAT_ATTACHMENTS}
aria-label="Attach files"
>
<Paperclip className="h-4 w-4" />
</Button>
) : null}
<Button
className="h-10 w-10 rounded-lg"
onClick={() => void handleSend()}
size="icon"
disabled={isSending || (!composer.trim() && !pendingAttachments.length)}
>
2026-02-13 23:49:55 -08:00
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
2026-02-13 23:15:12 -08:00
</Button>
</div>
</div>
</footer>
</main>
</div>
2026-02-14 01:10:27 -08:00
{contextMenu ? (
<div
ref={contextMenuRef}
2026-05-02 15:44:31 -07:00
className="fixed z-50 min-w-40 rounded-lg border border-violet-300/20 bg-[hsl(238_48%_7%)] p-1 shadow-2xl shadow-black/45"
2026-02-14 01:10:27 -08:00
style={{ left: contextMenu.x, top: contextMenu.y }}
onContextMenu={(event) => event.preventDefault()}
>
<button
type="button"
2026-05-02 15:44:31 -07:00
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-rose-300 transition hover:bg-rose-500/12 disabled:text-muted-foreground"
2026-02-14 01:10:27 -08:00
onClick={() => void handleDeleteFromContextMenu()}
disabled={isSending}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
</div>
) : null}
2026-05-02 23:48:01 -07:00
{isQuickQuestionOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="quick-question-title"
className="glass-panel flex max-h-[88vh] w-full max-w-3xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
>
<div className="mb-3 flex items-center justify-between gap-3">
<h2 id="quick-question-title" className="text-sm font-semibold text-violet-50">
Quick question
</h2>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => setIsQuickQuestionOpen(false)}
aria-label="Close quick question"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3">
<Textarea
id="quick-question-input"
rows={2}
value={quickPrompt}
onInput={(event) => {
const textarea = event.currentTarget;
textarea.style.height = "0px";
textarea.style.height = `${textarea.scrollHeight}px`;
const nextPrompt = textarea.value;
if (nextPrompt !== quickPrompt) {
quickQuestionAbortRef.current?.abort();
quickQuestionAbortRef.current = null;
setIsQuickQuestionSending(false);
setQuickSubmittedPrompt(null);
setQuickSubmittedModelSelection(null);
setQuickQuestionMessages([]);
setQuickQuestionError(null);
}
setQuickPrompt(nextPrompt);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSendQuickQuestion();
}
}}
placeholder="Ask Sybil..."
className="max-h-36 min-h-[4.75rem] resize-none overflow-y-auto border-violet-300/24 bg-background/72 text-base text-violet-50 placeholder:text-violet-200/45"
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
/>
<div className="h-[min(34vh,22rem)] overflow-y-auto rounded-xl border border-violet-300/16 bg-background/38 px-3 py-4">
{quickQuestionMessages.length ? (
<ChatMessagesPanel messages={quickQuestionMessages} isLoading={false} isSending={isQuickQuestionSending} />
) : null}
{quickQuestionError ? (
<p className="text-sm text-rose-300">{quickQuestionError}</p>
) : null}
</div>
</div>
<div className="mt-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex min-w-0 flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<select
className="h-10 min-w-32 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
value={quickProvider}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
}}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider"
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
<ModelCombobox
options={quickProviderModelOptions}
value={quickModel}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
onChange={(nextModel) => {
const normalizedModel = nextModel.trim();
setQuickModel(normalizedModel);
setQuickProviderModelPreferences((current) => ({
...current,
[quickProvider]: normalizedModel || null,
}));
}}
/>
</div>
<div className="flex items-center justify-end gap-2">
<Button
type="button"
variant="secondary"
className="gap-2"
onClick={() => void handleConvertQuickQuestionToChat()}
disabled={!canConvertQuickQuestion || isConvertingQuickQuestion}
>
<MessageSquare className="h-4 w-4" />
Convert to chat
</Button>
<Button
type="button"
className="h-10 w-10 rounded-lg"
onClick={() => void handleSendQuickQuestion()}
size="icon"
disabled={isQuickQuestionSending || isConvertingQuickQuestion || !quickPrompt.trim()}
aria-label="Ask quick question"
>
<SendHorizontal className="h-4 w-4" />
</Button>
</div>
</div>
</section>
</div>
) : null}
2026-02-13 23:15:12 -08:00
</div>
);
}