feat(agent): add authenticated durable uploads to backend and web

This commit is contained in:
2026-10-14 17:25:52 -07:00
parent 4cbab487d0
commit 8b125c13a5
16 changed files with 838 additions and 54 deletions
@@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from "preact/hooks";
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
type Props = { file: AgentAttachment; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
export function AgentAttachmentCard({ file, download }: Props) {
const [preview, setPreview] = useState<string>();
const [error, setError] = useState<string>();
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const controller = useRef<AbortController>();
useEffect(() => () => { controller.current?.abort(); }, []);
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
const retrieve = async (show: boolean) => {
if (busy) return;
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
try {
const blob = await download(file, request.signal); if (request.signal.aborted) return;
const url = URL.createObjectURL(blob);
if (show && file.kind === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(blob.type)) setPreview(url);
else {
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.filename; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
finally { if (!request.signal.aborted) setBusy(false); }
};
return <div className="agent-attachment">
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
{file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
<div className="agent-attachment__actions">
{file.kind === "image" ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{preview ? "Hide preview" : "Preview"}</button> : null}
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
</div>
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
</div>;
}
@@ -455,3 +455,24 @@
transition:none!important;
}
}
.agent-room__attach { display: grid; place-items: center; align-self: end; width: 38px; height: 46px; color: #c4b5fd; border-radius: 12px; }
.agent-room__attach:hover { background: #a78bfa22; }
.agent-room__uploads { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.agent-room__upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; padding: 9px 12px; border: 1px solid #a78bfa44; border-radius: 12px; background: #211b36; font-size: 12px; }
.agent-room__upload strong { display: block; overflow-wrap: anywhere; font-weight: 500; }
.agent-room__upload small { color: #c4b5fd; }
.agent-room__upload button { padding: 5px; color: #ddd6fe; }
.agent-room__upload-path { flex-basis: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: #b9a7d1; }
.agent-attachment { margin-top: 10px; padding: 10px; border: 1px solid #a78bfa44; border-radius: 10px; background: #0e0a1933; font-size: 12px; min-width: 0; }
.agent-attachment__title { display: flex; align-items: center; gap: 6px; }
.agent-attachment__title span { overflow-wrap: anywhere; }
.agent-attachment__title small { margin-left: auto; white-space: nowrap; opacity: .7; }
.agent-attachment__path { display: flex; gap: 6px; margin-top: 6px; }
.agent-attachment__path code { min-width: 0; overflow-wrap: anywhere; font-size: 10px; opacity: .75; }
.agent-attachment__path button { flex-shrink: 0; }
.agent-attachment__actions { display: flex; gap: 14px; margin-top: 8px; }
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
.agent-attachment__actions button:disabled { opacity: .5; }
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
+46 -11
View File
@@ -1,10 +1,12 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
import { ArrowDown, 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 = {
@@ -47,7 +49,7 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
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";
@@ -59,7 +61,9 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
<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 />}
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)}
</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}
@@ -78,6 +82,16 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
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>());
@@ -223,11 +237,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
};
const submit = () => {
const content = draftRef.current.trim();
if (!content) return;
const delivery = room.sendMessage(content);
updateDraft("");
jumpToLatest();
void delivery.catch(() => {});
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();
};
@@ -247,11 +261,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
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} />
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
</div>
);
});
}, [room.messages, room.isLoading, room.retryMessage]);
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
const activity = room.activity;
const running = room.isTyping;
@@ -306,21 +320,42 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
</div>
<div className="agent-room__composer-area">
<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()} aria-label="Send message"><SendHorizontal size={19} /></button>
<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>