mac,web: show the drop overlay while dragging a file into the Mac app (#194)
The native macOS shell intercepts Finder file-drops itself (a WKWebView won't surface them to the page), so the page's own dragenter/leave never fire in the app — the drop worked, but with no hover feedback. - chat.js now creates the .drop-overlay in native mode too (previously skipped) and exposes window.__lchatDropOverlay(show), selector-based so it targets the connected root's overlay (survives thread switches / instant-restore). - DropWebView gains onDragActive + draggingExited/draggingEnded and toggles the overlay on enter (file drags only) / exit / end / drop, routed to the selected instance's webview like handleDroppedFiles. Compiles (LChatMac) and node --check passes; the web listeners stay web-only so the native drop path is untouched.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3 files changed,
+58
−14
changelog/2026-07-05-mac-drag-drop-hover.md+5 −0ios/LChatMac/ChatFeedWebView.swift+28 −1web/static/apps/chat.js+25 −13
diff --git a/changelog/2026-07-05-mac-drag-drop-hover.md b/changelog/2026-07-05-mac-drag-drop-hover.md| new file mode 100644 |
| index 0000000..c8a7e0f |
| --- /dev/null |
| +++ b/changelog/2026-07-05-mac-drag-drop-hover.md |
| @@ -0,0 +1,5 @@ |
| +On the Mac app, dragging a photo or file into a chat now highlights the window |
| +with a dashed "Drop a photo or file to send" overlay — the same affordance the |
| +web app shows — so it's clear the drop will land here. Let go to attach it, then |
| +add a caption before sending. (Previously the drop worked, but with no hover |
| +feedback.) |
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift| index 331a53d..f2b8317 100644 |
| --- a/ios/LChatMac/ChatFeedWebView.swift |
| +++ b/ios/LChatMac/ChatFeedWebView.swift |
| @@ -56,6 +56,9 @@ struct ChatFeedWebView: NSViewRepresentable { |
| web.onDropFiles = { [weak coordinator = context.coordinator] urls in |
| coordinator?.handleDroppedFiles(urls) |
| } |
| + web.onDragActive = { [weak coordinator = context.coordinator] active in |
| + coordinator?.setDragOverlay(active) |
| + } |
| context.coordinator.webView = web |
| web.uiDelegate = context.coordinator |
| web.navigationDelegate = context.coordinator |
| @@ -211,6 +214,16 @@ struct ChatFeedWebView: NSViewRepresentable { |
| focusComposer() // after a drop, focus the message box so you can add a caption |
| } |
| + // Show/hide the page's .drop-overlay hover state during a Finder drag. The |
| + // native layer intercepts the drag (a WKWebView won't surface it to the |
| + // page), so the page's own dragenter/leave never fire — drive it from here. |
| + // Routed to the selected instance's webview, like handleDroppedFiles, so the |
| + // hover state shows on the chat you're viewing. |
| + func setDragOverlay(_ active: Bool) { |
| + guard let web = app.activeWebView ?? webView else { return } |
| + web.evaluateJavaScript("window.__lchatDropOverlay && window.__lchatDropOverlay(\(active))", completionHandler: nil) |
| + } |
| + |
| func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) { |
| switch message.name { |
| case "lchatDrag": moveWindow(message) |
| @@ -400,12 +413,26 @@ struct ChatFeedWebView: NSViewRepresentable { |
| // webview's own behaviour is unaffected for those. |
| final class DropWebView: WKWebView { |
| var onDropFiles: (([URL]) -> Void)? |
| + // Toggled as a file drag enters/leaves so the page can show its .drop-overlay |
| + // hover state — WebKit swallows the drag natively, so the web dragenter/leave |
| + // never fire in the app (see chat.js window.__lchatDropOverlay). |
| + var onDragActive: ((Bool) -> Void)? |
| private let fileOpts: [NSPasteboard.ReadingOptionKey: Any] = [.urlReadingFileURLsOnly: true] |
| - override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) } |
| + override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { |
| + let op = fileDrag(sender) |
| + if op == .copy { onDragActive?(true) } // show the drop overlay while a file hovers |
| + return op |
| + } |
| override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation { fileDrag(sender) } |
| + // Hide the overlay whenever the drag leaves or ends without a successful drop |
| + // (dragged back out, or cancelled) — performDragOperation covers the drop case. |
| + override func draggingExited(_ sender: NSDraggingInfo?) { onDragActive?(false) } |
| + override func draggingEnded(_ sender: NSDraggingInfo) { onDragActive?(false) } |
| + |
| override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { |
| + onDragActive?(false) // drop landed — clear the hover overlay |
| guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: fileOpts) as? [URL], |
| !urls.isEmpty else { return false } |
| onDropFiles?(urls) |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index c7ee5e7..2f2e118 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -2222,27 +2222,39 @@ function buildSkeleton(el, ctx) { |
| } |
| } |
| }); |
| - // Drag-drop with the original overlay affordance (.drop-overlay — the same |
| - // class the original pane uses, so it looks identical). The native macOS |
| - // wrapper (Eleven) intercepts Finder file-drops itself — a WKWebView doesn't |
| - // hand the page the dropped files on macOS, and merely *registering* a web |
| - // drop handler makes WebKit claim the drop, bypassing the native path, so |
| - // nothing uploads. Skip the web drop listeners in the native app (html.native). |
| + // Drag-drop overlay affordance (.drop-overlay — the same class the original |
| + // pane uses, so it looks identical). The overlay element exists in *both* modes; |
| + // only the web drop *listeners* are web-only (see below). |
| + const dropOverlay = document.createElement("div"); |
| + dropOverlay.className = "drop-overlay hidden"; |
| + dropOverlay.textContent = "Drop a photo or file to send"; |
| + root.appendChild(dropOverlay); |
| + // The native macOS shell (Eleven) drives the overlay during a Finder drag: |
| + // WebKit swallows the drag events natively (see the listener note below), so the |
| + // page's own dragenter/leave never fire in the app. Selector-based so it always |
| + // targets the connected root's overlay — survives thread switches / the |
| + // instant-restore path. (DropWebView in ChatFeedWebView.swift calls this.) |
| + window.__lchatDropOverlay = (show) => { |
| + const o = document.querySelector(".chatapp-root .drop-overlay"); |
| + if (o) o.classList.toggle("hidden", !show); |
| + }; |
| + // The native wrapper intercepts Finder file-drops itself — a WKWebView doesn't |
| + // hand the page the dropped files on macOS, and merely *registering* a web drop |
| + // handler makes WebKit claim the drop, bypassing the native path, so nothing |
| + // uploads. So skip the web drop listeners in the native app (html.native); there |
| + // the shell delivers files via __lchatAddFiles and shows the overlay via |
| + // __lchatDropOverlay above. |
| if (!document.documentElement.classList.contains("native")) { |
| - const overlay = document.createElement("div"); |
| - overlay.className = "drop-overlay hidden"; |
| - overlay.textContent = "Drop a photo or file to send"; |
| - root.appendChild(overlay); |
| let dragDepth = 0; |
| root.addEventListener("dragenter", (e) => { |
| if (!e.dataTransfer || ![...e.dataTransfer.types].includes("Files")) return; |
| dragDepth++; |
| - overlay.classList.remove("hidden"); |
| + dropOverlay.classList.remove("hidden"); |
| }); |
| root.addEventListener("dragleave", () => { |
| if (--dragDepth <= 0) { |
| dragDepth = 0; |
| - overlay.classList.add("hidden"); |
| + dropOverlay.classList.add("hidden"); |
| } |
| }); |
| root.addEventListener("dragover", (e) => { |
| @@ -2250,7 +2262,7 @@ function buildSkeleton(el, ctx) { |
| }); |
| root.addEventListener("drop", (e) => { |
| dragDepth = 0; |
| - overlay.classList.add("hidden"); |
| + dropOverlay.classList.add("hidden"); |
| const files = [...((e.dataTransfer && e.dataTransfer.files) || [])]; |
| if (files.length) { |
| e.preventDefault(); |