adds attachment support
This commit is contained in:
359
web/src/App.tsx
359
web/src/App.tsx
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
|
||||
import { Check, ChevronDown, Globe2, Menu, MessageSquare, Paperclip, Plus, Search, SendHorizontal, Trash2 } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AuthScreen } from "@/components/auth/auth-screen";
|
||||
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
runCompletionStream,
|
||||
runSearchStream,
|
||||
suggestChatTitle,
|
||||
getMessageAttachments,
|
||||
type ChatAttachment,
|
||||
type ModelCatalogResponse,
|
||||
type Provider,
|
||||
type ChatDetail,
|
||||
@@ -102,6 +105,65 @@ const TRANSCRIPT_BOTTOM_GAP = 20;
|
||||
const REPLY_SCROLL_BUFFER_MIN = 288;
|
||||
const REPLY_SCROLL_BUFFER_MAX = 576;
|
||||
const REPLY_SCROLL_BUFFER_VIEWPORT_RATIO = 0.52;
|
||||
const MAX_CHAT_ATTACHMENTS = 8;
|
||||
const MAX_IMAGE_ATTACHMENT_BYTES = 6 * 1024 * 1024;
|
||||
const MAX_TEXT_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_TEXT_ATTACHMENT_CHARS = 200_000;
|
||||
const CHAT_FILE_ACCEPT =
|
||||
".png,.jpg,.jpeg,.txt,.md,.markdown,.csv,.tsv,.json,.jsonl,.xml,.yaml,.yml,.html,.htm,.css,.js,.jsx,.ts,.tsx,.py,.rb,.java,.c,.cc,.cpp,.h,.hpp,.go,.rs,.sh,.sql,.log,.toml,.ini,.cfg,.conf,.swift,.kt,.m,.mm";
|
||||
const TEXT_ATTACHMENT_EXTENSIONS = new Set([
|
||||
".txt",
|
||||
".md",
|
||||
".markdown",
|
||||
".csv",
|
||||
".tsv",
|
||||
".json",
|
||||
".jsonl",
|
||||
".xml",
|
||||
".yaml",
|
||||
".yml",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".py",
|
||||
".rb",
|
||||
".java",
|
||||
".c",
|
||||
".cc",
|
||||
".cpp",
|
||||
".h",
|
||||
".hpp",
|
||||
".go",
|
||||
".rs",
|
||||
".sh",
|
||||
".sql",
|
||||
".log",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
".conf",
|
||||
".swift",
|
||||
".kt",
|
||||
".m",
|
||||
".mm",
|
||||
]);
|
||||
const TEXT_ATTACHMENT_MIME_TYPES = new Set([
|
||||
"application/json",
|
||||
"application/ld+json",
|
||||
"application/sql",
|
||||
"application/toml",
|
||||
"application/x-httpd-php",
|
||||
"application/x-javascript",
|
||||
"application/x-sh",
|
||||
"application/xml",
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
function getModelOptions(catalog: ModelCatalogResponse["providers"], provider: Provider) {
|
||||
const providerModels = catalog[provider]?.models ?? [];
|
||||
@@ -117,6 +179,103 @@ function getReplyScrollBufferHeight() {
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function loadStoredModelPreferences() {
|
||||
if (typeof window === "undefined") return EMPTY_MODEL_PREFERENCES;
|
||||
try {
|
||||
@@ -347,8 +506,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
|
||||
|
||||
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);
|
||||
const firstUserMessage = messages?.find((message) => message.role === "user");
|
||||
const firstUserText = firstUserMessage?.content.trim();
|
||||
if (firstUserText) return firstUserText.slice(0, 48);
|
||||
const firstUserAttachments = firstUserMessage ? getMessageAttachments(firstUserMessage.metadata) : [];
|
||||
const attachmentSummary = buildAttachmentSummary(firstUserAttachments);
|
||||
if (attachmentSummary) return attachmentSummary.slice(0, 48);
|
||||
return "New chat";
|
||||
}
|
||||
|
||||
@@ -448,6 +611,8 @@ export default function App() {
|
||||
const [isStartingSearchChat, setIsStartingSearchChat] = useState(false);
|
||||
const [pendingChatState, setPendingChatState] = useState<{ chatId: string | null; messages: Message[] } | null>(null);
|
||||
const [composer, setComposer] = useState("");
|
||||
const [pendingAttachments, setPendingAttachments] = useState<ChatAttachment[]>([]);
|
||||
const [isComposerDropActive, setIsComposerDropActive] = useState(false);
|
||||
const [provider, setProvider] = useState<Provider>("openai");
|
||||
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse["providers"]>(EMPTY_MODEL_CATALOG);
|
||||
const [providerModelPreferences, setProviderModelPreferences] = useState<ProviderModelPreferences>(() => loadStoredModelPreferences());
|
||||
@@ -460,6 +625,9 @@ export default function App() {
|
||||
const transcriptContainerRef = useRef<HTMLDivElement>(null);
|
||||
const transcriptEndRef = useRef<HTMLDivElement>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
|
||||
const selectedItemRef = useRef<SidebarSelection | null>(null);
|
||||
const pendingTitleGenerationRef = useRef<Set<string>>(new Set());
|
||||
const searchRunAbortRef = useRef<AbortController | null>(null);
|
||||
@@ -518,6 +686,10 @@ export default function App() {
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
}, [composer]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingAttachmentsRef.current = pendingAttachments;
|
||||
}, [pendingAttachments]);
|
||||
|
||||
const sidebarItems = useMemo(() => buildSidebarItems(chats, searches), [chats, searches]);
|
||||
const filteredSidebarItems = useMemo(() => {
|
||||
const query = sidebarQuery.trim().toLowerCase();
|
||||
@@ -540,6 +712,7 @@ export default function App() {
|
||||
setDraftKind(null);
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
@@ -767,6 +940,16 @@ export default function App() {
|
||||
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
|
||||
const isSearchRunning = isSending && isSearchMode;
|
||||
const isSendingActiveChat = isChatReplyStreamingInView;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearchMode && pendingAttachments.length) {
|
||||
setPendingAttachments([]);
|
||||
}
|
||||
if (isSearchMode) {
|
||||
dragDepthRef.current = 0;
|
||||
setIsComposerDropActive(false);
|
||||
}
|
||||
}, [isSearchMode, pendingAttachments.length]);
|
||||
const displayMessages = useMemo(() => {
|
||||
if (!pendingChatState) return messages.filter(isDisplayableMessage);
|
||||
if (pendingChatState.chatId) {
|
||||
@@ -837,6 +1020,7 @@ export default function App() {
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -847,6 +1031,7 @@ export default function App() {
|
||||
setSelectedItem(null);
|
||||
setSelectedChat(null);
|
||||
setSelectedSearch(null);
|
||||
setPendingAttachments([]);
|
||||
setIsMobileSidebarOpen(false);
|
||||
};
|
||||
|
||||
@@ -899,7 +1084,88 @@ export default function App() {
|
||||
};
|
||||
}, [contextMenu]);
|
||||
|
||||
const handleSendChat = async (content: string) => {
|
||||
const handleOpenAttachmentPicker = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRemovePendingAttachment = (attachmentId: string) => {
|
||||
setPendingAttachments((current) => current.filter((attachment) => attachment.id !== attachmentId));
|
||||
};
|
||||
|
||||
const appendPendingAttachments = async (files: File[]) => {
|
||||
if (!files.length) return;
|
||||
if (isSearchMode) {
|
||||
setError("Attachments are only available in chat mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const attachments = await Promise.all(files.map((file) => buildChatAttachment(file)));
|
||||
if (pendingAttachmentsRef.current.length + attachments.length > MAX_CHAT_ATTACHMENTS) {
|
||||
throw new Error(`You can attach up to ${MAX_CHAT_ATTACHMENTS} files per message.`);
|
||||
}
|
||||
setPendingAttachments((current) => current.concat(attachments));
|
||||
focusComposer();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelection = async (event: Event) => {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = "";
|
||||
await appendPendingAttachments(files);
|
||||
};
|
||||
|
||||
const handleComposerPaste = async (event: ClipboardEvent) => {
|
||||
const files = getFilesFromDataTransfer(event.clipboardData);
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
await appendPendingAttachments(files);
|
||||
};
|
||||
|
||||
const handleComposerDragEnter = (event: DragEvent) => {
|
||||
if (!hasFileTransfer(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
if (isSearchMode) return;
|
||||
dragDepthRef.current += 1;
|
||||
setIsComposerDropActive(true);
|
||||
};
|
||||
|
||||
const handleComposerDragOver = (event: DragEvent) => {
|
||||
if (!hasFileTransfer(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = isSearchMode ? "none" : "copy";
|
||||
}
|
||||
if (!isSearchMode) {
|
||||
setIsComposerDropActive(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerDragLeave = (event: DragEvent) => {
|
||||
if (!hasFileTransfer(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
if (isSearchMode) return;
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setIsComposerDropActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerDrop = async (event: DragEvent) => {
|
||||
if (!hasFileTransfer(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setIsComposerDropActive(false);
|
||||
await appendPendingAttachments(getFilesFromDataTransfer(event.dataTransfer));
|
||||
};
|
||||
|
||||
const handleSendChat = async (content: string, attachments: ChatAttachment[]) => {
|
||||
pendingReplyScrollRef.current = true;
|
||||
expandTranscriptTailSpacer(getReplyScrollBufferHeight());
|
||||
|
||||
@@ -909,7 +1175,7 @@ export default function App() {
|
||||
role: "user",
|
||||
content,
|
||||
name: null,
|
||||
metadata: null,
|
||||
metadata: attachments.length ? { attachments } : null,
|
||||
};
|
||||
|
||||
const optimisticAssistantMessage: Message = {
|
||||
@@ -965,13 +1231,15 @@ export default function App() {
|
||||
...baseChat.messages
|
||||
.filter((message) => !isToolCallLogMessage(message))
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
...(getMessageAttachments(message.metadata).length ? { attachments: getMessageAttachments(message.metadata) } : {}),
|
||||
})),
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
...(attachments.length ? { attachments } : {}),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -984,7 +1252,8 @@ export default function App() {
|
||||
const hasExistingTitle = Boolean(selectedChat?.id === chatId ? selectedChat.title?.trim() : chatSummary?.title?.trim());
|
||||
if (!hasExistingTitle && !pendingTitleGenerationRef.current.has(chatId)) {
|
||||
pendingTitleGenerationRef.current.add(chatId);
|
||||
void suggestChatTitle({ chatId, content })
|
||||
const titleSeed = content || buildAttachmentSummary(attachments) || "Uploaded files";
|
||||
void suggestChatTitle({ chatId, content: titleSeed })
|
||||
.then((updatedChat) => {
|
||||
setChats((current) =>
|
||||
current.map((chat) => {
|
||||
@@ -1232,6 +1501,7 @@ export default function App() {
|
||||
setDraftKind(null);
|
||||
setPendingChatState(null);
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setChats((current) => {
|
||||
const withoutExisting = current.filter((existing) => existing.id !== chat.id);
|
||||
return [chat, ...withoutExisting];
|
||||
@@ -1265,9 +1535,15 @@ export default function App() {
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = composer.trim();
|
||||
if (!content || isSending) return;
|
||||
const attachments = pendingAttachments;
|
||||
if ((!content && !attachments.length) || isSending) return;
|
||||
if (isSearchMode && attachments.length) {
|
||||
setError("Attachments are only available in chat mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
setComposer("");
|
||||
setPendingAttachments([]);
|
||||
setError(null);
|
||||
setIsSending(true);
|
||||
|
||||
@@ -1275,7 +1551,7 @@ export default function App() {
|
||||
if (isSearchMode) {
|
||||
await handleSendSearch(content);
|
||||
} else {
|
||||
await handleSendChat(content);
|
||||
await handleSendChat(content, attachments);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -1286,6 +1562,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
if (!isSearchMode) {
|
||||
setComposer(content);
|
||||
setPendingAttachments(attachments);
|
||||
setPendingChatState(null);
|
||||
}
|
||||
|
||||
@@ -1519,7 +1797,42 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="pointer-events-auto mx-auto max-w-4xl rounded-2xl border border-violet-300/30 bg-[linear-gradient(135deg,hsl(235_48%_7%_/_0.96),hsl(258_48%_11%_/_0.94))] p-2 shadow-lg shadow-black/20">
|
||||
<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}
|
||||
<Textarea
|
||||
id="composer-input"
|
||||
rows={1}
|
||||
@@ -1530,6 +1843,9 @@ export default function App() {
|
||||
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||
setComposer(textarea.value);
|
||||
}}
|
||||
onPaste={(event) => {
|
||||
void handleComposerPaste(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
@@ -1542,7 +1858,24 @@ export default function App() {
|
||||
/>
|
||||
<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}
|
||||
<Button className="h-10 w-10 rounded-lg" onClick={() => void handleSend()} size="icon" disabled={isSending || !composer.trim()}>
|
||||
{!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)}
|
||||
>
|
||||
{isSearchMode ? <Search className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user