eleven / messenger Public

web: route native macOS Finder drops to the live chat composer (#179)

* web: route native macOS Finder drops to the live chat composer

The native Mac shell (LChatMac) can't hand a WKWebView a Finder file-drop, so
it fills the page's static #file-input and dispatches `change` (see
ChatFeedWebView's __lchatAddFiles). Since the mini-apps refactor the live
composer is built in apps/chat.js with its OWN (id-less) file input and its own
stageFiles path, so that native drop was landing on the retired classic
#file-input — whose onchange runs the dead addAttachment path into a hidden
composer that can never be sent. Drops silently did nothing.

Wire #file-input's change to the live thread's staging path: buildSkeleton
exposes root._dropFiles, and render() points #file-input.onchange at the thread
on screen (re-pointed like the hotkey / native-composer dispatchers, so a chat
switch routes drops to the chat you're looking at). Web-only — no native
rebuild; it reaches the already-installed Mac app.

Also stopPropagation on the app's own web drop handler so the retired
#thread-view drop listener can't double-stage the same files in a browser.

Verified end-to-end (Playwright): simulating the exact native __lchatAddFiles
contract now stages the file in the live composer (1 chip); before, the live
composer stayed empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* web: drop the now-obsolete double-upload guard after #178

#178 deleted the classic #thread-view drop handler and #file-input wiring, so
the stopPropagation guard (against a double-stage into the dead composer) has
nothing to guard and the comments referenced deleted code. Remove it and
reword. The native-drop bridge (#file-input change -> live stageFiles) is
unchanged and still the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Paul Campbell 62df77f3e4ebd7c42f2385e9a090e8a8a71b9253 parent ecd2b5b
2 files changed, +25 −0
  • changelog/2026-07-04-fix-mac-drop-upload.md +1 −0
  • web/static/apps/chat.js +24 −0
diff --git a/changelog/2026-07-04-fix-mac-drop-upload.md b/changelog/2026-07-04-fix-mac-drop-upload.md
new file mode 100644
index 0000000..99f948d
--- /dev/null
+++ b/changelog/2026-07-04-fix-mac-drop-upload.md
@@ -0,0 +1 @@
+Fixed drag-and-drop file uploads in the Mac app again: a photo or file dropped onto a chat now lands in the message box ready to send, instead of being silently swallowed.
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js
index 8e8a633..1a31e8c 100644
--- a/web/static/apps/chat.js
+++ b/web/static/apps/chat.js
@@ -318,6 +318,15 @@ export const app = defineApp({
if (root.isConnected && root._native && host && host.registerNative) {
host.registerNative(root._native);
}
+ // Same again for the native macOS shell's Finder file-drop: it fills the
+ // page's single static #file-input and fires `change`, so point that input at
+ // the thread on screen (not the last-built one). Idempotent .onchange = so
+ // re-rendering doesn't stack listeners. (The classic composer that used to own
+ // #file-input is gone, so nothing else is listening.)
+ if (root.isConnected && root._dropFiles) {
+ const fileDropInput = document.getElementById("file-input");
+ if (fileDropInput) fileDropInput.onchange = root._dropFiles;
+ }
// Note: the notices strip is NOT cleared on re-render — a command reply owns
// its own lifecycle now (auto-dismiss countdown + ✕), and clearing here wiped
// the reply the instant its own event folded a bubble into this thread's feed
@@ -2200,6 +2209,21 @@ function buildSkeleton(el, ctx) {
}
});
}
+ // The native macOS shell (LChatMac) can't hand Finder file-drops to the page —
+ // a WKWebView doesn't deliver them and the web drop listeners above are skipped
+ // in native mode — so it fills the page's static #file-input and dispatches
+ // `change` (see ChatFeedWebView's __lchatAddFiles). The live composer uses its
+ // own input, so expose a handler that pulls the files off that static input
+ // into this thread's staging path; render() wires it to #file-input for the
+ // thread on screen. (A plain browser never reaches this — its drops land on the
+ // web listeners above.)
+ root._dropFiles = () => {
+ const el = document.getElementById("file-input");
+ if (!el) return;
+ const files = [...(el.files || [])];
+ el.value = "";
+ if (files.length) stageFiles(files);
+ };
// ---- :emoji / @mention typeahead — the original menu, verbatim ----
// composerToken (pure) detects the in-progress token at the caret; the host