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>
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+21
View File
@@ -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;