eleven / ocho Public

mac: fix chat file-drop — native drop path + route to the selected instance (#84)

* web: skip chat.js web file-drop handler in the native macOS wrapper

chat.js's dragover/drop listeners on #thread-view make WebKit treat the pane as a drop target and claim Finder drops — but macOS WKWebView gives the page no dropped files, and it bypasses Eleven's native DropWebView, so nothing uploads (broke wherever the newer chat.js is served, e.g. run.garden). Gate those listeners on !html.native so the native app's own drop path handles files; web + iOS are unchanged.

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

* mac: route dropped files to the selected instance (fix cross-chat uploads)

James hit files dropped into one chat being uploaded to a DIFFERENT instance. Every open instance keeps a hidden webview stacked in the same frame, so a Finder drop could be caught by the wrong one. Track the selected instance's webview (MacAppState.activeWebView, set in updateNSView) and always inject dropped/picked files + focus there — regardless of which webview's NSView caught the drop. Paired with the chat.js native-drop gate (this branch) and the earlier drop-registration gating.

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

* mac: remove dead #83 drag-event suppress (superseded by chat.js gate)

The capture-phase stopImmediatePropagation never worked (WebKit claims the drop on listener registration, not callback) and is now redundant with the chat.js native gate.

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 f2d297b5d9eef50c55aedb28508caf094d8fa23f parent 5792617
3 files changed, +42 −32
  • ios/LChatMac/ChatFeedWebView.swift +16 −18
  • ios/LChatMac/LChatMacApp.swift +6 −0
  • web/static/chat.js +20 −14
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
index 6bc3d0a..655d55c 100644
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -70,13 +70,17 @@ struct ChatFeedWebView: NSViewRepresentable {
}
func updateNSView(_ web: WKWebView, context: Context) {
- // Only the visible/selected instance accepts Finder file-drops. Every opened
- // instance keeps its webview mounted (hidden), and each is a registered drop
- // target — so a drop could otherwise land on a hidden instance's webview
- // instead of the feed you're looking at. That's what intermittently broke
- // drag-to-upload once more than one server had been opened.
- if isActive { web.registerForDraggedTypes([.fileURL]) }
- else { web.unregisterDraggedTypes() }
+ // Track the selected instance's webview so a dropped (or picked) file is always
+ // injected into the feed you're viewing — never a hidden, stacked instance's
+ // webview (which is how drops leaked to a different chat). Keeping drop
+ // registration on the selected webview only is a first line; the routing in
+ // handleDroppedFiles is the guarantee.
+ if isActive {
+ app.activeWebView = web
+ web.registerForDraggedTypes([.fileURL])
+ } else {
+ web.unregisterDraggedTypes()
+ }
}
// Runs on every page load. (1) Window drag: start a native window-drag when the
@@ -123,15 +127,6 @@ struct ChatFeedWebView: NSViewRepresentable {
input.dispatchEvent(new Event('change', { bubbles: true }));
};
- // chat.js has its own web drag-drop uploader (dragover/drop on #thread-view),
- // but macOS WKWebView doesn't hand Finder-drop files to the page, and the page's
- // dragover preventDefault stops our native DropWebView from getting the drop.
- // Swallow the page's drag events (capture phase, no preventDefault) so the native
- // drop path — DropWebView → __lchatAddFiles → the file input — handles it instead.
- ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (t) {
- document.addEventListener(t, function (e) { e.stopImmediatePropagation(); }, true);
- });
-
// Report the web feed's appearance CHOICE to native so the app chrome (rail,
// palette, title bar) matches. chat.js sets color-scheme on <html> to 'light',
// 'dark', or 'light dark' (follow OS). Crucially we report the mode, never a
@@ -181,7 +176,7 @@ struct ChatFeedWebView: NSViewRepresentable {
// type right away — used on app re-focus and after adding a file. No-op when
// no composer is present.
func focusComposer() {
- guard let web = webView else { return }
+ guard let web = app.activeWebView ?? webView else { return }
DispatchQueue.main.async {
web.window?.makeFirstResponder(web)
web.evaluateJavaScript(
@@ -193,7 +188,10 @@ struct ChatFeedWebView: NSViewRepresentable {
// Read dropped files (non-sandboxed, so plain reads work) and hand them to
// chat.js's file input. Base64 crosses the JS bridge safely.
func handleDroppedFiles(_ urls: [URL]) {
- guard let web = webView else { return }
+ // Route to the SELECTED instance's webview: whichever hidden webview's
+ // NSView caught the Finder drop, the file must land in the feed you're
+ // viewing — not a different chat.
+ guard let web = app.activeWebView ?? webView else { return }
var items: [[String: String]] = []
for url in urls {
guard let data = try? Data(contentsOf: url) else { continue }
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 219b53c..97244a0 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
+import WebKit
import UserNotifications
// A genuinely native macOS app — NOT Mac Catalyst. The window, sidebar, toolbar
@@ -90,6 +91,11 @@ final class MacAppState: ObservableObject {
// Chat ids, most-recently-selected first — drives the ⌘K palette order. The rail
// keeps its own manual (drag-reorderable) order in `chats`.
@Published var recent: [String] = []
+ // The selected instance's webview (set by ChatFeedWebView.updateNSView). NOT
+ // @Published — it's a routing handle, not UI state. A Finder drop can be caught
+ // by any instance's (hidden, stacked) webview, so we always inject the file into
+ // THIS one — the feed you're actually viewing — never a different chat.
+ weak var activeWebView: WKWebView?
// Selecting a chat records it as most-recently-accessed (for the palette).
@Published var selection: SavedChat.ID? {
diff --git a/web/static/chat.js b/web/static/chat.js
index 12ab965..2f8e52f 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -1024,20 +1024,26 @@ function renderApp() {
};
const pane = document.getElementById("thread-view");
const dropOverlay = document.getElementById("drop-overlay");
- pane.addEventListener("dragover", (e) => {
- e.preventDefault();
- if (dropOverlay) dropOverlay.classList.remove("hidden");
- });
- pane.addEventListener("dragleave", (e) => {
- // Hide only when the cursor truly leaves the pane — dragleave also fires when
- // crossing onto a child element, so check relatedTarget.
- if (dropOverlay && !pane.contains(e.relatedTarget)) dropOverlay.classList.add("hidden");
- });
- pane.addEventListener("drop", (e) => {
- e.preventDefault();
- if (dropOverlay) dropOverlay.classList.add("hidden");
- for (const f of e.dataTransfer.files || []) addAttachment(f);
- });
+ // The native macOS wrapper (Eleven) intercepts Finder file-drops itself — a
+ // WKWebView doesn't hand the page the dropped files on macOS. Merely *registering*
+ // a web drop handler here makes WebKit claim the drop, bypassing the native path,
+ // so nothing uploads. Skip the web drop listeners in the native app (html.native).
+ if (!document.documentElement.classList.contains("native")) {
+ pane.addEventListener("dragover", (e) => {
+ e.preventDefault();
+ if (dropOverlay) dropOverlay.classList.remove("hidden");
+ });
+ pane.addEventListener("dragleave", (e) => {
+ // Hide only when the cursor truly leaves the pane — dragleave also fires when
+ // crossing onto a child element, so check relatedTarget.
+ if (dropOverlay && !pane.contains(e.relatedTarget)) dropOverlay.classList.add("hidden");
+ });
+ pane.addEventListener("drop", (e) => {
+ e.preventDefault();
+ if (dropOverlay) dropOverlay.classList.add("hidden");
+ for (const f of e.dataTransfer.files || []) addAttachment(f);
+ });
+ }
text.addEventListener("paste", (e) => {
const item = [...(e.clipboardData?.items || [])].find((i) => i.type.startsWith("image/"));
if (item) {