(function ChatInput({
fileInputRef.current?.click()}
- title={t("chat.attachImage")}
+ title={t("chat.attachFiles")}
style={{
flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center",
width: 32, height: 32, padding: 0,
diff --git a/components/ChatWindow.attach-files.test.mjs b/components/ChatWindow.attach-files.test.mjs
new file mode 100644
index 0000000000..05617d8d54
--- /dev/null
+++ b/components/ChatWindow.attach-files.test.mjs
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+import { Script } from "node:vm";
+import { createJiti } from "jiti";
+import ts from "typescript";
+
+const jiti = createJiti(import.meta.url);
+const { dropMentionText, splitDroppedItems } = await jiti.import("../lib/file-upload-client.ts");
+
+const sourceText = readFileSync(new URL("./ChatWindow.tsx", import.meta.url), "utf8");
+const source = ts.createSourceFile("ChatWindow.tsx", sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+
+/** Runs one of ChatWindow's useCallback bodies against the given scope. */
+function chatWindowCallback(name, context) {
+ function findCallback(node) {
+ if (ts.isVariableDeclaration(node) && node.name.getText(source) === name) {
+ return node.initializer.arguments[0];
+ }
+ return ts.forEachChild(node, findCallback);
+ }
+ return new Script(ts.transpileModule(findCallback(source).getText(source), {
+ compilerOptions: { target: ts.ScriptTarget.ES2020 },
+ }).outputText).runInNewContext(context);
+}
+
+const photo = new File(["x"], "photo.png", { type: "image/png" });
+const report = new File(["x"], "report.pdf", { type: "application/pdf" });
+const log = new File(["x"], "server.log", { type: "" });
+
+/** Picks files with the composer's attach button, through ChatWindow's real drop path. */
+async function attach(files, { cwd = "/project", response } = {}) {
+ const calls = { images: [], uploads: [], mentions: [], notices: [], refreshed: 0 };
+ const scope = {
+ session: null,
+ newSessionCwd: cwd,
+ chatInputRef: {
+ current: {
+ addImages: (images) => calls.images.push(...images),
+ insertText: (text) => calls.mentions.push(text),
+ },
+ },
+ addNotice: (notice) => calls.notices.push([notice.type, notice.message]),
+ t: (key, vars) => (vars ? `${key} ${JSON.stringify(vars)}` : key),
+ onFilesUploaded: () => { calls.refreshed += 1; },
+ splitDroppedItems,
+ dropMentionText,
+ uploadFiles: async (target, uploads, strategy) => {
+ calls.uploads.push([target, uploads.map((file) => file.name), strategy]);
+ return response;
+ },
+ };
+ scope.uploadDroppedFiles = chatWindowCallback("uploadDroppedFiles", scope);
+ scope.onDrop = chatWindowCallback("onDrop", scope);
+ chatWindowCallback("onAttachFiles", scope)(files);
+ await new Promise((resolve) => setImmediate(resolve));
+ return calls;
+}
+
+test("the attach button sends picked files down the chat drop path (#1101)", async () => {
+ const mixed = await attach([photo, report, log], {
+ response: { status: 207, data: { uploaded: ["report.pdf"], skipped: ["server.log"] } },
+ });
+ // Images attach to the prompt; the rest are uploaded like a drop: never replacing a file, then mentioned.
+ assert.deepEqual(mixed.images, [photo]);
+ assert.deepEqual(mixed.uploads, [["/project", ["report.pdf", "server.log"], "skip"]]);
+ assert.deepEqual(mixed.mentions, ["@report.pdf @server.log "]);
+ assert.deepEqual(mixed.notices, [
+ ["success", 'chat.dropUploaded {"count":1}'],
+ ["warning", 'chat.dropAlreadyExists {"names":"server.log"}'],
+ ]);
+ assert.equal(mixed.refreshed, 1);
+
+ const imagesOnly = await attach([photo]);
+ assert.deepEqual(imagesOnly.images, [photo]);
+ assert.deepEqual(imagesOnly.uploads, []);
+ assert.deepEqual(imagesOnly.notices, []);
+});
+
+test("without a working directory, picked files behave like a drop there (#1101)", async () => {
+ const calls = await attach([photo, report], { cwd: null });
+ assert.deepEqual(calls.images, [photo]);
+ assert.deepEqual(calls.uploads, []);
+ assert.deepEqual(calls.notices, [["warning", "chat.dropNeedsCwd"]]);
+});
+
+test("wires the attach handler into the main composer only", () => {
+ assert.match(sourceText, /cwd=\{session\?\.cwd \?\? newSessionCwd\}\s*onAttachFiles=\{onAttachFiles\}/);
+ assert.equal((sourceText.match(/onAttachFiles=/g) ?? []).length, 1);
+});
diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx
index 09165f3105..96aeda2c74 100644
--- a/components/ChatWindow.tsx
+++ b/components/ChatWindow.tsx
@@ -728,7 +728,8 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
// Images attach to the prompt. Other files go through the file explorer's
// upload into the working directory, never replacing a file already there,
- // and come back as @mentions.
+ // and come back as @mentions. The composer's attach button takes the same
+ // path, for phones that cannot drop files.
const uploadDroppedFiles = useCallback(async (files: File[]) => {
const cwd = session?.cwd ?? newSessionCwd;
if (!cwd) {
@@ -766,6 +767,10 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
if (files.length > 0) void uploadDroppedFiles(files);
}, [addNotice, chatInputRef, t, uploadDroppedFiles]);
+ const onAttachFiles = useCallback((files: File[]) => {
+ onDrop(files.map((file) => ({ kind: "file", file })));
+ }, [onDrop]);
+
const { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop);
const visibleMessages = messages.filter((m) => isMessageGroupAnchor(m) || m.role === "assistant");
@@ -955,6 +960,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
onAudioUnlock={unlockAudio}
draftKey={session?.id ?? newSessionDraftKey ?? undefined}
cwd={session?.cwd ?? newSessionCwd}
+ onAttachFiles={onAttachFiles}
/>
);
diff --git a/docs/agents/files-and-access.md b/docs/agents/files-and-access.md
index aa08f2c019..9e23ae95d0 100644
--- a/docs/agents/files-and-access.md
+++ b/docs/agents/files-and-access.md
@@ -19,6 +19,7 @@
## Uploads and chat drops
- The explorer's Upload button and a non-image file dropped onto the chat share one client, `uploadFiles()` (`lib/file-upload-client.ts`), and the one endpoint, `POST /api/files/?type=upload`; never add a second write path for drops. The explorer asks `upload-check` first and lets the user replace or skip; a chat drop goes straight to `conflict=skip`, so it never replaces a file. It then inserts `@name` for every file the response lists as uploaded *or* skipped (a skipped name is a file already there, often the very file dragged out of the project) and says in a notice that the mention points to the existing file.
- Dropped images still attach to the prompt. Folders are left out with a notice: the endpoint writes flat file names only (`validateUploadFileNames()`), and the browser cannot tell where a dropped file lives on disk, so the copy always lands in the cwd root.
+- The composer's attach button (#1101, for phones that cannot drop) is a drop by another route: its file input has no `accept` list (and no `capture`, so phones still offer photos and the camera), and `ChatInput` hands the whole selection to ChatWindow's `onAttachFiles`, which calls the drop handler. Images attach, other files upload and get mentioned, with the same notices; keep one path for both. A `ChatInput` without `onAttachFiles` keeps `accept="image/*"`.
## Web password throttling
- `lib/auth-throttle.ts` is deliberately global, not per-IP (its header says why). Failures double the delay (1 s → 60 s cap) for everyone; a success or 5 idle minutes resets it. The reset window must stay longer than the max delay, or waiting out one block restarts the burst.
diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts
index e4a2a1f7ac..22e3aca382 100644
--- a/lib/i18n/messages/en.ts
+++ b/lib/i18n/messages/en.ts
@@ -498,7 +498,7 @@ export const enLocale: LocalePlugin = {
"chat.quoteIntro": "About this passage:",
"chat.quoteQuestion": "My question:",
"chat.quoteForkFailed": "Unable to create a branch from this message.",
- "chat.attachImage": "Attach image",
+ "chat.attachFiles": "Attach files",
"chat.previewImage": "Preview image",
"chat.filterModels": "Filter models…",
"chat.noMatchingModels": "No matching models",
@@ -562,7 +562,7 @@ export const enLocale: LocalePlugin = {
"chat.compacted": "Compacted",
"chat.imageNotSupportedTitle": "Images may not be sent",
"chat.imageNotSupportedBody": "The selected model ({model}) does not support image input. The attached images will likely be ignored.",
- "chat.dropNeedsCwd": "Choose a working directory before dropping files.",
+ "chat.dropNeedsCwd": "Choose a working directory before adding files.",
"chat.dropUploaded": "Copied {count} file(s) into the working directory",
"chat.dropAlreadyExists": "Already in the working directory and not replaced; the mention points to the existing file: {names}",
"chat.dropFoldersUnsupported": "Folders cannot be dropped here; drop the files inside instead: {names}",
diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts
index c8f3f7e9db..6c9da543dd 100644
--- a/lib/i18n/messages/zh-CN.ts
+++ b/lib/i18n/messages/zh-CN.ts
@@ -498,7 +498,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.quoteIntro": "关于这段内容:",
"chat.quoteQuestion": "我的问题是:",
"chat.quoteForkFailed": "无法从这条消息创建分支。",
- "chat.attachImage": "附加图片",
+ "chat.attachFiles": "附加文件",
"chat.previewImage": "预览图片",
"chat.filterModels": "筛选模型…",
"chat.noMatchingModels": "没有匹配的模型",
@@ -562,7 +562,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.compacted": "已压缩",
"chat.imageNotSupportedTitle": "图片可能无法发送",
"chat.imageNotSupportedBody": "当前选择的模型({model})不支持图片输入,附加的图片可能会被忽略。",
- "chat.dropNeedsCwd": "请先选择工作目录,再拖入文件。",
+ "chat.dropNeedsCwd": "请先选择工作目录,再添加文件。",
"chat.dropUploaded": "已将 {count} 个文件复制到工作目录",
"chat.dropAlreadyExists": "工作目录中已有同名文件,未覆盖,引用的是已有文件:{names}",
"chat.dropFoldersUnsupported": "不支持拖入文件夹,请拖入其中的文件:{names}",
diff --git a/lib/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts
index 392c45e297..4a2f1d9501 100644
--- a/lib/i18n/messages/zh-TW.ts
+++ b/lib/i18n/messages/zh-TW.ts
@@ -498,7 +498,7 @@ export const zhTWLocale: LocalePlugin = {
"chat.quoteIntro": "關於這段內容:",
"chat.quoteQuestion": "我的問題是:",
"chat.quoteForkFailed": "無法從這則訊息建立分支。",
- "chat.attachImage": "附加圖片",
+ "chat.attachFiles": "附加檔案",
"chat.previewImage": "預覽圖片",
"chat.filterModels": "篩選模型…",
"chat.noMatchingModels": "找不到相符的模型",
@@ -562,7 +562,7 @@ export const zhTWLocale: LocalePlugin = {
"chat.compacted": "已壓縮",
"chat.imageNotSupportedTitle": "圖片可能無法傳送",
"chat.imageNotSupportedBody": "目前選擇的模型({model})不支援圖片輸入,附加的圖片可能會被忽略。",
- "chat.dropNeedsCwd": "請先選擇工作目錄,再拖入檔案。",
+ "chat.dropNeedsCwd": "請先選擇工作目錄,再加入檔案。",
"chat.dropUploaded": "已將 {count} 個檔案複製到工作目錄",
"chat.dropAlreadyExists": "工作目錄中已有同名檔案,未覆寫,引用的是既有檔案:{names}",
"chat.dropFoldersUnsupported": "不支援拖入資料夾,請拖入其中的檔案:{names}",