From 42d36da89a4d181be48ba517644c499c06fe5f63 Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Thu, 16 Apr 2026 23:57:53 -0500 Subject: [PATCH 1/2] feat(web): attach images dragged from another browser tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user drags an image straight from another browser tab (e.g. the ChatGPT web UI) onto the composer, browsers hand the destination `text/uri-list` / `text/html` with the image URL — not a real `File`. The composer's drop handler only accepted `DataTransfer.files`, so nothing happened. This fetches the URL as an image and feeds it through the existing attachment pipeline. Finder drops still work; plain-text drags are still left to the Lexical editor. - `isComposerAttachmentDrag` / `extractDraggedImageUrls` / `deriveImageFilenameFromUrl` are pure helpers in `composer-logic.ts`. - `fetchDroppedImageAsFile` (`lib/fetchDroppedImage.ts`) downloads the URL, explicitly checks `response.ok` and the blob's image MIME type, and returns `null` on any failure so one bad URL can't poison a batch. - The drop handler preserves the existing sync path for real files and falls back to an async URL fetch only when `dataTransfer.files` is empty. A single toast is shown when image-shaped URLs were present but none could be fetched (typically CORS). Tests cover uri-list/html extraction, dedup, RFC 2483 comment lines, data:/blob: handling, MIME-based filename derivation, and every `fetchDroppedImageAsFile` failure mode (network, non-2xx, non-image, body read error). --- apps/web/src/components/chat/ChatComposer.tsx | 45 +++++- apps/web/src/composer-logic.test.ts | 148 ++++++++++++++++++ apps/web/src/composer-logic.ts | 99 ++++++++++++ apps/web/src/lib/fetchDroppedImage.test.ts | 74 +++++++++ apps/web/src/lib/fetchDroppedImage.ts | 39 +++++ 5 files changed, 399 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/lib/fetchDroppedImage.test.ts create mode 100644 apps/web/src/lib/fetchDroppedImage.ts diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f1663901ced..26f93c1c7d1 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -37,8 +37,11 @@ import { collapseExpandedComposerCursor, detectComposerTrigger, expandCollapsedComposerCursor, + extractDraggedImageUrls, + isComposerAttachmentDrag, replaceTextRange, } from "../../composer-logic"; +import { fetchDroppedImageAsFile } from "../../lib/fetchDroppedImage"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { type ComposerImageAttachment, @@ -1572,21 +1575,21 @@ export const ChatComposer = memo( }; const onComposerDragEnter = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!isComposerAttachmentDrag(event.dataTransfer.types)) return; event.preventDefault(); dragDepthRef.current += 1; setIsDragOverComposer(true); }; const onComposerDragOver = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!isComposerAttachmentDrag(event.dataTransfer.types)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; setIsDragOverComposer(true); }; const onComposerDragLeave = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!isComposerAttachmentDrag(event.dataTransfer.types)) return; event.preventDefault(); const nextTarget = event.relatedTarget; if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) return; @@ -1597,13 +1600,43 @@ export const ChatComposer = memo( }; const onComposerDrop = (event: React.DragEvent) => { - if (!event.dataTransfer.types.includes("Files")) return; + if (!isComposerAttachmentDrag(event.dataTransfer.types)) return; event.preventDefault(); dragDepthRef.current = 0; setIsDragOverComposer(false); + const files = Array.from(event.dataTransfer.files); - addComposerImages(files); - focusComposer(); + if (files.length > 0) { + addComposerImages(files); + focusComposer(); + return; + } + + // No real files — likely an image dragged from another browser tab + // (e.g. the ChatGPT web UI). Snapshot the URLs synchronously because + // `event.dataTransfer` is inert outside this handler, then fetch the + // bytes asynchronously. + const urls = extractDraggedImageUrls(event.dataTransfer); + if (urls.length === 0) return; + + void (async () => { + const results = await Promise.all(urls.map((url) => fetchDroppedImageAsFile(url))); + const fetched = results.filter((file): file is File => file !== null); + if (fetched.length > 0) { + addComposerImages(fetched); + focusComposer(); + return; + } + // We saw image-shaped URLs but couldn't turn any into a file — usually + // a CORS block on the source host. Let the user know so they know to + // fall back to the Finder round-trip that already works. + toastManager.add({ + type: "error", + title: "Couldn't attach dragged image.", + description: + "The source blocked a direct download. Save the image to your computer first, then drop the file.", + }); + })(); }; const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 1c18af54e62..812fd867903 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest"; import { clampCollapsedComposerCursor, collapseExpandedComposerCursor, + deriveImageFilenameFromUrl, detectComposerTrigger, expandCollapsedComposerCursor, + extractDraggedImageUrls, isCollapsedCursorAdjacentToInlineToken, + isComposerAttachmentDrag, parseStandaloneComposerSlashCommand, replaceTextRange, } from "./composer-logic"; @@ -288,6 +291,151 @@ describe("isCollapsedCursorAdjacentToInlineToken", () => { }); }); +describe("isComposerAttachmentDrag", () => { + it("accepts real File drags (Finder, other apps)", () => { + expect(isComposerAttachmentDrag(["Files"])).toBe(true); + }); + + it("accepts browser image/link drags that carry text/uri-list", () => { + expect(isComposerAttachmentDrag(["text/uri-list", "text/html", "text/plain"])).toBe(true); + }); + + it("ignores plain-text drags so Lexical keeps handling them", () => { + expect(isComposerAttachmentDrag(["text/plain"])).toBe(false); + expect(isComposerAttachmentDrag(["text/plain", "text/html"])).toBe(false); + }); + + it("ignores an empty type list", () => { + expect(isComposerAttachmentDrag([])).toBe(false); + }); +}); + +const makeDraggedData = (entries: Record) => ({ + types: Object.keys(entries), + getData: (type: string) => entries[type] ?? "", +}); + +describe("extractDraggedImageUrls", () => { + it("parses a single URL from text/uri-list", () => { + const data = makeDraggedData({ + "text/uri-list": "https://files.oaiusercontent.com/example.png", + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://files.oaiusercontent.com/example.png"]); + }); + + it("ignores comment lines in text/uri-list per RFC 2483", () => { + const data = makeDraggedData({ + "text/uri-list": "# dragged from ChatGPT\nhttps://cdn.example.com/a.png", + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/a.png"]); + }); + + it("falls back to in text/html when uri-list is missing", () => { + const data = makeDraggedData({ + "text/html": 'dragged', + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/b.webp"]); + }); + + it("accepts single-quoted src attributes", () => { + const data = makeDraggedData({ + "text/html": "", + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/c.gif"]); + }); + + it("deduplicates the same URL appearing in both uri-list and html", () => { + const data = makeDraggedData({ + "text/uri-list": "https://cdn.example.com/d.png", + "text/html": '', + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/d.png"]); + }); + + it("accepts data:image/ and blob: URLs", () => { + const data = makeDraggedData({ + "text/uri-list": "data:image/png;base64,AAAA", + "text/html": '', + }); + expect(extractDraggedImageUrls(data)).toEqual([ + "data:image/png;base64,AAAA", + "blob:https://example.com/abc-123", + ]); + }); + + it("rejects non-http schemes and non-image data URLs", () => { + const data = makeDraggedData({ + "text/uri-list": "file:///Users/alice/secret.txt\nabout:blank\ndata:text/plain,hello", + }); + expect(extractDraggedImageUrls(data)).toEqual([]); + }); + + it("uses text/plain as a last-resort fallback", () => { + const data = makeDraggedData({ "text/plain": "https://cdn.example.com/e.png" }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/e.png"]); + }); + + it("does not fall back to text/plain when uri-list/html already yielded URLs", () => { + // Guards against an optional-fallthrough bug: the fallback branch must + // only fire when uri-list AND html produced nothing. + const data = makeDraggedData({ + "text/uri-list": "https://cdn.example.com/f.png", + "text/plain": "totally unrelated text that happens to mention https://example.com", + }); + expect(extractDraggedImageUrls(data)).toEqual(["https://cdn.example.com/f.png"]); + }); + + it("returns an empty array when nothing is extractable", () => { + expect(extractDraggedImageUrls(makeDraggedData({ "text/plain": "just some text" }))).toEqual( + [], + ); + expect(extractDraggedImageUrls(makeDraggedData({}))).toEqual([]); + }); +}); + +describe("deriveImageFilenameFromUrl", () => { + it("preserves the original filename when the URL has one", () => { + expect(deriveImageFilenameFromUrl("https://cdn.example.com/path/photo.png", "image/png")).toBe( + "photo.png", + ); + }); + + it("strips the query string before extracting the filename", () => { + expect( + deriveImageFilenameFromUrl( + "https://cdn.example.com/path/photo.jpg?token=abc&v=2", + "image/jpeg", + ), + ).toBe("photo.jpg"); + }); + + it("adds an extension when the URL path has none", () => { + expect(deriveImageFilenameFromUrl("https://cdn.example.com/resource/12345", "image/webp")).toBe( + "12345.webp", + ); + }); + + it("falls back to a generic name when the URL has no usable path", () => { + expect(deriveImageFilenameFromUrl("https://cdn.example.com/", "image/png")).toBe( + "dropped-image.png", + ); + }); + + it("falls back to png when the MIME subtype is missing", () => { + expect(deriveImageFilenameFromUrl("https://cdn.example.com/", "")).toBe("dropped-image.png"); + }); + + it("decodes percent-encoded names", () => { + expect( + deriveImageFilenameFromUrl("https://cdn.example.com/path/my%20photo.png", "image/png"), + ).toBe("my photo.png"); + }); + + it("handles invalid URLs without throwing", () => { + expect(deriveImageFilenameFromUrl("not a url", "image/png")).toBe("dropped-image.png"); + }); +}); + describe("parseStandaloneComposerSlashCommand", () => { it("parses standalone /plan command", () => { expect(parseStandaloneComposerSlashCommand(" /plan ")).toBe("plan"); diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index a5b26b0e2de..8cc4fcedb44 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -296,3 +296,102 @@ export function replaceTextRange( const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; return { text: nextText, cursor: safeStart + replacement.length }; } + +// --------------------------------------------------------------------------- +// Drag-and-drop helpers (images dragged from other browser tabs / apps). +// +// Background: dropping an image straight from another browser tab (e.g. the +// ChatGPT web UI) into the composer does nothing today because the composer's +// drop handler only accepts `DataTransfer.files`. Browser drag sources expose +// the image via `text/uri-list` / `text/html` instead — the destination has +// to fetch the bytes itself. +// --------------------------------------------------------------------------- + +/** + * True when the dragged payload looks attachable — either a real file list + * (Finder, other apps) or a URL/image reference (another browser tab). + * + * Gating on `text/uri-list` (rather than any text/*) keeps plain-text drags + * from flipping the drop-zone highlight and from being hijacked away from + * the Lexical editor's native text-drop behavior. + */ +export function isComposerAttachmentDrag(types: ReadonlyArray): boolean { + return types.includes("Files") || types.includes("text/uri-list"); +} + +/** + * Extract candidate image URLs from a DataTransfer-like payload. Used when a + * drop carried no real `Files` (image dragged from another browser window). + * + * Preference order mirrors what browsers actually populate for image drags: + * 1. `text/uri-list` — the standard; one URL per non-comment line. + * 2. `text/html` — parse `` attributes for cases where the + * source only populated rich HTML. + * 3. `text/plain` — last-resort fallback; some sources put the URL here. + * + * Only `http(s):`, `data:image/*`, and `blob:` URLs are returned — everything + * else (chrome://, about:, file:, arbitrary text) is filtered out so the + * caller can safely `fetch()` the result. + */ +export function extractDraggedImageUrls(data: { + getData: (type: string) => string; + types: ReadonlyArray; +}): string[] { + const urls: string[] = []; + const push = (value: string | null | undefined) => { + if (!value) return; + const trimmed = value.trim(); + if (!trimmed) return; + if (!/^(?:https?:|data:image\/|blob:)/i.test(trimmed)) return; + if (!urls.includes(trimmed)) urls.push(trimmed); + }; + + if (data.types.includes("text/uri-list")) { + const raw = data.getData("text/uri-list"); + if (raw) { + for (const line of raw.split(/\r?\n/)) { + if (line.startsWith("#")) continue; + push(line); + } + } + } + if (data.types.includes("text/html")) { + const html = data.getData("text/html"); + if (html) { + // Match or src='...'; tolerate other attrs in between. + const pattern = /]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')/gi; + for (const match of html.matchAll(pattern)) { + push(match[1] ?? match[2]); + } + } + } + if (urls.length === 0 && data.types.includes("text/plain")) { + push(data.getData("text/plain")); + } + return urls; +} + +/** + * Derive a reasonable filename for a File fetched from a URL. + * + * - Strips the query string and takes the last non-empty path segment. + * - Falls back to `dropped-image.` when the URL has no usable name. + * - Infers extension from the MIME type when the derived name has none. + */ +export function deriveImageFilenameFromUrl(url: string, mimeType: string): string { + let pathname = ""; + try { + pathname = new URL(url).pathname; + } catch { + pathname = ""; + } + const last = pathname.split("/").findLast((segment) => segment.length > 0) ?? ""; + const cleaned = decodeURIComponent(last) + .replace(/[\r\n\t]/g, "") + .trim(); + const subtype = mimeType.split("/")[1]?.split(";")[0]?.trim() || "png"; + const ext = subtype || "png"; + if (cleaned && /\.[a-z0-9]{1,6}$/i.test(cleaned)) return cleaned; + if (cleaned) return `${cleaned}.${ext}`; + return `dropped-image.${ext}`; +} diff --git a/apps/web/src/lib/fetchDroppedImage.test.ts b/apps/web/src/lib/fetchDroppedImage.test.ts new file mode 100644 index 00000000000..5b9d33d2bbb --- /dev/null +++ b/apps/web/src/lib/fetchDroppedImage.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchDroppedImageAsFile } from "./fetchDroppedImage"; + +describe("fetchDroppedImageAsFile", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + const mockFetch = () => globalThis.fetch as ReturnType; + + it("returns a File when the URL resolves to image bytes", async () => { + const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }); + mockFetch().mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(blob), + } as unknown as Response); + + const result = await fetchDroppedImageAsFile( + "https://cdn.example.com/path/photo.png?token=abc", + ); + expect(result).not.toBeNull(); + expect(result?.name).toBe("photo.png"); + expect(result?.type).toBe("image/png"); + expect(result?.size).toBe(3); + }); + + it("returns null on a network / CORS failure (fetch rejects)", async () => { + mockFetch().mockRejectedValueOnce(new TypeError("Failed to fetch")); + + const result = await fetchDroppedImageAsFile("https://blocked.example.com/x.png"); + expect(result).toBeNull(); + }); + + it("returns null for non-2xx responses — never trusts body on error status", async () => { + // Pattern: response.ok must be checked. fetch() resolves on 4xx/5xx, + // and reading the body would otherwise wrap an HTML error page as a "file". + mockFetch().mockResolvedValueOnce({ + ok: false, + status: 403, + blob: () => Promise.resolve(new Blob(["forbidden"], { type: "text/html" })), + } as unknown as Response); + + const result = await fetchDroppedImageAsFile("https://cdn.example.com/forbidden.png"); + expect(result).toBeNull(); + }); + + it("returns null when the response body is not an image", async () => { + mockFetch().mockResolvedValueOnce({ + ok: true, + blob: () => Promise.resolve(new Blob([""], { type: "text/html" })), + } as unknown as Response); + + const result = await fetchDroppedImageAsFile("https://example.com/page"); + expect(result).toBeNull(); + }); + + it("returns null when reading the body throws", async () => { + mockFetch().mockResolvedValueOnce({ + ok: true, + blob: () => Promise.reject(new Error("body stream closed")), + } as unknown as Response); + + const result = await fetchDroppedImageAsFile("https://cdn.example.com/a.png"); + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/fetchDroppedImage.ts b/apps/web/src/lib/fetchDroppedImage.ts new file mode 100644 index 00000000000..ea59dd021db --- /dev/null +++ b/apps/web/src/lib/fetchDroppedImage.ts @@ -0,0 +1,39 @@ +import { deriveImageFilenameFromUrl } from "../composer-logic"; + +/** + * Fetch a URL that was dropped onto the composer and return it as a `File` + * suitable for the existing image-attachment pipeline. + * + * Returns `null` (instead of throwing) for every failure mode so the caller + * can aggregate results across a batch of URLs without a single bad URL + * poisoning the whole drop: + * + * - network / CORS failure → `null` + * - non-2xx response → `null` + * - non-image content type → `null` + * + * Note: `response.ok` is checked explicitly. `fetch` only rejects on network + * errors — 4xx/5xx resolve successfully and would otherwise silently return + * an error-page body wrapped as a "file". + */ +export async function fetchDroppedImageAsFile(url: string): Promise { + let response: Response; + try { + response = await fetch(url, { credentials: "omit" }); + } catch { + return null; + } + if (!response.ok) return null; + + let blob: Blob; + try { + blob = await response.blob(); + } catch { + return null; + } + + if (!blob.type.startsWith("image/")) return null; + + const name = deriveImageFilenameFromUrl(url, blob.type); + return new File([blob], name, { type: blob.type }); +} From 68e330f5957f29604b03b1ec26b4361faefe8418 Mon Sep 17 00:00:00 2001 From: Roni Estein Date: Fri, 17 Apr 2026 00:09:48 -0500 Subject: [PATCH 2/2] fix(web): collapse redundant png fallback in deriveImageFilenameFromUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor Bugbot caught the `|| "png"` on `const ext = subtype || "png"` being dead code: `subtype` already had the same fallback on the line above, so `ext === subtype` always. This is the same "redundant fallback that reads defensive but isn't" shape worth remembering for future reviews. Also moved the extension lookup below the "already has an extension" early return — we only need an inferred extension when we're about to append one. No behavior change. Existing tests still cover both branches. --- apps/web/src/composer-logic.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 8cc4fcedb44..91af86e2a78 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -389,9 +389,8 @@ export function deriveImageFilenameFromUrl(url: string, mimeType: string): strin const cleaned = decodeURIComponent(last) .replace(/[\r\n\t]/g, "") .trim(); - const subtype = mimeType.split("/")[1]?.split(";")[0]?.trim() || "png"; - const ext = subtype || "png"; if (cleaned && /\.[a-z0-9]{1,6}$/i.test(cleaned)) return cleaned; + const ext = mimeType.split("/")[1]?.split(";")[0]?.trim() || "png"; if (cleaned) return `${cleaned}.${ext}`; return `dropped-image.${ext}`; }