Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion components/ChatInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,44 @@ test("renders the compact composer with the standard Send button and no session
assert.match(html, /<textarea/);
assert.match(html, />Send<\/button>/);
assert.equal((html.match(/<button\b/g) ?? []).length, 1);
assert.doesNotMatch(html, /type="file"|Attach image|Change tool preset/);
assert.doesNotMatch(html, /type="file"|Attach files|Change tool preset/);
});

test("the attach button takes any file when the chat handles them, and images only otherwise (#1101)", () => {
const render = (props) => renderToStaticMarkup(
React.createElement(I18nProvider, null, React.createElement(ChatInput, {
onSend() {}, onAbort() {}, isStreaming: false, ...props,
})),
);
const fileInput = (html) => html.match(/<input[^>]*type="file"[^>]*>/)?.[0] ?? "";

const html = render({ onAttachFiles() {} });
assert.match(html, /title="Attach files"/);
assert.match(fileInput(html), /multiple=""/);
// No accept list and no capture: phones offer files, the photo library and the camera.
assert.doesNotMatch(fileInput(html), /accept=|capture=/);

assert.match(fileInput(render({})), /accept="image\/\*"/);
});

test("picked files go to the chat's attach handler, or attach as images without one (#1101)", () => {
const photo = new File(["x"], "photo.png", { type: "image/png" });
const report = new File(["x"], "report.pdf", { type: "application/pdf" });
const pick = (context) => {
const calls = { attached: [], images: [] };
const target = { files: [photo, report], value: "C:\\fakepath\\photo.png" };
chatInputCallback("handleFilePick", {
processImageFiles: (files) => calls.images.push([...files]),
...context(calls),
})({ target });
return { ...calls, value: target.value };
};

assert.deepEqual(
pick((calls) => ({ onAttachFiles: (files) => calls.attached.push([...files]) })),
{ attached: [[photo, report]], images: [], value: "" },
);
assert.deepEqual(pick(() => ({ onAttachFiles: undefined })), { attached: [], images: [[photo, report]], value: "" });
});

test("shows and locks the optimistic model while a switch is pending", () => {
Expand Down
23 changes: 15 additions & 8 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ interface Props {
draftKey?: string;
/** Session working directory — enables the @ file autocomplete menu */
cwd?: string | null;
/** Files picked with the attach button, handled like files dropped onto the chat. Without it the button takes images only. */
onAttachFiles?: (files: File[]) => void;
}

export interface ChatInputHandle {
Expand Down Expand Up @@ -643,6 +645,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
onPromptWithStreamingBehavior,
draftKey,
cwd,
onAttachFiles,
compact = false,
}: Props, ref) {
const { t } = useI18n();
Expand Down Expand Up @@ -906,6 +909,14 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
}
}, [compact]);

const handleFilePick = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
// Clearing the value lets the same file be picked again.
e.target.value = "";
if (onAttachFiles) onAttachFiles(files);
else processImageFiles(files);
}, [onAttachFiles, processImageFiles]);

const removeImage = useCallback((index: number) => {
setAttachedImages((prev) => {
const next = [...prev];
Expand Down Expand Up @@ -1685,18 +1696,14 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
transition: "opacity 0.15s",
}}
>
{/* Hidden file input */}
{/* Hidden file input. No `capture`: phones still offer the photo library and camera. */}
{!compact && <input
ref={fileInputRef}
type="file"
accept="image/*"
accept={onAttachFiles ? undefined : "image/*"}
multiple
style={{ display: "none" }}
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
processImageFiles(files);
e.target.value = "";
}}
onChange={handleFilePick}
/>}
<div style={{ maxWidth: "var(--chat-content-max-width, 820px)", margin: "0 auto" }}>
<ModelErrorBanner error={modelError} />
Expand Down Expand Up @@ -2379,7 +2386,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
<div style={{ flex: isMobile ? "1 1 auto" : "0 0 auto", minWidth: 0, display: "flex", alignItems: "center", gap: 2 }}>
<button
onClick={() => 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,
Expand Down
90 changes: 90 additions & 0 deletions components/ChatWindow.attach-files.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 7 additions & 1 deletion components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -955,6 +960,7 @@ export function ChatWindow({ session, searchTarget, onSearchTargetHandled, initi
onAudioUnlock={unlockAudio}
draftKey={session?.id ?? newSessionDraftKey ?? undefined}
cwd={session?.cwd ?? newSessionCwd}
onAttachFiles={onAttachFiles}
/>
);

Expand Down
1 change: 1 addition & 0 deletions docs/agents/files-and-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<cwd>?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.
Expand Down
4 changes: 2 additions & 2 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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}",
Expand Down
4 changes: 2 additions & 2 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ export const zhCNLocale: LocalePlugin = {
"chat.quoteIntro": "关于这段内容:",
"chat.quoteQuestion": "我的问题是:",
"chat.quoteForkFailed": "无法从这条消息创建分支。",
"chat.attachImage": "附加图片",
"chat.attachFiles": "附加文件",
"chat.previewImage": "预览图片",
"chat.filterModels": "筛选模型…",
"chat.noMatchingModels": "没有匹配的模型",
Expand Down Expand Up @@ -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}",
Expand Down
4 changes: 2 additions & 2 deletions lib/i18n/messages/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,7 @@ export const zhTWLocale: LocalePlugin = {
"chat.quoteIntro": "關於這段內容:",
"chat.quoteQuestion": "我的問題是:",
"chat.quoteForkFailed": "無法從這則訊息建立分支。",
"chat.attachImage": "附加圖片",
"chat.attachFiles": "附加檔案",
"chat.previewImage": "預覽圖片",
"chat.filterModels": "篩選模型…",
"chat.noMatchingModels": "找不到相符的模型",
Expand Down Expand Up @@ -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}",
Expand Down
Loading