Attachment previews in chat panel, input, and queue.

This commit is contained in:
Mark 2026-02-20 00:02:18 -08:00
parent 6c6289eb2e
commit face53f234
7 changed files with 341 additions and 243 deletions

2
.gitignore vendored
View File

@ -9,7 +9,7 @@ bun.lockb
coverage
__pycache__/
*.pyc
.tsbuildinfo
*.tsbuildinfo
.pnpm-store
.worktrees/
.DS_Store

View File

@ -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 <original-basename>.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 });
}
}

View File

@ -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/<timestamp>-<filename> 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" },

View File

@ -347,93 +347,42 @@ function AttachFileIcon({ category }: { category: string }) {
function AttachedFilesCard({ paths }: { paths: string[] }) {
return (
<div className="mb-2">
<div className="flex items-center gap-1.5 mb-2">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ opacity: 0.5 }}
>
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
<span
className="text-[11px] font-medium uppercase tracking-wider"
style={{ opacity: 0.5 }}
>
{paths.length}{" "}
{paths.length === 1 ? "file" : "files"}{" "}
attached
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{paths.map((filePath, i) => {
const category =
getCategoryFromPath(filePath);
const filename =
filePath.split("/").pop() ??
filePath;
const meta =
attachCategoryMeta[category] ??
attachCategoryMeta.other;
const short = shortenPath(filePath);
<div className="flex flex-wrap gap-1.5 mb-2 justify-end">
{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 (
<div
key={i}
className="flex-shrink-0 rounded-lg"
style={{
background:
"rgba(0,0,0,0.04)",
border: "1px solid rgba(0,0,0,0.06)",
}}
>
<div className="flex items-center gap-2 px-2.5 py-1.5">
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{
background:
meta.bg,
color: meta.fg,
}}
>
<AttachFileIcon
category={
category
}
/>
</div>
<div className="min-w-0">
<p
className="text-[12px] font-medium truncate max-w-[160px]"
title={
filePath
}
>
{filename}
</p>
<p
className="text-[10px] truncate max-w-[160px]"
style={{
opacity: 0.45,
}}
title={
filePath
}
>
{short}
</p>
</div>
</div>
</div>
);
})}
</div>
return (
<div
key={i}
className="relative rounded-xl overflow-hidden shrink-0"
>
<img
src={src}
alt={filePath.split("/").pop() ?? ""}
className="block rounded-xl object-cover"
style={{ maxHeight: 140, maxWidth: 160, background: "rgba(0,0,0,0.04)" }}
loading="lazy"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
{category !== "image" && (
<span
className="absolute bottom-2 left-2 rounded-md px-1.5 py-0.5 text-[10px] font-bold uppercase"
style={{
background: "rgba(255,255,255,0.85)",
color: "rgba(0,0,0,0.5)",
backdropFilter: "blur(4px)",
}}
>
{ext}
</span>
)}
</div>
);
})}
</div>
);
}
@ -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 (
<div className="flex flex-col items-end gap-1.5 py-2">
{/* Attachment previews — standalone above the text bubble */}
<AttachedFilesCard paths={attachmentInfo.paths} />
{/* Text bubble */}
{attachmentInfo.message && (
<div
className="max-w-[80%] w-fit rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 break-words chat-message-font"
style={{
background: "var(--color-user-bubble)",
color: "var(--color-user-bubble-text)",
}}
>
<p className="whitespace-pre-wrap break-words">
{attachmentInfo.message}
</p>
</div>
)}
</div>
);
}
return (
<div className="flex justify-end py-2">
<div
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-all chat-message-font"
className="max-w-[80%] min-w-0 rounded-2xl rounded-br-sm px-3 py-2 text-sm leading-6 overflow-hidden break-words chat-message-font"
style={{
background: "var(--color-user-bubble)",
color: "var(--color-user-bubble-text)",
}}
>
{attachmentInfo ? (
<>
<AttachedFilesCard
paths={
attachmentInfo.paths
}
/>
{attachmentInfo.message && (
<p className="whitespace-pre-wrap break-all">
{
attachmentInfo.message
}
</p>
)}
</>
) : (
<p className="whitespace-pre-wrap break-all">
{textContent}
</p>
)}
<p className="whitespace-pre-wrap break-words text-right">
{textContent}
</p>
</div>
</div>
);

View File

