Attachment previews in chat panel, input, and queue.
This commit is contained in:
parent
6c6289eb2e
commit
face53f234
2
.gitignore
vendored
2
.gitignore
vendored
@ -9,7 +9,7 @@ bun.lockb
|
||||
coverage
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.tsbuildinfo
|
||||
*.tsbuildinfo
|
||||
.pnpm-store
|
||||
.worktrees/
|
||||
.DS_Store
|
||||
|
||||
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal file
69
apps/web/app/api/workspace/thumbnail/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@ -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" },
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"}`}
|
||||
|
||||
@ -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")) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user