Skip to content

feat(web): attach images dragged from another browser tab#2108

Open
roni-estein wants to merge 2 commits into
pingdotgg:mainfrom
roni-estein:feat/image-url-drop
Open

feat(web): attach images dragged from another browser tab#2108
roni-estein wants to merge 2 commits into
pingdotgg:mainfrom
roni-estein:feat/image-url-drop

Conversation

@roni-estein
Copy link
Copy Markdown

@roni-estein roni-estein commented Apr 17, 2026

What Changed

Dropping an image straight from another browser tab onto the composer now works. Today, dragging an image from e.g. the ChatGPT web UI onto the composer silently does nothing — you have to drop the image into Finder first, then drop the file from Finder into T3 Code.

Why

The composer's drop handler in ChatComposer.tsx short-circuits on the very first line of every drag handler:

if (!event.dataTransfer.types.includes("Files")) return;

That gate is correct for Finder drops (which expose a real File), but wrong for cross-tab browser drags. When a browser is the drag source, it puts the image into DataTransfer as text/uri-list + text/html (an <img src="…">). There is no File — the destination has to fetch the bytes itself.

The drop zone never even highlighted, so the user couldn't tell whether the gesture was recognized.

Why not existing PRs

Neither touches the "Files" gate, and neither helps a drag that contains no File at all. The two surfaces are complementary.

How it works

