eleven / ocho Public

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>
Paul Campbell c63a88615ee6615af0e8da7161ee43d1f8527b20 parent dcebf1a
3 files changed, +58 −14
  • changelog/2026-07-05-mac-drag-drop-hover.md +5 −0
  • ios/LChatMac/ChatFeedWebView.swift +28 −1
  • web/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();