Attach any file type in chat and queue (uploaded into the project)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 16:55:51 +01:00
1 parent 49262c612c
commit fab9add5d5
4 files changed
+90 -14

No files matched your search

+61 -10
View File
@@ -16,6 +16,12 @@ const BUILTIN: { name: string; key: Key }[] = [
const MAX_SIDE = 2000;
function formatSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
/** Read an image and downscale it: models don't use more detail, and uploads stay small. */
async function readImage(file: File): Promise<ImageData> {
const bitmap = await createImageBitmap(file);
@@ -51,6 +57,9 @@ export function Composer({
const t = useT();
const ref = useRef<HTMLTextAreaElement>(null);
const [images, setImages] = useState<ImageData[]>([]);
// Non-image attachments, already uploaded into the project.
const [files, setFiles] = useState<{ name: string; path: string; size: number }[]>([]);
const [uploading, setUploading] = useState(0);
const [active, setActive] = useState(0);
const agents = useAgents(session.cwd).filter((a) => a.mode === "primary");
const cmdData = useFetch<{ commands: Suggestion[] }>(`/commands?cwd=${encodeURIComponent(session.cwd)}`).data;
@@ -74,9 +83,12 @@ export function Composer({
}, [text, cmdData, t]);
const send = () => {
if (!text.trim() && images.length === 0) return;
onSend(text, images);
if ((!text.trim() && images.length === 0 && files.length === 0) || uploading > 0) return;
// Files are referenced by path; the agent reads them with its tools.
const refs = files.length ? `\n\n${t("composer.attachedFiles")}\n${files.map((f) => `- ${f.path} (${formatSize(f.size)})`).join("\n")}` : "";
onSend(`${text}${refs}`, images);
setImages([]);
setFiles([]);
};
const onKeyDown = (e: React.KeyboardEvent) => {
@@ -101,13 +113,38 @@ export function Composer({
if (e.key === "Escape" && busy) onAbort();
};
const addFiles = async (files: FileList | File[]) => {
const addFiles = async (list: FileList | File[]) => {
const all = [...list];
try {
const imgs = await Promise.all([...files].filter((f) => f.type.startsWith("image/")).map(readImage));
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
} catch (e: any) {
alert(e.message ?? String(e));
}
// Everything else is uploaded into the project folder.
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
const stamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14);
const path = `${session.cwd}/.codecraft/uploads/${stamp}-${f.name.replace(/[\\/]/g, "_")}`;
setUploading((n) => n + 1);
try {
await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft`, dir: true }) });
await fetch(`/api/files/create?`, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: `${session.cwd}/.codecraft/uploads`, dir: true }) });
// Keep uploads out of git status without touching the project's .gitignore.
await fetch(`/api/files/upload?path=${encodeURIComponent(`${session.cwd}/.codecraft/uploads/.gitignore`)}`, { method: "PUT", body: "*\n", credentials: "same-origin" });
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(path)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
} catch (e: any) {
alert(e.message ?? String(e));
} finally {
setUploading((n) => n - 1);
}
}
};
const removeFile = (path: string) => {
setFiles((cur) => cur.filter((f) => f.path !== path));
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
};
return (
@@ -140,8 +177,22 @@ export function Composer({
))}
</div>
)}
{images.length > 0 && (
{(images.length > 0 || files.length > 0 || uploading > 0) && (
<div className="attachments">
{files.map((f) => (
<div className="att file" key={f.path} title={f.path}>
<span>📄 {f.name}</span>
<small>{formatSize(f.size)}</small>
<button onClick={() => removeFile(f.path)} aria-label={t("common.remove")}>
×
</button>
</div>
))}
{uploading > 0 && (
<div className="att file">
<span>{t("composer.uploading", { n: uploading })}</span>
</div>
)}
{images.map((img, i) => (
<div className="att" key={i}>
<img src={`data:${img.media_type};base64,${img.data}`} alt="" />
@@ -163,10 +214,10 @@ export function Composer({
}}
onKeyDown={onKeyDown}
onPaste={(e) => {
const files = [...e.clipboardData.files];
if (files.some((f) => f.type.startsWith("image/"))) {
const pasted = [...e.clipboardData.files];
if (pasted.length > 0) {
e.preventDefault();
void addFiles(files);
void addFiles(pasted);
}
}}
aria-label={t("composer.placeholder")}
@@ -200,7 +251,7 @@ export function Composer({
<ModelSelect value={session.model} onChange={(m) => onUpdate({ model: m })} title={t("composer.model")} />
<label className="ghost" style={{ cursor: "pointer", padding: "3px 6px" }} title={t("composer.attach")}>
📎
<input type="file" accept="image/*" multiple hidden onChange={(e) => e.target.files && addFiles(e.target.files)} />
<input type="file" multiple hidden onChange={(e) => e.target.files && (addFiles(e.target.files), (e.target.value = ""))} />
</label>
</div>
<div className="composer-actions">
@@ -209,7 +260,7 @@ export function Composer({
■ {t("composer.stop")}
</button>
)}
<button className="primary" onClick={send} disabled={!text.trim() && images.length === 0}>
<button className="primary" onClick={send} disabled={uploading > 0 || (!text.trim() && images.length === 0 && files.length === 0)}>
{busy ? t("composer.queue") : t("composer.send")}
</button>
</div>