Three small pure helpers in composer-logic.ts, one async helper in lib/fetchDroppedImage.ts, and a ~30-line change to the existing drop handler.

  1. isComposerAttachmentDrag(types) — accepts Files (as before) or text/uri-list. Gating on text/uri-list rather than any text type is deliberate: plain-text drags (e.g. selecting and dragging text from another input) don't include text/uri-list, so the Lexical editor's native text-drop behavior is preserved.

  2. extractDraggedImageUrls(dataTransfer) — extracts candidate URLs in priority order:

    • text/uri-list (the standard; strips # comment lines per RFC 2483)
    • text/html (<img src> parsed for the URL)
    • text/plain (last-resort fallback, only used if the two above yielded nothing)

    Only http(s):, data:image/*, and blob: URLs pass — everything else (file:, about:, random text) is dropped so the caller can safely fetch() the result.

  3. deriveImageFilenameFromUrl(url, mime) — strips query strings, takes the last path segment, falls back to dropped-image.<ext> when the URL has no usable name, and infers the extension from the MIME type when missing.

  4. fetchDroppedImageAsFile(url) — downloads the URL and returns it as a File suitable for the existing attachment pipeline. Explicitly handles every failure mode (network/CORS reject, non-2xx status, non-image MIME, body read error) by returning null so a single bad URL can't poison a batch.

  5. Drop handler update — when dataTransfer.files is empty but image-shaped URLs are present, fetch them asynchronously and feed the results through the existing addComposerImages(...) path. If every fetch fails (typically a CORS block on the source host), a single toast tells the user to fall back to the Finder round-trip that already works.

Order of preservation:

  • Finder / app drop → unchanged sync path.
  • Text-only drag → still handled by Lexical as before (no text/uri-list, handler bails).
  • Link / image drag from browser → new path.

Defensive coding notes

This PR was written with explicit attention to three patterns that have bitten me (and been caught by bots) on recent PRs:

  1. Optional fallthrough in ternary / guarded fallback chain — the text/plain fallback only fires when both text/uri-list and text/html produced zero URLs. There's a dedicated test (does not fall back to text/plain when uri-list/html already yielded URLs) that would fail if the guard regressed to something like if (uris.length === 0 || ...).
  2. Check return values before signaling successfetchDroppedImageAsFile explicitly checks response.ok (fetch resolves on 4xx/5xx; treating a 403 HTML error page as image bytes would otherwise wrap it as a "file"), explicitly checks blob.type.startsWith("image/"), and the caller gates the toast + addComposerImages on fetched.length > 0. A dedicated test covers the 403 case.
  3. DOM cleanup — not applicable here (no temporary DOM elements created), but noted in case future reviewers expect it.

Tests

apps/web/src/composer-logic.test.ts — new coverage for all three pure helpers:

  • uri-list single URL
  • uri-list with RFC 2483 # comment lines
  • <img src> extraction from html, both " and ' quoted
  • dedup across uri-list and html
  • data:image/ and blob: URL acceptance
  • rejection of file:, about:, and data:text/plain
  • text/plain last-resort fallback
  • no-fall-through guard (see above)
  • filename derivation: passthrough, query-string stripping, extension inference, generic fallback, percent-decoding, invalid URL

apps/web/src/lib/fetchDroppedImage.test.ts — all four failure modes covered:

  • happy path (returns File with derived name/MIME)
  • fetch rejects (network/CORS)
  • response.ok false (403)
  • non-image blob type
  • blob read throws

Full suite: 897/897 tests pass.

UI Changes

None when no image is dragged. New behavior only activates when dataTransfer.files is empty and image-shaped URLs are present.

Error path (image URL but fetch blocked, typically CORS on the source host):

Couldn't attach dragged image.
The source blocked a direct download. Save the image to your computer first, then drop the file.

Validation

  • bun fmt — clean
  • bun lint — zero new warnings from the changed files (pre-existing warnings unrelated)
  • bun typecheck — clean across @t3tools/scripts, @t3tools/web, @t3tools/desktop, root
  • bun run test — 897/897

Checklist


Note

Medium Risk
Adds new drag/drop behavior that triggers client-side fetch()es of dropped URLs and alters event gating, which could introduce edge-case UX regressions or unexpected network requests (though failures are handled and surfaced to users).

Overview
Enables attaching images by dragging them from another browser tab into the chat composer, not just dropping local files.

Updates composer drag handlers to recognize URL-based drags (text/uri-list), extract image URLs from DataTransfer, and asynchronously download them into Files via a new fetchDroppedImageAsFile helper; if all downloads fail (commonly due to CORS), the user gets an error toast.

Adds tested helpers in composer-logic.ts for drag-type gating, URL extraction/deduping, and deriving filenames from URLs, plus unit tests for the new fetch helper’s success and failure modes.

Reviewed by Cursor Bugbot for commit 68e330f. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Support attaching images dragged from another browser tab in the chat composer

  • Extends onComposerDrop in ChatComposer.tsx to handle URL-based drags (e.g. dragging an image from another tab) in addition to file drags.
  • Adds extractDraggedImageUrls to parse image URLs from text/uri-list, text/html <img src>, and text/plain in the drag payload, and isComposerAttachmentDrag to gate drag-over highlighting on both Files and text/uri-list drags.
  • Adds fetchDroppedImageAsFile in lib/fetchDroppedImage.ts to fetch each extracted URL and convert it to a File; non-image, failed, or non-OK responses return null.
  • If all URL fetches fail, a toast error is shown suggesting the user save the image locally first.
  • Behavioral Change: drag-over highlighting and copy dropEffect now activates for text/uri-list drags but not for plain-text drags.

Macroscope summarized 68e330f.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: bd72b9ce-6d74-4f72-aed1-518fcd25e3a4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Apr 17, 2026
Comment thread apps/web/src/composer-logic.ts Outdated
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).
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.
@roni-estein roni-estein force-pushed the feat/image-url-drop branch from 65ba5eb to 68e330f Compare April 17, 2026 05:10
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 68e330f. Configure here.

Comment thread apps/web/src/composer-logic.ts
Comment thread apps/web/src/lib/fetchDroppedImage.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented Apr 17, 2026

Approvability

Verdict: Needs human review

This PR introduces a new user-facing feature (drag-and-drop images from browser tabs) with ~400 lines of new logic for URL extraction, async fetching, and error handling. Additionally, there are two unresolved review comments identifying potential bugs in HTML entity decoding and URI error handling that could cause the feature to fail silently.

You can customize Macroscope's approvability policy. Learn more.

roni-estein added a commit to roni-estein/t3code that referenced this pull request Apr 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant