eleven / messenger Public

mac: one drop-to-attach region routed to the active feed — per-pane onDrops misroute (#632)

The keep-alive ZStack means every visited space's feed carried its own
overlapping .onDrop, and drop targeting resolves to the topmost region
regardless of opacity/allowsHitTesting. So a hidden space's region
either swallowed the drop (staging into its own composer, invisibly) or
— after #618's type-list gate — dead-ended the drag with no + badge for
the pane actually on screen.

Replace the per-feed regions with ONE onDrop on RootView's detail area,
delivered through a shared PaneDropRouter that the active feed claims
(and releases on deactivate/disappear, owner-checked against switch
races). validateDrop consults the live claimant, so the + badge still
stays off for default-command threads and claimant-less surfaces.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a62dbaa850bb62e1f863039feee4e68ed04ecedb parent 3a5b40a
2 files changed, +78 −17
  • ios/LChatMac/NativeFeedView.swift +63 −17
  • ios/LChatMac/RootView.swift +15 −0
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 8b43ba7..b6b5227 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -2,6 +2,41 @@ import LChatCore
import SwiftUI
import AVFoundation
import os
+
+// Routes drop-to-attach from RootView's ONE drop region to the active feed.
+// Per-pane .onDrop regions can't coexist with the keep-alive ZStack: drop
+// targeting resolves to the topmost region regardless of opacity or
+// allowsHitTesting, so a hidden space's region either swallowed the drop
+// (staging into its own composer, invisibly) or — type-gated — dead-ended
+// the drag with no + badge for the pane actually on screen. One region,
+// one dynamic owner, no overlap. Main-thread only (claims come from view
+// lifecycle, delivery from the drop delegate).
+final class PaneDropRouter {
+ static let shared = PaneDropRouter()
+ private var owner: UUID?
+ private var handler: (([NSItemProvider]) -> Void)?
+ // Drives the drag's + badge via validateDrop: no active claimant (locked
+ // pane, default-command thread, first-run) → the detail area rejects the
+ // drag outright, matching the old empty-type-list behaviour.
+ var canDrop: Bool { handler != nil }
+ func claim(_ token: UUID, handler: @escaping ([NSItemProvider]) -> Void) {
+ owner = token
+ self.handler = handler
+ }
+ // Owner-checked so pane-switch ordering can't evict the newcomer: the
+ // outgoing feed's release may land after the incoming feed's claim.
+ func release(_ token: UUID) {
+ guard owner == token else { return }
+ owner = nil
+ handler = nil
+ }
+ func deliver(_ providers: [NSItemProvider]) -> Bool {
+ guard let handler else { return false }
+ handler(providers)
+ return true
+ }
+}
+
// One thread's natively-folded feed — the real native chat surface (mounted by
// the takeover's NativeChatView and the Debug diff pane alike), rendered with
// the shared MessageBubble leaf views.
@@ -149,6 +184,10 @@ struct NativeFeedView: View {
// horizontal scroll events (vertical ones pass through untouched).
@State private var paneFrame: CGRect = .zero
@State private var swipeMonitor: Any?
+ // Identifies this feed's claim on the shared drop router, so a stale
+ // release (pane switch races the newcomer's claim) can't evict the
+ // rightful owner.
+ @State private var dropToken = UUID()
// True while a horizontal swipe is actively dragging the gutter — the
// zero-delta release event snaps only when a drag was in flight.
@State private var draggingTimes = false
@@ -416,30 +455,26 @@ struct NativeFeedView: View {
model.start() // idempotent — a parked model is already live
frames.isActive = isActive
installSwipeMonitor()
+ syncDropClaim()
+ }
+ .onChange(of: isActive) { _, active in
+ frames.isActive = active
+ syncDropClaim()
}
- .onChange(of: isActive) { _, active in frames.isActive = active }
.onDisappear {
// Deliberately NOT model.stop(): parked models keep their socket
// (the FeedWebCache rule); the cache's eviction stops them.
if let swipeMonitor { NSEvent.removeMonitor(swipeMonitor) }
swipeMonitor = nil
+ PaneDropRouter.shared.release(dropToken)
}
- // Drop-to-attach: anything draggable stages exactly like the + button's
- // picks — Finder files (fileURL), app-vended images (CleanShot, Photos:
- // raw image data and/or a FILE PROMISE, which is why a fileURL-only
- // accept list bounced them), movies. No overlay chrome: the system's
- // own + drag badge is the affordance (Paul's call — idiomatic macOS).
- // In a default-command thread (text-only surface) the type list is
- // empty, so the feed is not a drop target at all. Same for a HIDDEN
- // pane: RootView keeps every visited space alive in its ZStack, and
- // AppKit's drag targeting ignores opacity/allowsHitTesting — without
- // this gate the hidden space claims the drop and stages the file
- // invisibly in ITS composer (the swipe monitor learned this first).
- .onDrop(of: (isActive && defaultCommand == nil) ? Self.droppableTypes : [], isTargeted: nil) { providers in
- guard isActive else { return false }
- stageProviders(providers)
- return true
- }
+ // Drop-to-attach is NOT a per-feed .onDrop: every visited space stays
+ // alive in RootView's ZStack, and drop-region resolution picks the
+ // topmost region regardless of opacity/allowsHitTesting — a hidden
+ // pane's region either stole the drop (staging into the wrong space's
+ // composer) or, gated, dead-ended the drag with no + badge at all.
+ // Instead RootView holds the ONE drop region for the whole detail
+ // area and the active feed claims the router (below) to receive it.
}
// What the feed accepts on a drop: real files, promised files (how most
@@ -450,6 +485,17 @@ struct NativeFeedView: View {
return types
}()
+ // The active feed owns drops for the whole detail pane; a default-command
+ // thread (text-only surface) claims nothing, so the drag shows no + badge
+ // there — same behaviour the per-feed type-list gate used to give.
+ private func syncDropClaim() {
+ if isActive && defaultCommand == nil {
+ PaneDropRouter.shared.claim(dropToken) { stageProviders($0) }
+ } else {
+ PaneDropRouter.shared.release(dropToken)
+ }
+ }
+
static let bottomSentinel = "bottom-sentinel"
// A COLD feed stays invisible until the pin is verified flush — the reader
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index a15f54a..2d098e2 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -190,6 +190,21 @@ struct RootView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { if let s = app.selection { activated.insert(s) } }
.onChange(of: app.selection) { _, s in if let s { activated.insert(s) } }
+ // THE drop-to-attach region — one for the whole detail area, delivered
+ // to whichever feed currently claims the router. Never per-pane: the
+ // keep-alive ZStack means overlapping regions, and drop targeting picks
+ // the topmost one regardless of opacity/allowsHitTesting, so a hidden
+ // space's region hijacks or dead-ends drops meant for the visible one.
+ .onDrop(of: NativeFeedView.droppableTypes, delegate: PaneDropDelegate())
+ }
+}
+
+// validateDrop is consulted per drag session (unlike the registered type list),
+// so the + badge tracks the live claimant: no active feed → the drag bounces.
+private struct PaneDropDelegate: DropDelegate {
+ func validateDrop(info: DropInfo) -> Bool { PaneDropRouter.shared.canDrop }
+ func performDrop(info: DropInfo) -> Bool {
+ PaneDropRouter.shared.deliver(info.itemProviders(for: NativeFeedView.droppableTypes))
}
}