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

601 lines
20 KiB
TypeScript
Raw Normal View History

2026-02-13 23:15:12 -08:00
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
2026-02-14 00:22:19 -08:00
import { Globe2, LogOut, MessageSquare, Plus, Search, SendHorizontal } from "lucide-preact";
2026-02-13 23:15:12 -08:00
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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";
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-02-13 23:49:55 -08:00
createSearch,
2026-02-13 23:15:12 -08:00
getChat,
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-13 23:15:12 -08:00
runCompletion,
2026-02-13 23:49:55 -08:00
runSearch,
2026-02-13 23:15:12 -08:00
type ChatDetail,
type ChatSummary,
type CompletionRequestMessage,
2026-02-13 23:49:55 -08:00
type SearchDetail,
type SearchSummary,
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";
type Provider = "openai" | "anthropic" | "xai";
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-13 23:15:12 -08:00
const PROVIDER_DEFAULT_MODELS: Record<Provider, string> = {
openai: "gpt-4.1-mini",
anthropic: "claude-3-5-sonnet-latest",
xai: "grok-3-mini",
};
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";
}
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,
})),
...searches.map((search) => ({
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
updatedAt: search.updatedAt,
createdAt: search.createdAt,
})),
];
return items.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}
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));
}
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);
const [composer, setComposer] = useState("");
const [provider, setProvider] = useState<Provider>("openai");
const [model, setModel] = useState(PROVIDER_DEFAULT_MODELS.openai);
const [error, setError] = useState<string | null>(null);
const transcriptEndRef = useRef<HTMLDivElement>(null);
2026-02-13 23:49:55 -08:00
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
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 00:22:19 -08:00
setComposer("");
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
}
};
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-13 23:49:55 -08:00
void refreshCollections();
2026-02-13 23:15:12 -08:00
}, [isAuthenticated]);
2026-02-13 23:49:55 -08:00
const selectedKey = selectedItem ? `${selectedItem.kind}:${selectedItem.id}` : null;
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;
2026-02-13 23:15:12 -08:00
transcriptEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
2026-02-14 00:09:06 -08:00
}, [draftKind, selectedChat?.messages.length, isSending, selectedItem?.kind]);
2026-02-13 23:15:12 -08:00
const messages = selectedChat?.messages ?? [];
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]);
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 00:09:06 -08:00
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
2026-02-14 00:22:19 -08:00
const isSearchRunning = isSending && isSearchMode;
2026-02-13 23:15:12 -08:00
2026-02-14 00:09:06 -08:00
const handleCreateChat = () => {
2026-02-13 23:15:12 -08:00
setError(null);
2026-02-14 00:09:06 -08:00
setDraftKind("chat");
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
2026-02-13 23:15:12 -08:00
};
2026-02-14 00:09:06 -08:00
const handleCreateSearch = () => {
2026-02-13 23:15:12 -08:00
setError(null);
2026-02-14 00:09:06 -08:00
setDraftKind("search");
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
2026-02-13 23:49:55 -08:00
};
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
const handleSendChat = async (content: string) => {
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-13 23:49:55 -08:00
setSelectedItem({ kind: "chat", id: chatId });
setSelectedChat({
id: chat.id,
title: chat.title,
createdAt: chat.createdAt,
updatedAt: chat.updatedAt,
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 optimisticUserMessage = {
id: `temp-user-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "user" as const,
content,
name: null,
};
const optimisticAssistantMessage = {
id: `temp-assistant-${Date.now()}`,
createdAt: new Date().toISOString(),
role: "assistant" as const,
content: "",
name: null,
};
setSelectedChat((current) => {
if (!current || current.id !== chatId) return current;
return {
...current,
messages: [...current.messages, optimisticUserMessage, optimisticAssistantMessage],
2026-02-13 23:15:12 -08:00
};
2026-02-13 23:49:55 -08:00
});
const requestMessages: CompletionRequestMessage[] = [
...baseChat.messages.map((message) => ({
role: message.role,
content: message.content,
...(message.name ? { name: message.name } : {}),
})),
{
role: "user",
content,
},
];
await runCompletion({
chatId,
provider,
model: model.trim(),
messages: requestMessages,
});
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
await Promise.all([refreshCollections({ kind: "chat", id: chatId }), refreshChat(chatId)]);
};
const handleSendSearch = async (query: string) => {
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-13 23:49:55 -08:00
const search = await runSearch(searchId, {
query,
title: query.slice(0, 80),
type: "auto",
numResults: 10,
});
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
setSelectedSearch(search);
setSelectedChat(null);
await refreshCollections({ kind: "search", id: searchId });
};
const handleSend = async () => {
const content = composer.trim();
if (!content || isSending) return;
setComposer("");
setError(null);
setIsSending(true);
2026-02-13 23:15:12 -08:00
2026-02-13 23:49:55 -08:00
try {
if (isSearchMode) {
await handleSendSearch(content);
} else {
await handleSendChat(content);
}
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
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);
}
};
const handleLogout = () => {
2026-02-14 00:22:19 -08:00
logout();
resetWorkspaceState();
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-02-14 00:35:01 -08:00
<div className="h-full">
<div className="flex h-full w-full overflow-hidden bg-background">
2026-02-13 23:20:57 -08:00
<aside className="flex w-80 shrink-0 flex-col border-r bg-[#111827]">
2026-02-13 23:49:55 -08:00
<div className="grid grid-cols-2 gap-2 p-3">
<Button className="justify-start gap-2" onClick={handleCreateChat}>
2026-02-13 23:15:12 -08:00
<Plus className="h-4 w-4" />
New chat
</Button>
2026-02-13 23:49:55 -08:00
<Button className="justify-start gap-2" variant="secondary" onClick={handleCreateSearch}>
<Search className="h-4 w-4" />
New search
</Button>
2026-02-13 23:15:12 -08:00
</div>
<Separator />
<div className="flex-1 overflow-y-auto p-2">
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-02-13 23:49:55 -08:00
{sidebarItems.map((item) => {
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
2026-02-13 23:15:12 -08:00
return (
<button
2026-02-13 23:49:55 -08:00
key={`${item.kind}-${item.id}`}
2026-02-13 23:15:12 -08:00
className={cn(
"mb-1 w-full rounded-lg px-3 py-2 text-left transition",
2026-02-13 23:20:57 -08:00
active ? "bg-slate-700 text-slate-50" : "text-slate-200 hover:bg-slate-800"
2026-02-13 23:15:12 -08:00
)}
2026-02-14 00:09:06 -08:00
onClick={() => {
setDraftKind(null);
setSelectedItem({ kind: item.kind, id: item.id });
}}
2026-02-13 23:15:12 -08:00
type="button"
>
2026-02-13 23:49:55 -08:00
<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>
<p className={cn("mt-1 text-xs", active ? "text-slate-200" : "text-slate-400")}>{formatDate(item.updatedAt)}</p>
2026-02-13 23:15:12 -08:00
</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>
2026-02-13 23:49:55 -08:00
<h1 className="text-sm font-semibold md:text-base">{selectedTitle}</h1>
2026-02-13 23:15:12 -08:00
<p className="text-xs text-muted-foreground">
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
2026-02-13 23:49:55 -08:00
{isSearchMode ? " • Exa Search" : ""}
2026-02-13 23:15:12 -08:00
</p>
</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
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);
setModel(PROVIDER_DEFAULT_MODELS[nextProvider]);
}}
disabled={isSending}
>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="xai">xAI</option>
</select>
2026-02-14 00:22:19 -08:00
<Input value={model} onInput={(event) => setModel(event.currentTarget.value)} placeholder="Model" disabled={isSending} />
2026-02-13 23:49:55 -08:00
</>
) : (
<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>
)}
2026-02-13 23:15:12 -08:00
<Button variant="outline" size="sm" onClick={handleLogout}>
<LogOut className="mr-1 h-4 w-4" />
Logout
</Button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-3 py-6 md:px-10">
2026-02-13 23:49:55 -08:00
{!isSearchMode ? (
2026-02-14 00:22:19 -08:00
<ChatMessagesPanel messages={messages} isLoading={isLoadingSelection} isSending={isSending} />
2026-02-13 23:49:55 -08:00
) : (
2026-02-14 00:22:19 -08:00
<SearchResultsPanel search={selectedSearch} isLoading={isLoadingSelection} isRunning={isSearchRunning} />
2026-02-13 23:49:55 -08:00
)}
2026-02-13 23:15:12 -08:00
<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
rows={3}
value={composer}
onInput={(event) => setComposer(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend();
}
}}
2026-02-13 23:49:55 -08:00
placeholder={isSearchMode ? "Search the web" : "Message Sybil"}
2026-02-13 23:15:12 -08:00
className="resize-none border-0 shadow-none focus-visible:ring-0"
disabled={isSending}
/>
<div className="flex items-center justify-between px-2 pb-1">
2026-02-14 00:22:19 -08:00
{error ? <p className="text-xs text-red-600">{error}</p> : <span className="text-xs text-muted-foreground">{isSearchMode ? "Enter to search" : "Enter to send"}</span>}
2026-02-13 23:15:12 -08:00
<Button onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
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>
</div>
);
}