eleven / ocho Public

mac: a dropped file lands in the composer you dropped it on, not the focused one (#1497)

With a thread pane open, dragging an image anywhere in the window staged it
into the THREAD's composer — including a drop right on the main chat input
(James, on the Mac). The drop was routed by FOCUS, and opening a thread
claims the caret, so the pane won every drop in the window.

Focus is the wrong signal; where you dropped is the right one. There is
deliberately only ONE .onDrop region for the whole window (RootView's
catcher — every visited space stays mounted in a ZStack, and drop targeting
resolves to the topmost region regardless of opacity, so a per-pane region
lets a hidden pane steal drops: #618 → #632 → #634). So the drop's LOCATION
now rides through that one region alongside the pasteboard, and the feed
routes by containment against the docked pane's frame — the same LiveFrame
machinery the times-gutter swipe already uses to ignore scrolls made over
the pane.

One coordinate space end to end: SwiftUI `.global` (top-left origin,
measured from the window's content view), which is what LiveFrame.rect
answers in. Each drop surface converts into it exactly once at its own edge
— the AppKit catcher flips `draggingLocation` (window base coords,
bottom-left) against the content view's height, exactly as LiveFrame and the
feed's scroll/drag monitors do; the SwiftUI safety net offsets its
region-local `DropInfo.location` by the detail area's own tracked frame.
Nothing downstream converts anything.

Unchanged: the create-takeover guard, the register/unregister token dance,
hidden panes staying registered, and paste (which runs inside the focused
field and correctly follows focus). No pane open → everything goes to the
channel composer, exactly as before; while the Threads list covers the pane's
column, a drop there goes to the bar the reader can actually see. The router
now logs the point, the pane rect it was tested against, and the target it
resolved to, so the next misroute is diagnosable from `log show` alone. The
caret follows the file into the bar it landed on (what pickFiles already does
for the "+" picker), so Return still means "send this" right after a drop.

iOS/iPad needs no equivalent fix: it has no window-wide drop zone — the one
`.onDrop` lives on the composer bar itself, and the docked pane's bar carries
its own attach hooks, so a drop is already routed by the bar it landed on.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev e1c83da6b833d5260cebcb4d628a306243697e42 parent fbf6621
2 files changed, +125 −17
  • ios/LChatMac/NativeFeedView.swift +75 −11
  • ios/LChatMac/RootView.swift +50 −6
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 56c1958..7179825 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -23,19 +23,21 @@ import os
//
// The payload is the drag PASTEBOARD, not [NSItemProvider]: the AppKit
// catcher only ever has the pasteboard, and it carries every drag shape the
-// feed stages (file URLs, file promises, raw image data).
+// feed stages (file URLs, file promises, raw image data) — plus the drop's
+// LOCATION, since one window-wide region can't itself say where a drop
+// landed and the feed needs that to pick a composer (see dropTarget(at:)).
final class PaneDropRouter {
static let shared = PaneDropRouter()
private struct Entry {
let token: UUID
- let handler: (NSPasteboard) -> Void
+ let handler: (NSPasteboard, CGPoint) -> Void
}
// Keyed by the pane's instance origin — hidden panes stay registered
// (they stay mounted); selection picks between them at drop time.
private var entries: [String: Entry] = [:]
func register(_ key: String, token: UUID,
- handler: @escaping (NSPasteboard) -> Void) {
+ handler: @escaping (NSPasteboard, CGPoint) -> Void) {
Logger.identity.info("drop router: register \(key, privacy: .public)")
entries[key] = Entry(token: token, handler: handler)
}
@@ -52,15 +54,27 @@ final class PaneDropRouter {
func canDrop(over key: String?) -> Bool {
key.flatMap { entries[$0] } != nil
}
- func deliver(_ pasteboard: NSPasteboard, over key: String?) -> Bool {
+ /// `point` is where the file was dropped, in SwiftUI `.global` space
+ /// (top-left origin, measured from the window's content view) — the space
+ /// `LiveFrame.rect` answers in, so the receiving feed can test it against
+ /// its tracked frames with no further conversion. Both drop surfaces
+ /// convert into it once, at their own edge (RootView).
+ func deliver(_ pasteboard: NSPasteboard, at point: CGPoint, over key: String?) -> Bool {
guard let key, let entry = entries[key] else {
Logger.identity.error("drop router: no feed for \(key ?? "<no selection>", privacy: .public) — drop bounced")
return false
}
- Logger.identity.info("drop router: deliver to \(key, privacy: .public)")
- entry.handler(pasteboard)
+ Logger.identity.info("drop router: deliver to \(key, privacy: .public) at \(Self.pt(point), privacy: .public)")
+ entry.handler(pasteboard, point)
return true
}
+
+ // Points/rects for the log lines — rounded, so a `log show` read of a
+ // misrouted drop is legible at a glance.
+ static func pt(_ p: CGPoint) -> String { String(format: "(%.0f, %.0f)", p.x, p.y) }
+ static func rc(_ r: CGRect) -> String {
+ String(format: "(%.0f, %.0f, %.0f×%.0f)", r.minX, r.minY, r.width, r.height)
+ }
}
// One thread's natively-folded feed — the real native chat surface (mounted by
@@ -330,6 +344,13 @@ struct NativeFeedView: View {
// frame is a LiveFrame — read lazily at scroll-event time, never tracked
// with a per-frame `onChange(of: CGRect)` (window-resize churn — #899).
@State private var paneFrame = LiveFrame()
+ // The DOCKED THREAD PANE's window frame — despite the name above,
+ // paneFrame tracks the FEED column, so routing a drop needs the other
+ // half of the split. Same lazy LiveFrame treatment: read only when a
+ // drop lands (dropTarget(at:)), never observed. Its anchor dies with the
+ // pane, so a closed thread reads .zero — and an empty rect contains
+ // nothing, which is exactly "everything goes to the channel composer".
+ @State private var threadPaneFrame = LiveFrame()
@State private var swipeMonitor: Any?
// The drag monitor's twin handle: mouse input over the feed is how a text
// selection moves the offset (AppKit autoscrolls the drag), and it emits no
@@ -1003,14 +1024,54 @@ struct NativeFeedView: View {
// Registered once per mount, so the target is decided AT DROP TIME
// from live state (the closure's @State reads go through SwiftUI's
// storage, not a snapshot): the window zone pre-empts the fields'
- // own onStageDrop backstops, so a drop meant for the docked pane's
- // composer must follow the caret — the pane bar when its field has
- // focus, the channel bar otherwise.
- PaneDropRouter.shared.register(key, token: dropToken) { pb in
- stageDrop(pb, into: threadRootID != nil && paneFocused ? .pane : .main)
+ // own onStageDrop backstops, so this closure is what decides which
+ // composer a dropped file lands on.
+ PaneDropRouter.shared.register(key, token: dropToken) { pb, point in
+ let target = dropTarget(at: point)
+ guard stageDrop(pb, into: target) else { return }
+ // The caret follows the file into the bar it landed on — the same
+ // move pickFiles makes for the "+" picker, and what keeps Return
+ // meaning "send this" straight after a drop. Not while the
+ // Threads list stands, though: it OWNS the caret while it's up
+ // (openThreadList), and a column you can't Escape out of is a trap.
+ guard !showThreadList else { return }
+ switch target {
+ case .main: composerFocused = true
+ case .pane: paneFocused = true
+ }
}
}
+ // WHERE you dropped decides which composer stages the file — never which
+ // composer holds the caret. Routing by focus (this closure's first cut)
+ // meant that with a thread open EVERY drop in the window went to the
+ // pane, because opening a thread claims the caret: dropping a photo on
+ // the channel composer put it in the thread (James, Mac, 2026-08-30).
+ //
+ // COORDINATE SPACES: `point` and `threadPaneFrame.rect` are both SwiftUI
+ // `.global` — top-left origin, measured from the window's content view.
+ // The drop surface does the one AppKit flip (RootView's DropCatcherView /
+ // PaneDropDelegate); LiveFrame answers in that space by construction.
+ // Nothing is converted here, and nothing here may assume otherwise.
+ private func dropTarget(at point: CGPoint) -> ComposerTarget {
+ // No docked pane on screen → the channel composer, exactly as before.
+ // The Threads list borrows the SAME column and covers the pane
+ // opaquely, so while it stands the pane's composer can't be seen: a
+ // drop over it belongs to the bar the reader can actually look at.
+ let paneShowing = threadDocked && threadRootID != nil && !showThreadList
+ let rect = paneShowing ? threadPaneFrame.rect : .zero
+ let target: ComposerTarget = rect.contains(point) ? .pane : .main
+ // The whole diagnosis of a future "it went to the wrong box", from
+ // `log show` alone: the point, the pane it was tested against, and
+ // where it ended up.
+ Logger.identity.info("""
+ drop router: at \(PaneDropRouter.pt(point), privacy: .public) \
+ pane \(PaneDropRouter.rc(rect), privacy: .public) \
+ → \(target == .pane ? "pane" : "main", privacy: .public)
+ """)
+ return target
+ }
+
static let bottomSentinel = "bottom-sentinel"
// ---- per-thread scroll memory -------------------------------------------
@@ -2075,6 +2136,9 @@ struct NativeFeedView: View {
// panel content inside carries the header/controls.
.accessibilityElement(children: .contain)
.accessibilityLabel("Reply thread")
+ // Track this column's window frame so a drop can be routed by where
+ // it landed (dropTarget(at:)) — read lazily, like paneFrame.
+ .trackingFrame(into: threadPaneFrame)
}
// The pane's composer — a SECOND MessageComposerBar over its own state
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift
index 725271c..1a841b3 100644
--- a/ios/LChatMac/RootView.swift
+++ b/ios/LChatMac/RootView.swift
@@ -28,6 +28,13 @@ struct RootView: View {
// first responder, so .onKeyPress/.onExitCommand never fire here.
@State private var createEscMonitor: Any?
+ // The detail area's window frame, used ONLY to turn the SwiftUI
+ // safety-net's region-local drop point into the global point the router
+ // speaks (see PaneDropDelegate.performDrop). A LiveFrame, read lazily at
+ // drop time — never a tracked `onChange(of: CGRect)` (window-resize
+ // churn, #899).
+ @State private var detailFrame = LiveFrame()
+
// The goes-to-eleven create flow. The WHOLE pipeline (provision → claim →
// setup_complete) runs inside `create`, under "Building your space" — the
// instance is persisted (and vault-pushed) before success is ever shown.
@@ -130,12 +137,16 @@ struct RootView: View {
// only catch in a strip above the composer" bug. Topmost in the view
// tree, this catcher wins everywhere; clicks fall straight through it.
// No hover overlay — the drag's + badge is affordance enough.
+ //
+ // One region for the whole window means the REGION can't say where a
+ // drop landed — so the location rides along with the pasteboard and
+ // the receiving feed decides from it which composer stages the file.
.overlay {
WindowDropZone(
// The create takeover owns the window; a drop would stage
// invisibly into the pane underneath it.
canDrop: { createModel == nil && PaneDropRouter.shared.canDrop(over: selectedPaneKey(app)) },
- perform: { PaneDropRouter.shared.deliver($0, over: selectedPaneKey(app)) })
+ perform: { PaneDropRouter.shared.deliver($0, at: $1, over: selectedPaneKey(app)) })
}
}
@@ -231,7 +242,11 @@ struct RootView: View {
// path that never reaches it. Still ONE region, never per-pane: the
// keep-alive ZStack means overlapping regions, and drop targeting
// picks the topmost one regardless of opacity or allowsHitTesting.
- .onDrop(of: paneDroppableTypes, delegate: PaneDropDelegate(app: app))
+ .onDrop(of: paneDroppableTypes,
+ delegate: PaneDropDelegate(app: app, detailFrame: detailFrame))
+ // …and this net's drop point is region-LOCAL, so its own frame is
+ // what converts it to the global point the router speaks.
+ .trackingFrame(into: detailFrame)
}
}
@@ -255,15 +270,24 @@ private let paneDroppableTypes: [UTType] = {
private struct PaneDropDelegate: DropDelegate {
let app: MacAppState
+ let detailFrame: LiveFrame
// Consulted per drag session (unlike the registered type list), so the
// + badge tracks the live selection: no registered feed → the drag bounces.
func validateDrop(info: DropInfo) -> Bool {
PaneDropRouter.shared.canDrop(over: selectedPaneKey(app))
}
func performDrop(info: DropInfo) -> Bool {
+ // COORDINATE SPACES. `info.location` is in this drop region's OWN
+ // space — SwiftUI, top-left origin, measured from the detail area's
+ // corner. The router speaks SwiftUI `.global` (top-left, measured
+ // from the window's content view), the space LiveFrame.rect answers
+ // in, so the one conversion on this path is the region's own origin.
+ let origin = detailFrame.rect.origin
+ let point = CGPoint(x: info.location.x + origin.x, y: info.location.y + origin.y)
// The drag pasteboard directly (DropInfo never exposes it) — the same
// payload the catcher delivers, so the router speaks one language.
- PaneDropRouter.shared.deliver(NSPasteboard(name: .drag), over: selectedPaneKey(app))
+ return PaneDropRouter.shared.deliver(NSPasteboard(name: .drag), at: point,
+ over: selectedPaneKey(app))
}
}
@@ -275,7 +299,9 @@ private struct PaneDropDelegate: DropDelegate {
// walks registered frames, not the hitTest override).
private struct WindowDropZone: NSViewRepresentable {
var canDrop: () -> Bool
- var perform: (NSPasteboard) -> Bool
+ // The drop's location comes with it, in SwiftUI `.global` space — see
+ // DropCatcherView.globalPoint.
+ var perform: (NSPasteboard, CGPoint) -> Bool
func makeNSView(context: Context) -> DropCatcherView { DropCatcherView() }
@@ -287,7 +313,7 @@ private struct WindowDropZone: NSViewRepresentable {
private final class DropCatcherView: NSView {
var canDrop: () -> Bool = { false }
- var perform: (NSPasteboard) -> Bool = { _ in false }
+ var perform: (NSPasteboard, CGPoint) -> Bool = { _, _ in false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
@@ -306,7 +332,25 @@ private final class DropCatcherView: NSView {
return .copy
}
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
- perform(sender.draggingPasteboard)
+ perform(sender.draggingPasteboard, globalPoint(sender))
+ }
+
+ // COORDINATE SPACES — the one AppKit→SwiftUI flip on the drop path.
+ // `draggingLocation` is in the destination WINDOW's base coordinate
+ // system: bottom-left origin (the same space `NSEvent.locationInWindow`
+ // uses). Everything downstream — the router, and the LiveFrame rects the
+ // feed tests the point against — speaks SwiftUI `.global`: top-left
+ // origin, measured from the window's content view. So flip against the
+ // content view's height, exactly as LiveFrame.rect and the feed's
+ // scroll/drag monitors do, and hand on a point in that space only.
+ private func globalPoint(_ sender: NSDraggingInfo) -> CGPoint {
+ let p = sender.draggingLocation // window base coords, bottom-left origin
+ // Windowless is impossible mid-drag; if it ever happens, .zero sits
+ // in the feed column, so the drop lands on the visible channel
+ // composer rather than a pane the reader may not be looking at.
+ guard let window else { return .zero }
+ let h = window.contentView?.bounds.height ?? window.frame.height
+ return CGPoint(x: p.x, y: h - p.y)
}
}