From face53f2341417e339c02d5d7d4c412b961f87f6 Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 20 Feb 2026 00:02:18 -0800 Subject: [PATCH] Attachment previews in chat panel, input, and queue. --- .gitignore | 2 +- apps/web/app/api/workspace/thumbnail/route.ts | 69 +++++ apps/web/app/api/workspace/upload/route.ts | 28 +- apps/web/app/components/chat-message.tsx | 169 ++++------- apps/web/app/components/chat-panel.tsx | 273 +++++++++++------- .../web/app/components/tiptap/chat-editor.tsx | 25 ++ apps/web/app/globals.css | 18 +- 7 files changed, 341 insertions(+), 243 deletions(-) create mode 100644 apps/web/app/api/workspace/thumbnail/route.ts diff --git a/.gitignore b/.gitignore index ea74e9fc3f5..07dce81b01f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ bun.lockb coverage __pycache__/ *.pyc -.tsbuildinfo +*.tsbuildinfo .pnpm-store .worktrees/ .DS_Store diff --git a/apps/web/app/api/workspace/thumbnail/route.ts b/apps/web/app/api/workspace/thumbnail/route.ts new file mode 100644 index 00000000000..22b298ead14 --- /dev/null +++ b/apps/web/app/api/workspace/thumbnail/route.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, mkdirSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join, basename } from "node:path"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { safeResolvePath } from "@/lib/workspace"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const THUMB_DIR = join(tmpdir(), "ironclaw-thumbs"); +mkdirSync(THUMB_DIR, { recursive: true }); + +/** + * Resolve a file path — supports absolute paths and workspace-relative paths. + */ +function resolveFile(path: string): string | null { + if (path.startsWith("/")) { + const abs = resolve(path); + if (existsSync(abs)) {return abs;} + } + return safeResolvePath(path) ?? null; +} + +/** + * GET /api/workspace/thumbnail?path=...&size=200 + * Uses macOS Quick Look (qlmanage) to generate a thumbnail image. + * Returns the thumbnail as image/png. + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const path = url.searchParams.get("path"); + const size = url.searchParams.get("size") ?? "200"; + + if (!path) { + return new Response("Missing path", { status: 400 }); + } + + const absolute = resolveFile(path); + if (!absolute) { + return new Response("Not found", { status: 404 }); + } + + // The thumbnail output filename is .png + const thumbName = `${basename(absolute)}.png`; + const thumbPath = join(THUMB_DIR, thumbName); + + try { + // Generate thumbnail using macOS Quick Look + execSync( + `qlmanage -t -s ${parseInt(size, 10)} -o "${THUMB_DIR}" "${absolute}" 2>/dev/null`, + { timeout: 5000 }, + ); + + if (!existsSync(thumbPath)) { + return new Response("Thumbnail generation failed", { status: 500 }); + } + + const buffer = readFileSync(thumbPath); + return new Response(buffer, { + headers: { + "Content-Type": "image/png", + "Cache-Control": "public, max-age=3600", + }, + }); + } catch { + return new Response("Thumbnail generation failed", { status: 500 }); + } +} diff --git a/apps/web/app/api/workspace/upload/route.ts b/apps/web/app/api/workspace/upload/route.ts index 04909bf2e62..522ea2ccfb9 100644 --- a/apps/web/app/api/workspace/upload/route.ts +++ b/apps/web/app/api/workspace/upload/route.ts @@ -1,27 +1,21 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { join, dirname } from "node:path"; -import { resolveWorkspaceRoot, safeResolveNewPath } from "@/lib/workspace"; +import { homedir } from "node:os"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const MAX_SIZE = 25 * 1024 * 1024; // 25 MB +/** Hidden uploads dir in the user's home directory — persists forever, invisible to users. */ +const UPLOADS_DIR = join(homedir(), ".ironclaw", "uploads"); + /** * POST /api/workspace/upload * Accepts multipart form data with a "file" field. - * Saves to assets/- inside the workspace. - * Returns { ok, path } where path is workspace-relative. + * Saves to a temp directory and returns the absolute path. */ export async function POST(req: Request) { - const root = resolveWorkspaceRoot(); - if (!root) { - return Response.json( - { error: "Workspace not found" }, - { status: 500 }, - ); - } - let formData: FormData; try { formData = await req.formData(); @@ -49,21 +43,13 @@ export async function POST(req: Request) { const safeName = file.name .replace(/[^a-zA-Z0-9._-]/g, "_") .replace(/_{2,}/g, "_"); - const relPath = join("assets", `${Date.now()}-${safeName}`); - - const absPath = safeResolveNewPath(relPath); - if (!absPath) { - return Response.json( - { error: "Invalid path" }, - { status: 400 }, - ); - } + const absPath = join(UPLOADS_DIR, `${Date.now()}-${safeName}`); try { mkdirSync(dirname(absPath), { recursive: true }); const buffer = Buffer.from(await file.arrayBuffer()); writeFileSync(absPath, buffer); - return Response.json({ ok: true, path: relPath }); + return Response.json({ ok: true, path: absPath }); } catch (err) { return Response.json( { error: err instanceof Error ? err.message : "Upload failed" }, diff --git a/apps/web/app/components/chat-message.tsx b/apps/web/app/components/chat-message.tsx index 27c955709f7..ebf610e68f0 100644 --- a/apps/web/app/components/chat-message.tsx +++ b/apps/web/app/components/chat-message.tsx @@ -347,93 +347,42 @@ function AttachFileIcon({ category }: { category: string }) { function AttachedFilesCard({ paths }: { paths: string[] }) { return ( -
-
- - - - - {paths.length}{" "} - {paths.length === 1 ? "file" : "files"}{" "} - attached - -
-
- {paths.map((filePath, i) => { - const category = - getCategoryFromPath(filePath); - const filename = - filePath.split("/").pop() ?? - filePath; - const meta = - attachCategoryMeta[category] ?? - attachCategoryMeta.other; - const short = shortenPath(filePath); +
+ {paths.map((filePath, i) => { + const category = getCategoryFromPath(filePath); + const src = category === "image" + ? `/api/workspace/raw-file?path=${encodeURIComponent(filePath)}` + : `/api/workspace/thumbnail?path=${encodeURIComponent(filePath)}&size=200`; + const ext = filePath.split(".").pop()?.toUpperCase() ?? ""; - return ( -
-
-
- -
-
-

- {filename} -

-

- {short} -

-
-
-
- ); - })} -
+ return ( +
+ {filePath.split("/").pop() { (e.currentTarget as HTMLImageElement).style.display = "none"; }} + /> + {category !== "image" && ( + + {ext} + + )} +
+ ); + })}
); } @@ -741,35 +690,41 @@ export const ChatMessage = memo(function ChatMessage({ message, isStreaming, onS // Parse attachment prefix from sent messages const attachmentInfo = parseAttachments(textContent); + if (attachmentInfo) { + return ( +
+ {/* Attachment previews — standalone above the text bubble */} + + {/* Text bubble */} + {attachmentInfo.message && ( +
+

+ {attachmentInfo.message} +

+
+ )} +
+ ); + } + return (
- {attachmentInfo ? ( - <> - - {attachmentInfo.message && ( -

- { - attachmentInfo.message - } -

- )} - - ) : ( -

- {textContent} -

- )} +

+ {textContent} +

); diff --git a/apps/web/app/components/chat-panel.tsx b/apps/web/app/components/chat-panel.tsx index d246cf20781..5db09f54aa0 100644 --- a/apps/web/app/components/chat-panel.tsx +++ b/apps/web/app/components/chat-panel.tsx @@ -31,6 +31,10 @@ type AttachedFile = { id: string; name: string; path: string; + /** True while the file is still uploading to the server. */ + uploading?: boolean; + /** Local blob URL for instant preview before upload completes. */ + localUrl?: string; }; function getFileCategory( @@ -228,12 +232,34 @@ function QueueItem({ }} /> ) : ( -

- {msg.text || (msg.attachedFiles.length > 0 ? `${msg.attachedFiles.length} file(s)` : "")} -

+
+ {msg.attachedFiles.length > 0 && ( +
+ {msg.attachedFiles.map((af) => { + const cat = getFileCategory(af.name); + const src = cat === "image" + ? (af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`) + : af.path ? `/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=100` : undefined; + return ( + {af.name} { (e.currentTarget as HTMLImageElement).style.display = "none"; }} + /> + ); + })} +
+ )} +

+ {msg.text || `${msg.attachedFiles.length} ${msg.attachedFiles.length === 1 ? "file" : "files"}`} +

+
)} {!editing && (
@@ -294,26 +320,7 @@ function AttachmentStrip({ if (files.length === 0) {return null;} return ( -
-
- - {files.length}{" "} - {files.length === 1 ? "file" : "files"} attached - - {files.length > 1 && ( - - )} -
+
{category === "image" ? ( - /* Image thumbnail preview */ -
- {af.name} { - (e.currentTarget as HTMLImageElement).style.display = "none"; - }} - /> -

- {af.name} -

-
+ /* Image thumbnail — no filename */ + {af.name} { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : category === "pdf" && af.path ? ( + /* PDF thumbnail via Quick Look */ + {af.name} { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> ) : ( -
+
{af.name}

- {short} + {af.uploading ? "Uploading..." : short}

@@ -1112,34 +1135,44 @@ export const ChatPanel = forwardRef( // Ref for handleNewSession so handleEditorSubmit doesn't depend on the hook order const handleNewSessionRef = useRef<() => void>(() => {}); - /** Submit from the Tiptap editor (called on Enter or send button). */ + /** Submit from the Tiptap editor (called on Enter or send button). + * `overrideAttachments` is used by the queue system to pass saved attachments directly. */ const handleEditorSubmit = useCallback( async ( text: string, mentionedFiles: Array<{ name: string; path: string }>, + overrideAttachments?: AttachedFile[], ) => { const hasText = text.trim().length > 0; const hasMentions = mentionedFiles.length > 0; - const hasFiles = attachedFiles.length > 0; + // Use override attachments (from queue) or current state + const readyFiles = overrideAttachments + ? overrideAttachments.filter((f) => !f.uploading && f.path) + : attachedFiles.filter((f) => !f.uploading && f.path); + const hasFiles = readyFiles.length > 0; if (!hasText && !hasMentions && !hasFiles) { return; } const userText = text.trim(); - const currentAttachments = [...attachedFiles]; - - // Clear attachments - if (currentAttachments.length > 0) { - setAttachedFiles([]); - } + const currentAttachments = [...readyFiles]; if (userText.toLowerCase() === "/new") { + // Revoke blob URLs before clearing + for (const f of attachedFiles) { + if (f.localUrl) {URL.revokeObjectURL(f.localUrl);} + } + setAttachedFiles([]); handleNewSessionRef.current(); return; } // Queue the message if the agent is still running. if (isStreaming) { + // Clear attachment strip but keep blob URLs alive for queue thumbnails + if (!overrideAttachments) { + setAttachedFiles([]); + } setQueuedMessages((prev) => [ ...prev, { @@ -1153,6 +1186,14 @@ export const ChatPanel = forwardRef( return; } + // Clear attachments (revoke blob URLs to free memory) + if (!overrideAttachments && currentAttachments.length > 0) { + for (const f of attachedFiles) { + if (f.localUrl) {URL.revokeObjectURL(f.localUrl);} + } + setAttachedFiles([]); + } + let sessionId = currentSessionId; if (!sessionId) { const titleSource = @@ -1226,9 +1267,13 @@ export const ChatPanel = forwardRef( if (wasStreaming && isNowReady && queuedMessages.length > 0) { const [next, ...rest] = queuedMessages; setQueuedMessages(rest); + // Revoke blob URLs from queued attachments (no longer needed for thumbnails) + for (const f of next.attachedFiles) { + if (f.localUrl) {URL.revokeObjectURL(f.localUrl);} + } // Use a microtask so React can settle the status update first. queueMicrotask(() => { - void handleEditorSubmit(next.text, next.mentionedFiles); + void handleEditorSubmit(next.text, next.mentionedFiles, next.attachedFiles); }); } }, [status, queuedMessages, handleEditorSubmit]); @@ -1413,7 +1458,7 @@ export const ChatPanel = forwardRef( await handleStop(); // Submit the message after a short delay to let status settle. setTimeout(() => { - void handleEditorSubmit(msg.text, msg.mentionedFiles); + void handleEditorSubmit(msg.text, msg.mentionedFiles, msg.attachedFiles); }, 100); }, [queuedMessages, handleStop, handleEditorSubmit], @@ -1439,41 +1484,71 @@ export const ChatPanel = forwardRef( ); const removeAttachment = useCallback((id: string) => { - setAttachedFiles((prev) => - prev.filter((f) => f.id !== id), - ); + setAttachedFiles((prev) => { + const removed = prev.find((f) => f.id === id); + if (removed?.localUrl) {URL.revokeObjectURL(removed.localUrl);} + return prev.filter((f) => f.id !== id); + }); }, []); const clearAllAttachments = useCallback(() => { - setAttachedFiles([]); + setAttachedFiles((prev) => { + for (const f of prev) { + if (f.localUrl) {URL.revokeObjectURL(f.localUrl);} + } + return []; + }); }, []); - /** Upload native files (e.g. dropped from Finder/Desktop) and attach them. */ + /** Upload native files (e.g. dropped from Finder/Desktop) and attach them. + * Shows files instantly with a local preview, then uploads in the background. */ const uploadAndAttachNativeFiles = useCallback( - async (files: FileList) => { - const uploaded: AttachedFile[] = []; - for (const file of Array.from(files)) { - try { - const form = new FormData(); - form.append("file", file); - const res = await fetch("/api/workspace/upload", { - method: "POST", - body: form, + (files: FileList) => { + const fileArray = Array.from(files); + + // Immediately add placeholder entries with local blob URLs + const placeholders: AttachedFile[] = fileArray.map((file) => ({ + id: `pending-${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: file.name, + path: "", + uploading: true, + localUrl: URL.createObjectURL(file), + })); + setAttachedFiles((prev) => [...prev, ...placeholders]); + + // Upload each file in the background and update the entry + for (let i = 0; i < fileArray.length; i++) { + const file = fileArray[i]; + const placeholderId = placeholders[i].id; + const localUrl = placeholders[i].localUrl; + + const form = new FormData(); + form.append("file", file); + fetch("/api/workspace/upload", { + method: "POST", + body: form, + }) + .then((res) => res.ok ? res.json() : null) + .then((json: { ok?: boolean; path?: string } | null) => { + if (json?.ok && json.path) { + // Replace placeholder with the real uploaded file + setAttachedFiles((prev) => + prev.map((f) => + f.id === placeholderId + ? { ...f, path: json.path!, uploading: false } + : f, + ), + ); + } else { + // Upload failed — remove the placeholder + setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId)); + if (localUrl) {URL.revokeObjectURL(localUrl);} + } + }) + .catch(() => { + setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholderId)); + if (localUrl) {URL.revokeObjectURL(localUrl);} }); - if (!res.ok) { continue; } - const json = (await res.json()) as { ok?: boolean; path?: string }; - if (!json.ok || !json.path) { continue; } - uploaded.push({ - id: `${json.path}-${Date.now()}-${Math.random().toString(36).slice(2)}`, - name: file.name, - path: json.path, - }); - } catch { - // skip files that fail to upload - } - } - if (uploaded.length > 0) { - setAttachedFiles((prev) => [...prev, ...uploaded]); } }, [], @@ -1819,7 +1894,7 @@ export const ChatPanel = forwardRef( if (files && files.length > 0) { e.preventDefault(); e.stopPropagation(); - void uploadAndAttachNativeFiles(files); + uploadAndAttachNativeFiles(files); } }} > @@ -1856,6 +1931,16 @@ export const ChatPanel = forwardRef(
)} + {/* Attachment preview strip */} + + ( compact={compact} /> - {/* Attachment preview strip */} - - {/* Toolbar row */}
( // otherwise consume the event or insert the text/plain // fallback data as raw text. handleDOMEvents: { + paste: (_view, event) => { + const clipboardData = event.clipboardData; + if (!clipboardData) {return false;} + + // Collect files from clipboard (images, screenshots, etc.) + const pastedFiles: File[] = []; + if (clipboardData.items) { + for (const item of Array.from(clipboardData.items)) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file) {pastedFiles.push(file);} + } + } + } + + if (pastedFiles.length > 0) { + event.preventDefault(); + const dt = new DataTransfer(); + for (const f of pastedFiles) {dt.items.add(f);} + nativeFileDropRef.current?.(dt.files); + return true; + } + + return false; + }, dragover: (_view, event) => { const de = event; if (de.dataTransfer?.types.includes("application/x-file-mention")) { diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 08d6b5ed1f0..13a55077943 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -208,15 +208,9 @@ body { font-family: "Bookerly", Georgia, "Times New Roman", serif; } -/* Message bubbles and assistant text: use body font so they render immediately (no FOUT). */ +/* Message bubbles and assistant text: use Bookerly for a polished reading experience. */ .chat-message-font { - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; + font-family: "Bookerly", Georgia, "Times New Roman", serif; } /* Smooth theme transitions */ @@ -907,13 +901,7 @@ a, line-height: 1.8; overflow-wrap: anywhere; word-break: break-word; - font-family: - "Inter", - -apple-system, - BlinkMacSystemFont, - "Segoe UI", - Roboto, - sans-serif; + font-family: "Bookerly", Georgia, "Times New Roman", serif; } .chat-prose > *:first-child {