From 9052766efa654372fecbf30015ee2c14dc3a657c Mon Sep 17 00:00:00 2001 From: WXR Date: Sat, 1 Aug 2026 19:32:04 +0800 Subject: [PATCH 1/2] feat: accept any file drag into the chat input Dropping a file from the OS file manager onto the chat window now works for any file type, not just images. - Images keep the existing behavior: they are attached as base64 multimodal content and sent with the message. - Other files get their real filesystem path inserted into the input at the caret, so the agent can read them with its own tools. Browsers do not expose the path on File objects, but OS-originated drags carry file:// URIs on the dataTransfer's text/uri-list; since pi-web is a local app the path is directly usable. Falls back to the bare file name when no path is available. - The drop overlay icon now distinguishes image vs file drags. Adds lib/dropped-files.ts with unit-tested parsing logic. --- components/ChatInput.tsx | 62 +++++++++++++++++++++++-------------- components/ChatWindow.tsx | 44 ++++++++++++++++---------- hooks/useAgentSession.ts | 1 + hooks/useDragDrop.ts | 19 +++++++----- lib/dropped-files.test.mjs | 54 ++++++++++++++++++++++++++++++++ lib/dropped-files.ts | 63 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 47 deletions(-) create mode 100644 lib/dropped-files.test.mjs create mode 100644 lib/dropped-files.ts diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bdf..5575bf952 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -13,6 +13,7 @@ import { buildEntriesFromFiles, buildAtInsertText, extractAtQuery, filterFileEntries, type AtQueryMatch, type FileIndexEntry, } from "@/lib/file-fuzzy"; +import { droppedFilePaths, droppedFileReference } from "@/lib/dropped-files"; import { FolderIcon, getFileIcon } from "./FileIcons"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useI18n } from "@/hooks/useI18n"; @@ -76,6 +77,7 @@ export interface ChatInputHandle { insertIfEmpty: (text: string) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; + addFiles: (files: File[], dataTransfer?: DataTransfer | null) => void; } const TOOL_PRESETS = ["off", "default", "full"] as const; @@ -379,6 +381,30 @@ export const ChatInput = forwardRef(function ChatInput({ valueRef.current = value; attachedImagesRef.current = attachedImages; + const insertTextAtCursor = useCallback((text: string) => { + const ta = textareaRef.current; + if (!ta) { + setValue((v) => v + (v ? " " : "") + text); + return; + } + const start = ta.selectionStart ?? ta.value.length; + const end = ta.selectionEnd ?? ta.value.length; + const before = ta.value.slice(0, start); + const after = ta.value.slice(end); + const sep = before.length > 0 && !before.endsWith(" ") ? " " : ""; + const newVal = before + sep + text + after; + setValue(newVal); + setAtQuery(null); + requestAnimationFrame(() => { + if (!ta) return; + const pos = start + sep.length + text.length; + ta.setSelectionRange(pos, pos); + ta.focus(); + ta.style.height = "auto"; + ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; + }); + }, []); + useImperativeHandle(ref, () => ({ insertIfEmpty(text: string) { const ta = textareaRef.current; @@ -410,32 +436,22 @@ export const ChatInput = forwardRef(function ChatInput({ ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; }); }, - insertText(text: string) { - const ta = textareaRef.current; - if (!ta) { - setValue((v) => v + (v ? " " : "") + text); - return; - } - const start = ta.selectionStart ?? ta.value.length; - const end = ta.selectionEnd ?? ta.value.length; - const before = ta.value.slice(0, start); - const after = ta.value.slice(end); - const sep = before.length > 0 && !before.endsWith(" ") ? " " : ""; - const newVal = before + sep + text + after; - setValue(newVal); - setAtQuery(null); - requestAnimationFrame(() => { - if (!ta) return; - const pos = start + sep.length + text.length; - ta.setSelectionRange(pos, pos); - ta.focus(); - ta.style.height = "auto"; - ta.style.height = `${Math.min(ta.scrollHeight, 200)}px`; - }); - }, + insertText: insertTextAtCursor, addImages(files: File[]) { processImageFiles(files); }, + addFiles(files: File[], dataTransfer?: DataTransfer | null) { + if (isStreaming) return; + const imageFiles = files.filter((f) => f.type.startsWith("image/")); + const otherFiles = files.filter((f) => !f.type.startsWith("image/")); + if (imageFiles.length) processImageFiles(imageFiles); + if (!otherFiles.length) return; + const uriList = dataTransfer?.getData("text/uri-list") ?? ""; + const plainText = dataTransfer?.getData("text/plain") ?? ""; + const paths = droppedFilePaths(otherFiles, uriList, plainText); + const references = otherFiles.map((file, index) => droppedFileReference(file, paths[index])); + insertTextAtCursor(references.join(" ")); + }, })); const processImageFiles = useCallback(async (files: File[]) => { diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..0cb53e74d 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -304,12 +304,12 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate }, [ctxKey, onContextUsageChange]); useEffect(() => () => { onContextUsageChange?.(null); }, [onContextUsageChange]); - const onDrop = useCallback((files: File[]) => { + const onDrop = useCallback((files: File[], dataTransfer: DataTransfer | null) => { if (sessionBusy) return; - chatInputRef?.current?.addImages(files); + chatInputRef?.current?.addFiles(files, dataTransfer); }, [sessionBusy, chatInputRef]); - const { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop); + const { isDragOver, dragHasImages, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } = useDragDrop(onDrop); const visibleMessages = messages.filter((m) => m.role === "user" || m.role === "assistant"); const inputHistory = useMemo(() => { @@ -426,19 +426,31 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate width="280" height="280" viewBox="0 0 140 140" fill="none" xmlns="http://www.w3.org/2000/svg" className="drop-shadow-[0_6px_18px_rgba(37,99,235,0.18)]" > - - - - - - - - - - - - - + {dragHasImages ? ( + + + + + + + + + + + + + + + + ) : ( + + + + + + + + )} )} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index a482f7120..79bc61692 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -305,6 +305,7 @@ export interface ChatInputHandle { insertIfEmpty: (content: string) => void; prependText: (text: string) => void; addImages: (files: File[]) => void; + addFiles: (files: File[], dataTransfer?: DataTransfer | null) => void; } export interface AttachedImage { diff --git a/hooks/useDragDrop.ts b/hooks/useDragDrop.ts index d490721b6..2574513ac 100644 --- a/hooks/useDragDrop.ts +++ b/hooks/useDragDrop.ts @@ -2,21 +2,23 @@ import { useState, useCallback, useRef } from "react"; -export function useDragDrop(onDrop: (files: File[]) => void) { +export function useDragDrop(onDrop: (files: File[], dataTransfer: DataTransfer | null) => void) { const [isDragOver, setIsDragOver] = useState(false); + const [dragHasImages, setDragHasImages] = useState(false); const counterRef = useRef(0); const handleDragEnter = useCallback((e: React.DragEvent) => { - const hasImages = Array.from(e.dataTransfer.items).some((item) => item.type.startsWith("image/")); - if (!hasImages) return; + const items = Array.from(e.dataTransfer.items); + if (!items.some((item) => item.kind === "file")) return; e.preventDefault(); counterRef.current += 1; setIsDragOver(true); + setDragHasImages(items.some((item) => item.type.startsWith("image/"))); }, []); const handleDragOver = useCallback((e: React.DragEvent) => { - const hasImages = Array.from(e.dataTransfer.items).some((item) => item.type.startsWith("image/")); - if (!hasImages) return; + const items = Array.from(e.dataTransfer.items); + if (!items.some((item) => item.kind === "file")) return; e.preventDefault(); }, []); @@ -33,8 +35,9 @@ export function useDragDrop(onDrop: (files: File[]) => void) { counterRef.current = 0; setIsDragOver(false); const files = Array.from(e.dataTransfer.files); - onDrop(files); + if (!files.length) return; + onDrop(files, e.dataTransfer); }, [onDrop]); - return { isDragOver, handleDragEnter, handleDragOver, handleDragLeave, handleDrop }; -} \ No newline at end of file + return { isDragOver, dragHasImages, handleDragEnter, handleDragOver, handleDragLeave, handleDrop }; +} diff --git a/lib/dropped-files.test.mjs b/lib/dropped-files.test.mjs new file mode 100644 index 000000000..945e7e6ab --- /dev/null +++ b/lib/dropped-files.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url, { + tsconfigPaths: true, +}); +const { decodeDroppedFileUri, droppedFilePaths, droppedFileReference } = await jiti.import("./dropped-files.ts"); + +test("decodes POSIX file URIs", () => { + assert.equal(decodeDroppedFileUri("file:///home/me/a%20b.txt"), "/home/me/a b.txt"); +}); + +test("decodes Windows drive file URIs", () => { + assert.equal(decodeDroppedFileUri("file:///C:/Users/me/a.txt"), "C:/Users/me/a.txt"); +}); + +test("rejects non-file URIs and malformed input", () => { + assert.equal(decodeDroppedFileUri("https://example.com/x"), null); + assert.equal(decodeDroppedFileUri("file:///home/me/%zz"), null); + assert.equal(decodeDroppedFileUri("file://"), null); +}); + +test("maps file URIs to files in order", () => { + const files = [new File(["a"], "a.txt"), new File(["b"], "b.log")]; + const paths = droppedFilePaths(files, "file:///tmp/a.txt\r\nfile:///tmp/b.log", ""); + assert.deepEqual(paths, ["/tmp/a.txt", "/tmp/b.log"]); +}); + +test("ignores comments and blank lines in uri-list", () => { + const files = [new File(["a"], "a.txt")]; + const paths = droppedFilePaths(files, "# comment\r\n\r\nfile:///tmp/a.txt", ""); + assert.deepEqual(paths, ["/tmp/a.txt"]); +}); + +test("falls back to null when the URI count does not match", () => { + const files = [new File(["a"], "a.txt")]; + assert.deepEqual(droppedFilePaths(files, "file:///tmp/a.txt\r\nfile:///tmp/b.log", ""), [null]); + assert.deepEqual(droppedFilePaths(files, "https://example.com/a.txt", ""), [null]); +}); + +test("uses an absolute text/plain fallback for a single file", () => { + const files = [new File(["a"], "a.txt")]; + assert.deepEqual(droppedFilePaths(files, "", "/tmp/a.txt"), ["/tmp/a.txt"]); + assert.deepEqual(droppedFilePaths(files, "", "C:\\Users\\me\\a.txt"), ["C:\\Users\\me\\a.txt"]); + // Bare basenames carry no location, so they are ignored. + assert.deepEqual(droppedFilePaths(files, "", "a.txt"), [null]); +}); + +test("reference falls back to the file name", () => { + const file = new File(["a"], "notes.md"); + assert.equal(droppedFileReference(file, "/tmp/notes.md"), "/tmp/notes.md"); + assert.equal(droppedFileReference(file, null), "notes.md"); +}); diff --git a/lib/dropped-files.ts b/lib/dropped-files.ts new file mode 100644 index 000000000..68c2eaacc --- /dev/null +++ b/lib/dropped-files.ts @@ -0,0 +1,63 @@ +// Mapping for files dragged into the chat input from the OS file manager. +// +// Browsers never expose the real filesystem path of a dropped file on the +// File object. However, when the drag originates from the OS file manager, +// Chrome/Edge/Firefox/Safari put file:// URIs on the dataTransfer's +// "text/uri-list". pi-web is a local app (browser and agent on the same +// machine), so that path is directly usable by the agent. + +/** Decode a single file:// URI into a filesystem path, or null when malformed. */ +export function decodeDroppedFileUri(uri: string): string | null { + if (!uri.startsWith("file://")) return null; + let path: string; + try { + path = decodeURIComponent(uri.slice("file://".length)); + } catch { + return null; + } + if (!path) return null; + // file:///C:/Users/me/a.txt -> /C:/Users/me/a.txt -> C:/Users/me/a.txt + if (/^\/[A-Za-z]:[\\/]/.test(path)) path = path.slice(1); + return path; +} + +/** + * Best-effort mapping of dropped File objects to their real filesystem paths. + * + * The i-th file:// URI on text/uri-list corresponds to the i-th dropped file + * (all major browsers preserve the order). When the count does not line up — + * or the drop did not come from the OS file manager — fall back to text/plain + * for a single absolute-looking value, and null (the caller inserts the bare + * file name) otherwise. + */ +export function droppedFilePaths(files: File[], uriList: string, plainText: string): (string | null)[] { + const paths: (string | null)[] = files.map(() => null); + + const uris = uriList + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + const fileUris = uris.filter((uri) => uri.startsWith("file://")); + if (fileUris.length === files.length) { + for (let index = 0; index < files.length; index += 1) { + paths[index] = decodeDroppedFileUri(fileUris[index]); + } + return paths; + } + + // Single-file fallback: some sources put the real path on text/plain. + // Bare basenames are useless here, so only trust absolute-looking values. + if (files.length === 1 && plainText.trim()) { + const candidate = plainText.trim(); + if (candidate.startsWith("/") || candidate.startsWith("~") || /^[A-Za-z]:[\\/]/.test(candidate)) { + paths[0] = candidate; + } + } + + return paths; +} + +/** The text inserted for a dropped file: its path when known, else its name. */ +export function droppedFileReference(file: File, path: string | null): string { + return path ?? file.name; +} From f07e924e3693dde4e8f6256b0f3978657a2c05f9 Mon Sep 17 00:00:00 2001 From: WXR Date: Sat, 1 Aug 2026 20:23:16 +0800 Subject: [PATCH 2/2] fix: align dropped-file path lookup with the full file list Images were filtered out before resolving paths, so the file count no longer matched the text/uri-list entries and mixed drops (image + file) fell back to bare file names. Resolve paths against the full file list first, then split images from path references. --- components/ChatInput.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 5575bf952..35c01d27b 100644 --- a/components/ChatInput.tsx +++ b/components/ChatInput.tsx @@ -442,14 +442,19 @@ export const ChatInput = forwardRef(function ChatInput({ }, addFiles(files: File[], dataTransfer?: DataTransfer | null) { if (isStreaming) return; - const imageFiles = files.filter((f) => f.type.startsWith("image/")); - const otherFiles = files.filter((f) => !f.type.startsWith("image/")); - if (imageFiles.length) processImageFiles(imageFiles); - if (!otherFiles.length) return; + // Resolve paths against the full file list so the index aligns with the + // text/uri-list entries (which include image files too). const uriList = dataTransfer?.getData("text/uri-list") ?? ""; const plainText = dataTransfer?.getData("text/plain") ?? ""; - const paths = droppedFilePaths(otherFiles, uriList, plainText); - const references = otherFiles.map((file, index) => droppedFileReference(file, paths[index])); + const paths = droppedFilePaths(files, uriList, plainText); + const imageFiles: File[] = []; + const references: string[] = []; + files.forEach((file, index) => { + if (file.type.startsWith("image/")) imageFiles.push(file); + else references.push(droppedFileReference(file, paths[index])); + }); + if (imageFiles.length) processImageFiles(imageFiles); + if (!references.length) return; insertTextAtCursor(references.join(" ")); }, }));