From fab9add5d5e67a83ea7c4b9aa22776c9164f2a84 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sun, 8 Nov 2026 16:55:51 +0100 Subject: [PATCH] Attach any file type in chat and queue (uploaded into the project) Co-Authored-By: Claude Opus 5.5 --- web/src/components/Composer.tsx | 71 ++++++++++++++++++++++++++++----- web/src/i18n/de.ts | 6 ++- web/src/i18n/en.ts | 6 ++- web/src/styles.css | 21 ++++++++++ 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/web/src/components/Composer.tsx b/web/src/components/Composer.tsx index 15e6c47..57ef6d5 100644 --- a/web/src/components/Composer.tsx +++ b/web/src/components/Composer.tsx @@ -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 { const bitmap = await createImageBitmap(file); @@ -51,6 +57,9 @@ export function Composer({ const t = useT(); const ref = useRef(null); const [images, setImages] = useState([]); + // 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({ ))} )} - {images.length > 0 && ( + {(images.length > 0 || files.length > 0 || uploading > 0) && (
+ {files.map((f) => ( +
+ đź“„ {f.name} + {formatSize(f.size)} + +
+ ))} + {uploading > 0 && ( +
+ {t("composer.uploading", { n: uploading })} +
+ )} {images.map((img, i) => (
@@ -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({ onUpdate({ model: m })} title={t("composer.model")} />
@@ -209,7 +260,7 @@ export function Composer({ â–  {t("composer.stop")} )} -
diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 99a62f3..c2729b5 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -123,6 +123,8 @@ export const de: Messages = { "git.commit": "Commit", "git.noCommits": "Noch keine Commits.", "git.noDiff": "Keine Unterschiede.", + "composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):", + "composer.uploading": "Lade {n} hoch…", "login.prompt": "Melde dich an, um fortzufahren.", "login.invalid": "Ungültiges Token.", "login.submit": "Anmelden", @@ -175,7 +177,7 @@ export const de: Messages = { "chat.delete": "Session löschen", "chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.", - "composer.placeholder": "Nachricht… (/ für Befehle, Bilder einfügen)", + "composer.placeholder": "Nachricht… (/ für Befehle, Dateien anhängen oder einfügen)", "composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…", "composer.send": "Senden", "composer.queue": "Einreihen", @@ -183,7 +185,7 @@ export const de: Messages = { "composer.mode": "Modus", "composer.model": "Modell", "composer.permissionMode": "Berechtigungsmodus", - "composer.attach": "Bilder anhängen", + "composer.attach": "Dateien anhängen", "command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben", "command.new": "Neue Session starten", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2ea6a6c..947e034 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -122,6 +122,8 @@ export const en = { "git.commit": "Commit", "git.noCommits": "No commits yet.", "git.noDiff": "No differences.", + "composer.attachedFiles": "Attached files (in the project, read them with your tools):", + "composer.uploading": "Uploading {n}…", "login.prompt": "Sign in to continue.", "login.invalid": "Invalid token.", "login.submit": "Sign in", @@ -174,7 +176,7 @@ export const en = { "chat.delete": "Delete session", "chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.", - "composer.placeholder": "Message… (/ for commands, paste images)", + "composer.placeholder": "Message… (/ for commands, attach or paste files)", "composer.placeholderBusy": "Message is queued and sent at the next step…", "composer.send": "Send", "composer.queue": "Queue", @@ -182,7 +184,7 @@ export const en = { "composer.mode": "Mode", "composer.model": "Model", "composer.permissionMode": "Permission mode", - "composer.attach": "Attach images", + "composer.attach": "Attach files", "command.compact": "Summarize the conversation to free up context", "command.new": "Start a new session", diff --git a/web/src/styles.css b/web/src/styles.css index 901653e..9f64a33 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -710,6 +710,27 @@ textarea:focus { .attachments .att { position: relative; } +.attachments .att.file { + display: flex; + align-items: center; + gap: 6px; + height: 36px; + padding: 0 22px 0 10px; + border: 1px solid var(--bg3); + border-radius: 6px; + background: var(--bg1); + font-size: 12.5px; + max-width: 260px; +} +.attachments .att.file span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.attachments .att.file small { + color: var(--muted); + white-space: nowrap; +} .attachments img { height: 56px; border-radius: 6px;