Skip to content
Open
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
67 changes: 44 additions & 23 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -379,6 +381,30 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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;
Expand Down Expand Up @@ -410,32 +436,27 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(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[]) => {
Expand Down
44 changes: 28 additions & 16 deletions components/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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)]"
>
<rect x="28" y="44" width="84" height="60" rx="8" fill="rgba(37,99,235,0.08)" stroke="rgba(37,99,235,0.50)" strokeWidth="1.8"/>
<path d="M36 100 L54 72 L68 88 L80 74 L104 100Z" fill="rgba(37,99,235,0.16)" stroke="rgba(37,99,235,0.40)" strokeWidth="1.4" strokeLinejoin="round"/>
<circle cx="96" cy="58" r="8" fill="rgba(37,99,235,0.22)" stroke="rgba(37,99,235,0.55)" strokeWidth="1.6"/>
<g stroke="rgba(37,99,235,0.45)" strokeWidth="1.4" strokeLinecap="round">
<line x1="96" y1="46" x2="96" y2="43"/>
<line x1="96" y1="70" x2="96" y2="73"/>
<line x1="84" y1="58" x2="81" y2="58"/>
<line x1="108" y1="58" x2="111" y2="58"/>
<line x1="87.5" y1="49.5" x2="85.4" y2="47.4"/>
<line x1="104.5" y1="66.5" x2="106.6" y2="68.6"/>
<line x1="104.5" y1="49.5" x2="106.6" y2="47.4"/>
<line x1="87.5" y1="66.5" x2="85.4" y2="68.6"/>
</g>
{dragHasImages ? (
<g>
<rect x="28" y="44" width="84" height="60" rx="8" fill="rgba(37,99,235,0.08)" stroke="rgba(37,99,235,0.50)" strokeWidth="1.8"/>
<path d="M36 100 L54 72 L68 88 L80 74 L104 100Z" fill="rgba(37,99,235,0.16)" stroke="rgba(37,99,235,0.40)" strokeWidth="1.4" strokeLinejoin="round"/>
<circle cx="96" cy="58" r="8" fill="rgba(37,99,235,0.22)" stroke="rgba(37,99,235,0.55)" strokeWidth="1.6"/>
<g stroke="rgba(37,99,235,0.45)" strokeWidth="1.4" strokeLinecap="round">
<line x1="96" y1="46" x2="96" y2="43"/>
<line x1="96" y1="70" x2="96" y2="73"/>
<line x1="84" y1="58" x2="81" y2="58"/>
<line x1="108" y1="58" x2="111" y2="58"/>
<line x1="87.5" y1="49.5" x2="85.4" y2="47.4"/>
<line x1="104.5" y1="66.5" x2="106.6" y2="68.6"/>
<line x1="104.5" y1="49.5" x2="106.6" y2="47.4"/>
<line x1="87.5" y1="66.5" x2="85.4" y2="68.6"/>
</g>
</g>
) : (
<g>
<path d="M46 30 h34 l16 16 v60 a6 6 0 0 1 -6 6 h-44 a6 6 0 0 1 -6 -6 v-70 a6 6 0 0 1 6 -6z" fill="rgba(37,99,235,0.08)" stroke="rgba(37,99,235,0.50)" strokeWidth="1.8" strokeLinejoin="round"/>
<polyline points="80 30 80 46 96 46" fill="none" stroke="rgba(37,99,235,0.50)" strokeWidth="1.8" strokeLinejoin="round"/>
<line x1="52" y1="68" x2="88" y2="68" stroke="rgba(37,99,235,0.45)" strokeWidth="1.6" strokeLinecap="round"/>
<line x1="52" y1="80" x2="88" y2="80" stroke="rgba(37,99,235,0.45)" strokeWidth="1.6" strokeLinecap="round"/>
<line x1="52" y1="92" x2="76" y2="92" stroke="rgba(37,99,235,0.45)" strokeWidth="1.6" strokeLinecap="round"/>
</g>
)}
</svg>
</div>
)}
Expand Down
1 change: 1 addition & 0 deletions hooks/useAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 11 additions & 8 deletions hooks/useDragDrop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}, []);

Expand All @@ -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 };
}
return { isDragOver, dragHasImages, handleDragEnter, handleDragOver, handleDragLeave, handleDrop };
}
54 changes: 54 additions & 0 deletions lib/dropped-files.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
63 changes: 63 additions & 0 deletions lib/dropped-files.ts
Original file line number Diff line number Diff line change
@@ -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;
}