388 lines
23 KiB
TypeScript
388 lines
23 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
import { memo } from "preact/compat";
|
|
import { Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
|
import type { Message } from "@/lib/api";
|
|
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
|
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
|
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
|
import { Button } from "@/components/ui/button";
|
|
import { agentAttachments, attachmentSize } from "@/lib/agent-attachments";
|
|
import { AgentAttachmentCard } from "./agent-attachment";
|
|
import "./agent-conversation.css";
|
|
|
|
type Props = {
|
|
room: AgentRoomHookResult;
|
|
onOpenSidebar: () => void;
|
|
/** False while a mobile navigation overlay covers this pane. */
|
|
isVisible?: boolean;
|
|
};
|
|
|
|
type ReadingAnchor = { id: string; offset: number };
|
|
const DRAFT_KEY = "sybil.pi-root.draft";
|
|
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
|
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" });
|
|
|
|
function savedDraft() {
|
|
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
|
|
}
|
|
function saveDraft(value: string) {
|
|
try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ }
|
|
}
|
|
function metadata(message: Message): Record<string, unknown> {
|
|
return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
|
? message.metadata as Record<string, unknown> : {};
|
|
}
|
|
function dateOf(value: string) {
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date;
|
|
}
|
|
function dayKey(date: Date | null) {
|
|
return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older";
|
|
}
|
|
function dayLabel(date: Date | null) {
|
|
if (!date) return "Earlier";
|
|
const today = new Date();
|
|
if (dayKey(date) === dayKey(today)) return "Today";
|
|
today.setDate(today.getDate() - 1);
|
|
return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date);
|
|
}
|
|
function toolName(value: unknown) {
|
|
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
|
}
|
|
|
|
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
|
const own = message.role === "user";
|
|
const info = metadata(message);
|
|
const status = typeof info.status === "string" ? info.status : "complete";
|
|
const failed = status === "failed" || status === "unconfirmed";
|
|
const date = dateOf(message.createdAt);
|
|
return (
|
|
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's message"}>
|
|
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
|
<div className="agent-room__message-content">
|
|
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
|
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
|
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
|
</div>
|
|
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
|
<div className="agent-room__message-meta">
|
|
<span>{own ? "You" : "Sybil"}</span>
|
|
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
|
|
{own && status === "sending" ? <span className="agent-room__delivery" role="status">Sending…</span> : null}
|
|
{own && status === "queued" ? <span className="agent-room__delivery" role="status"><Clock3 size={11} aria-hidden="true" /> Queued</span> : null}
|
|
{own && failed ? <span className="agent-room__delivery is-error" title={typeof info.error === "string" ? info.error : undefined} role="status">
|
|
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}
|
|
<button type="button" onClick={() => onRetry(message.id)} aria-label="Retry message">Retry</button>
|
|
</span> : null}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
);
|
|
});
|
|
|
|
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
|
const [draft, setDraft] = useState(savedDraft);
|
|
const draftRef = useRef(draft);
|
|
const [uploadError, setUploadError] = useState<string>();
|
|
const pickerRef = useRef<HTMLInputElement>(null);
|
|
const reselectRef = useRef<HTMLInputElement>(null);
|
|
const reselectId = useRef<string>();
|
|
const uploads = room.uploadQueue.snapshot();
|
|
const pendingUploads = uploads.some(file => file.status !== "ready");
|
|
const addFiles = (files: Iterable<File>) => {
|
|
try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); }
|
|
};
|
|
const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } };
|
|
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
|
|
const enteringMessageIdsRef = useRef(new Set<string>());
|
|
const animatedEntryIdsRef = useRef(new Set<string>());
|
|
const entryAnimationsRef = useRef(new Set<Animation>());
|
|
const typingHeightRef = useRef(0);
|
|
const historyReadyRef = useRef(!room.isLoading);
|
|
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
|
const timelineRef = useRef<HTMLDivElement>(null);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const pinnedRef = useRef(true);
|
|
const restoredScrollTopRef = useRef<number | null>(null);
|
|
const anchorRef = useRef<ReadingAnchor | null>(null);
|
|
const markReadRef = useRef(room.markRead);
|
|
const visibleRef = useRef(isVisible);
|
|
const loadingRef = useRef(room.isLoading);
|
|
const readFrameRef = useRef<number | null>(null);
|
|
const mountedRef = useRef(true);
|
|
markReadRef.current = room.markRead;
|
|
visibleRef.current = isVisible;
|
|
loadingRef.current = room.isLoading;
|
|
|
|
const distanceFromBottom = () => {
|
|
const element = scrollerRef.current;
|
|
return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity;
|
|
};
|
|
const scheduleRead = () => {
|
|
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
|
readFrameRef.current = requestAnimationFrame(() => {
|
|
readFrameRef.current = null;
|
|
if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) {
|
|
markReadRef.current();
|
|
}
|
|
});
|
|
};
|
|
const rememberAnchor = () => {
|
|
const element = scrollerRef.current;
|
|
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
|
const top = element.getBoundingClientRect().top;
|
|
// Find the first visible row without laying out every offscreen message.
|
|
const entries = element.querySelectorAll<HTMLElement>("[data-agent-entry-id]");
|
|
// Probe row boxes without measuring every skipped subtree in a long history.
|
|
let low = 0, high = entries.length;
|
|
while (low < high) {
|
|
const mid = (low + high) >>> 1;
|
|
if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1;
|
|
else high = mid;
|
|
}
|
|
const node = entries[low];
|
|
// The first row can lose its day separator when older rows arrive. Anchor
|
|
// the message itself so that changing that separator cannot shift its text.
|
|
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
|
anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null;
|
|
};
|
|
const restoreViewport = () => {
|
|
const element = scrollerRef.current;
|
|
if (!element) return;
|
|
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
|
else if (anchorRef.current) {
|
|
const anchor = anchorRef.current;
|
|
const node = element.querySelector<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
|
|
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
|
if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
|
}
|
|
restoredScrollTopRef.current = element.scrollTop;
|
|
// Keep the user's key/offset through successive intrinsic-size corrections.
|
|
// Recapturing here can anchor a newly revealed older row while its neighbors
|
|
// are still being measured, causing the original reading position to drift.
|
|
scheduleRead();
|
|
};
|
|
const jumpToLatest = () => {
|
|
pinnedRef.current = true;
|
|
anchorRef.current = null;
|
|
if (scrollerRef.current) {
|
|
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
|
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
|
}
|
|
scheduleRead();
|
|
};
|
|
|
|
useLayoutEffect(() => {
|
|
const timeline = timelineRef.current;
|
|
if (!timeline) return;
|
|
const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0;
|
|
const typing = timeline.querySelector<HTMLElement>(".agent-room__typing");
|
|
// Transfer the departing indicator's space to the reply before it grows.
|
|
// Removing that space first would briefly move the transcript downward.
|
|
let replacedTypingHeight = typing ? 0 : typingHeightRef.current;
|
|
typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0;
|
|
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
for (const entry of timeline.querySelectorAll<HTMLElement>("[data-agent-entry-id]")) {
|
|
const id = entry.dataset.agentEntryId!;
|
|
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
|
|
animatedEntryIdsRef.current.add(id);
|
|
if (reduceMotion) continue;
|
|
const height = entry.getBoundingClientRect().height;
|
|
const spacing = entry.previousElementSibling ? gap : 0;
|
|
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
|
|
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
|
|
if (isReply) replacedTypingHeight = 0;
|
|
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
|
|
// positive-Y transform from prematurely extending the scrollable area.
|
|
entry.style.overflow = "clip";
|
|
const animation = entry.animate([
|
|
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
|
|
{ height: `${height}px`, marginTop: "0px" },
|
|
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
|
|
entryAnimationsRef.current.add(animation);
|
|
animation.onfinish = () => {
|
|
entry.style.overflow = "";
|
|
entryAnimationsRef.current.delete(animation);
|
|
restoreViewport();
|
|
};
|
|
}
|
|
}, [room.messages, room.isTyping]);
|
|
|
|
useLayoutEffect(() => {
|
|
restoreViewport();
|
|
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, room.isLoadingOlder, room.olderError, room.hasOlder, isVisible]);
|
|
|
|
useEffect(() => {
|
|
mountedRef.current = true;
|
|
const observer = new ResizeObserver(restoreViewport);
|
|
if (scrollerRef.current) observer.observe(scrollerRef.current);
|
|
if (timelineRef.current) observer.observe(timelineRef.current);
|
|
const onVisibility = () => scheduleRead();
|
|
window.addEventListener("focus", onVisibility);
|
|
document.addEventListener("visibilitychange", onVisibility);
|
|
return () => {
|
|
mountedRef.current = false;
|
|
observer.disconnect();
|
|
for (const animation of entryAnimationsRef.current) animation.cancel();
|
|
entryAnimationsRef.current.clear();
|
|
window.removeEventListener("focus", onVisibility);
|
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
|
|
};
|
|
}, []);
|
|
|
|
useLayoutEffect(() => {
|
|
const textarea = textareaRef.current;
|
|
if (!textarea) return;
|
|
textarea.style.height = "0px";
|
|
textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`;
|
|
}, [draft]);
|
|
|
|
const updateDraft = (value: string) => {
|
|
draftRef.current = value;
|
|
saveDraft(value);
|
|
setDraft(value);
|
|
};
|
|
|
|
const retryMessage = useCallback((id: string) => {
|
|
// Admission errors remain attached to their own bubble.
|
|
void room.retryMessage(id).catch(() => {});
|
|
}, [room.retryMessage]);
|
|
const submit = () => {
|
|
const content = draftRef.current.trim();
|
|
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
|
let attachments;
|
|
try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; }
|
|
const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); });
|
|
void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); });
|
|
textareaRef.current?.focus();
|
|
};
|
|
|
|
const messages = useMemo(() => {
|
|
const transcript = agentTranscriptMessages(room.messages);
|
|
const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor;
|
|
historyBeforeRef.current = room.historyBeforeCursor;
|
|
let previousDay = "";
|
|
let lastSeen = -1;
|
|
transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; });
|
|
for (const [index, message] of transcript.entries()) {
|
|
// Historical prepends keep their measured space; only new tail rows enter.
|
|
if (index > lastSeen && !(historyChanged && lastSeen < 0) && !seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
|
enteringMessageIdsRef.current.add(message.id);
|
|
}
|
|
seenMessageIdsRef.current.add(message.id);
|
|
}
|
|
if (!room.isLoading) historyReadyRef.current = true;
|
|
return transcript.map(message => {
|
|
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
|
|
previousDay = day;
|
|
return (
|
|
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
|
|
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
|
|
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
|
|
</div>
|
|
);
|
|
});
|
|
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
|
|
|
|
const activity = room.activity;
|
|
const running = room.isTyping;
|
|
const waiting = activity?.state === "waiting";
|
|
const connected = room.connection === "connected";
|
|
const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting";
|
|
const activeTool = activity?.tools?.find(tool => tool.status !== "done");
|
|
const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}`
|
|
: activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : "";
|
|
|
|
return (
|
|
<section className="agent-room" aria-label="Sybil Agent conversation">
|
|
<header className="agent-room__header flex shrink-0 items-center justify-between gap-2 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:gap-3 md:px-7">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Button type="button" size="icon" variant="ghost" className="workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick={onOpenSidebar} aria-label="Open sidebar">
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">Sybil Agent</h1>
|
|
</div>
|
|
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
|
|
<span className="sr-only" role="status">{connectionLabel}</span>
|
|
{!connected ? <Button type="button" size="icon" variant="ghost" onClick={room.retry} aria-label="Reconnect to Sybil Agent" title={connectionLabel}><RefreshCw className="h-4 w-4" /></Button> : null}
|
|
</div>
|
|
</header>
|
|
|
|
<div className="agent-room__body">
|
|
<div
|
|
ref={scrollerRef}
|
|
className="agent-room__scroll"
|
|
tabIndex={0}
|
|
aria-label="Conversation messages"
|
|
onScroll={() => {
|
|
// A scroll event from our last resize correction can arrive after
|
|
// the next animation frame has already grown a tall reply. That
|
|
// temporary bottom distance does not mean the reader scrolled up.
|
|
if (restoredScrollTopRef.current !== null && scrollerRef.current
|
|
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
|
const bottom = distanceFromBottom() <= 48;
|
|
pinnedRef.current = bottom;
|
|
rememberAnchor();
|
|
if (bottom) scheduleRead();
|
|
}}
|
|
>
|
|
<div ref={timelineRef} className="agent-room__timeline">
|
|
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
|
{room.hasOlder ? <div className="agent-room__history">
|
|
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
|
|
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
|
|
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
|
|
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
|
|
</div> : null}
|
|
{messages}
|
|
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
|
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="agent-room__composer-area"
|
|
onDragOver={event => { if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }}
|
|
onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
|
|
<div className="agent-room__composer-inner">
|
|
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
|
|
{uploadError ? <div className="agent-room__error" role="alert">{uploadError}</div> : null}
|
|
<input ref={reselectRef} type="file" hidden aria-label="Reselect attachment" onChange={event => {
|
|
const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = "";
|
|
if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error)));
|
|
}} />
|
|
{uploads.length ? <div className="agent-room__uploads" aria-label="Selected attachments">{uploads.map(row => <div className="agent-room__upload" key={row.uploadId}>
|
|
<div><strong>{row.filename}</strong><small>{attachmentSize(row.sizeBytes)} · <span role="status">{row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}</span></small></div>
|
|
{row.status === "reselect" ? <button type="button" onClick={() => { reselectId.current = row.uploadId; reselectRef.current?.click(); }}>Reselect file</button> : null}
|
|
{row.status === "failed" ? <button type="button" onClick={() => room.uploadQueue.retry(row.uploadId)}>Retry upload</button> : null}
|
|
<button type="button" aria-label={`Remove ${row.filename}`} onClick={() => removeFile(row.uploadId)}><X size={15} /></button>
|
|
{row.error ? <span className="agent-attachment__error" role="alert">{row.error}</span> : null}
|
|
{row.attachment?.workspacePath ? <code className="agent-room__upload-path" title={row.attachment.workspacePath}>{row.attachment.workspacePath}</code> : null}
|
|
</div>)}</div> : null}
|
|
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
|
|
{room.attachmentCapability ? <>
|
|
<input ref={pickerRef} type="file" multiple hidden aria-label="Choose attachments" onChange={event => { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} />
|
|
<button type="button" className="agent-room__attach" aria-label="Attach files" title="Attach files (8 files, 32 MiB total)" onClick={() => pickerRef.current?.click()}><Paperclip size={19} /></button>
|
|
</> : null}
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={draft}
|
|
rows={1}
|
|
maxLength={40000}
|
|
onPaste={event => { if (room.attachmentCapability && event.clipboardData?.files.length) { event.preventDefault(); addFiles(event.clipboardData.files); } }}
|
|
onInput={event => updateDraft(event.currentTarget.value)}
|
|
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
|
|
placeholder="Message Sybil Agent…"
|
|
aria-label="Message Sybil Agent"
|
|
enterkeyhint="send"
|
|
/>
|
|
<button type="submit" className="agent-room__send" disabled={(!draft.trim() && !uploads.length) || pendingUploads || Boolean(uploads.length && !room.attachmentCapability)} aria-label="Send message"><SendHorizontal size={19} /></button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|