@ -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({
}}
/>
) : (
<p
className="flex-1 text-[13px] leading-[1.45] line-clamp-2 min-w-0"
style={{ color: "var(--color-text-secondary)", whiteSpace: "pre-wrap" }}
>
{msg.text || (msg.attachedFiles.length > 0 ? `${msg.attachedFiles.length} file(s)` : "")}
</p>
<div className="flex-1 min-w-0 flex items-center gap-2">
{msg.attachedFiles.length > 0 && (
<div className="flex gap-1 shrink-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 (
<img
key={af.id}
src={src}
alt={af.name}
className="rounded object-cover"
style={{ height: 28, width: 28, background: "var(--color-surface-hover)" }}
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }}
/>
);
})}
</div>
)}
<p
className="text-[13px] leading-[1.45] line-clamp-1 min-w-0"
style={{ color: "var(--color-text-secondary)" }}
>
{msg.text || `${msg.attachedFiles.length} ${msg.attachedFiles.length === 1 ? "file" : "files"}`}
</p>
</div>
)}
{!editing && (
<div className="flex items-center gap-1 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
@ -294,26 +320,7 @@ function AttachmentStrip({
if (files.length === 0) {return null;}
return (
<div className={`${compact ? "px-2" : "px-3"} pb-2`}>
<div className="flex items-center justify-between mb-1.5">
<span
className="text-[10px] font-medium uppercase tracking-wider"
style={{ color: "var(--color-text-muted)" }}
>
{files.length}{" "}
{files.length === 1 ? "file" : "files"} attached
</span>
{files.length > 1 && (
<button
type="button"
onClick={onClearAll}
className="text-[10px] font-medium px-1.5 py-0.5 rounded hover:opacity-80 transition-opacity"
style={{ color: "var(--color-text-muted)" }}
>
Clear all
</button>
)}
</div>
<div className={`${compact ? "px-2" : "px-3"} pt-2`}>
<div
className="flex gap-2 overflow-x-auto pb-1"
style={{ scrollbarWidth: "thin" }}
@ -367,29 +374,45 @@ function AttachmentStrip({
</button>
{category === "image" ? (
/* Image thumbnail preview */
<div className="flex flex-col items-center" style={{ width: 96 }}>
<img
src={`/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`}
alt={af.name}
className="w-full rounded-t-xl object-cover"
style={{ height: 56, background: "var(--color-bg-secondary)" }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
<p
className="text-[10px] font-medium truncate w-full px-2 py-1.5 text-center"
style={{ color: "var(--color-text)" }}
title={af.path}
>
{af.name}
</p>
</div>
/* Image thumbnail — no filename */
<img
src={af.localUrl || `/api/workspace/raw-file?path=${encodeURIComponent(af.path)}`}
alt={af.name}
className="block rounded-xl object-cover"
style={{
height: 80,
width: "auto",
minWidth: 60,
maxWidth: 140,
opacity: af.uploading ? 0.6 : 1,
background: "var(--color-bg-secondary)",
}}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : category === "pdf" && af.path ? (
/* PDF thumbnail via Quick Look */
<img
src={`/api/workspace/thumbnail?path=${encodeURIComponent(af.path)}&size=200`}
alt={af.name}
className="block rounded-xl object-cover"
style={{
height: 80,
width: "auto",
minWidth: 60,
maxWidth: 140,
opacity: af.uploading ? 0.6 : 1,
background: "var(--color-bg-secondary)",
}}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="flex items-center gap-2.5 px-3 py-2.5">
<div className="flex items-center gap-2.5 px-3 py-2.5" style={{ opacity: af.uploading ? 0.6 : 1 }}>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: meta.bg,
color: meta.fg,
@ -401,16 +424,16 @@ function AttachmentStrip({
<p
className="text-[11px] font-medium truncate"
style={{ color: "var(--color-text)" }}
title={af.path}
title={af.path || af.name}
>
{af.name}
</p>
<p
className="text-[9px] truncate"
style={{ color: "var(--color-text-muted)" }}
title={af.path}
title={af.path || af.name}
>
{short}
{af.uploading ? "Uploading..." : short}
</p>
</div>
</div>
@ -1112,34 +1135,44 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
// 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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
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<ChatPanelHandle, ChatPanelProps>(
);
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<ChatPanelHandle, ChatPanelProps>(
if (files && files.length > 0) {
e.preventDefault();
e.stopPropagation();
void uploadAndAttachNativeFiles(files);
uploadAndAttachNativeFiles(files);
}
}}
>
@ -1856,6 +1931,16 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
</div>
)}
{/* Attachment preview strip */}
<AttachmentStrip
files={attachedFiles}
compact={compact}
onRemove={removeAttachment}
onClearAll={
clearAllAttachments
}
/>
<ChatEditor
ref={editorRef}
onSubmit={handleEditorSubmit}
@ -1877,16 +1962,6 @@ export const ChatPanel = forwardRef<ChatPanelHandle, ChatPanelProps>(
compact={compact}
/>
{/* Attachment preview strip */}
<AttachmentStrip
files={attachedFiles}
compact={compact}
onRemove={removeAttachment}
onClearAll={
clearAllAttachments
}
/>
{/* Toolbar row */}
<div
className={`flex items-center justify-between ${compact ? "px-2 pb-1.5" : "px-3 pb-2.5"}`}

View File

@ -259,6 +259,31 @@ export const ChatEditor = forwardRef<ChatEditorHandle, ChatEditorProps>(
// 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")) {

View File

@ -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 {