| 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 |