diff --git a/components/ChatInput.tsx b/components/ChatInput.tsx index 35fa00bdf..35c01d27b 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,27 @@ 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; + // 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(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(" ")); + }, })); 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; +}