eleven / ocho Public

ios+mac: a tapped notification lands on THAT message; the channel composer keeps the caret (#1489)

* ios: a tapped notification lands on THAT message, and the channel keeps the caret

The push route was thread-granular: the banner handed over a thread id and the
screen could only open the chat. The extension already knows the row (it
decrypts and files it into MessageStore at delivery), so it now stamps
userInfo["message"] beside the existing origin stamp — on the same
delivery-time-truth terms, and only for a real renderable feed row, so a
reaction push (no bubble of its own) is never stamped.

PushManager carries it as pendingMessageID beside pendingThreadOrigin,
routeNotificationTap arms NativeChatScreen.pendingJump — the seam a search hit
already uses — and jumpAndWave lands it: an ordinary message scrolls and waves
in the feed, a reply that lives only in its thread opens that thread and waves
inside it. The commonest tap of all is the chat ALREADY on screen (you were
reading it when the app went to the background), where nothing re-appears, so
the route posts .lchatJumpToMessage and the open screen consumes the parked
jump — with a second chance once the store catch-up folds the pushed row.

The paging walk now only pages BACK for a target that predates what's loaded: a
target newer than the newest row is the message the push just announced, still
landing, and history can never surface it — so it waits for the fold instead of
burning eight page-backs.

landOnUnreadThreadReply stays, strictly as the no-id fallback: a tap can still
arrive without a message (a push the extension couldn't decrypt, a banner in the
shade from before this build, an older server that sends no id), and for those
the all-or-nothing unread heuristic is still the only thing that finds a reply
hidden in its thread. It stands down the moment a real target is consumed.

Focus: coming back to the app with a docked thread pane open hands the caret to
the CHANNEL's composer — you return to a conversation, not to the reply you were
parked in. A handoff, never a claim: only if the pane's field actually held
focus, so a keyboard that was up moves over and one that was down stays down
(closeThread's rule). Deliberate moves into the pane are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)

* mac: notification taps route to the chat AND the message; the channel bar wins on activation

The Mac tap only ever selected the space — yet its local banners already carried
the thread, and the frame carries the message id, so both were there for the
taking. They're stamped now (instance/thread/message), and the tap hands all
three to the surfaces that own each step: selection, nativeOpenThread, and
nativeJump (the search hit's path — page to the message, scroll, wave, opening
its thread when the reply lives only there).

Both requests PARK until the surface that owns them can act: the jump until its
feed is the one on screen, and the chat until its space is active with threads
in hand. The palette's open-a-chat handler became consumeOpenThread(),
re-checked when a space becomes active and once its threads have loaded, so a
tap into a space this session never opened lands as well as one into the chat
already in front of you. Reactions stay unstamped — a reaction event has no
bubble of its own to land on.

consumeJump now only pages BACK for a target that predates what's loaded: a
target newer than the newest row is the message the banner just announced, still
folding in from the socket, and history can never surface it.

Focus: MessageComposerBar's isActive was doing two jobs — "may I claim first
responder at all" and "do I take the caret when the app comes forward". Split
them. The new focusOnActivate belongs to the CHANNEL's bar whenever its space is
on screen, thread pane open or not, and is false on the pane's bar: you come
back to a conversation, not to the reply you were parked in. Opening a thread,
replying to a pane bubble, a notification landing and closeThread's hand-back
all still focus exactly as they did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
🤖 Generated with [Claude Code](https://claude.com/claude-code)

* ios+mac: pageBack waits for a message that hasn't arrived yet, instead of calling it gone

The shared jump walk answers "gone" for any target the loaded window starts
below — pages are contiguous, so a message that isn't there was deleted. That
inference only holds BELOW the window. Above it, it's a flat lie, and the
notification tap is exactly the caller that hits it: the banner names a message
this model may not have folded yet (the extension filed the row to disk at
delivery; the store catch-up or the socket's re-pull brings it in a beat later),
so a tap on the freshest possible message answered "That message isn't available
any more."

A target above the newest row now WAITS for the fold — four passes of the walk's
own budget, ~2s, then the ordinary history reasoning takes over. Measured
against the newest SERVER row, because our own unconfirmed sends carry synthetic
ids above every real one (pendingIDBase) and would otherwise make every target
look like settled history.

The walk still terminates in at most jumpPageCap passes: each one returns, waits
(bounded), or pages with its own progress check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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 2a97bf2401a3b0aee0cfab273fac03d4e7308f10 parent d05ee9a
8 files changed, +230 −60
  • ios/LChat/LChatApp.swift +14 −0
  • ios/LChat/PushManager.swift +20 −1
  • ios/LChat/Views/NativeChatScreen.swift +54 −17
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +27 −0
  • ios/LChatMac/LChatMacApp.swift +23 −2
  • ios/LChatMac/NativeChatView.swift +47 −26
  • ios/LChatMac/NativeFeedView.swift +27 −9
  • ios/LChatNSE/NotificationService.swift +18 −5
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 7a6dab6..94be26c 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -591,8 +591,10 @@ struct RootView: View {
private func routeNotificationTap() {
guard let threadID = push.pendingThreadID else { return }
let stampedOrigin = push.pendingThreadOrigin
+ let stampedMessage = push.pendingMessageID
push.pendingThreadID = nil
push.pendingThreadOrigin = nil
+ push.pendingMessageID = nil
// The NSE's delivery-time origin stamp is the fast path; an unstamped
// banner falls down the ladder — parked SharedThreadKeys entry, the
// spaces' thread snapshots, the only signed-in space, and finally the
@@ -618,7 +620,19 @@ struct RootView: View {
nav.selectInstance(inst)
}
currentSpaceID = inst.id
+ // Land on the MESSAGE, not just its chat: the banner's stamped row id
+ // rides the same pending-jump seam a search hit uses, so the chat opens
+ // on it and waves it — paging history first when it isn't loaded, and
+ // opening its thread when the reply lives only there (jumpAndWave →
+ // jumpIntoThread). Armed BEFORE the push so the screen's own
+ // appear/ready pass finds it; the post covers the chat that's already
+ // on screen (nothing re-appears there).
+ let landOn = (stampedMessage ?? 0) > 0 ? stampedMessage : nil
+ if let landOn { NativeChatScreen.pendingJump = (threadID, landOn) }
if path.last != .thread(threadID) { path = [.chats, .thread(threadID)] }
+ if landOn != nil {
+ NotificationCenter.default.post(name: .lchatJumpToMessage, object: nil)
+ }
}
// A tapped "What's new" banner (the release-notes push, announce.go). The
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index 3bd9a8b..98cd3bc 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -27,6 +27,13 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
// always reads a matched pair; plain var — the id's change is the signal.
var pendingThreadOrigin: String?
+ // The NSE-stamped MESSAGE the banner was about, riding beside the origin on
+ // the same terms: a tap must land on that message, not merely open its chat.
+ // Nil when the extension couldn't decrypt, when the push predates the stamp,
+ // or for a reaction (no bubble of its own) — the route then falls back to
+ // opening the chat, as it always did.
+ var pendingMessageID: Int64?
+
// Bumped whenever a push lands while the app is open, for a thread other than
// the one on-screen. RootView watches it and re-fetches the active thread list,
// so a chat someone *just* started with us (or a message in another chat) shows
@@ -205,7 +212,9 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
// route to the right instance + chat. (UN delegate callbacks are on the
// main thread, so touching @Published / this static here is safe.)
Self.forceScrollThreadID = thread
- pendingThreadOrigin = response.notification.request.content.userInfo["origin"] as? String
+ let info = response.notification.request.content.userInfo
+ pendingThreadOrigin = info["origin"] as? String
+ pendingMessageID = (info["message"] as? NSNumber)?.int64Value
pendingThreadID = thread
} else if response.notification.request.content.userInfo["changelog"] != nil {
// "What's new" (the release-notes banner): route to the Admin app.
@@ -214,3 +223,13 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
completionHandler()
}
}
+
+extension Notification.Name {
+ /// A notification tap parked a message to land on (NativeChatScreen's
+ /// pendingJump) for a chat that may ALREADY be on screen — the common tap:
+ /// you were reading that chat when the app went to the background. Nothing
+ /// re-appears in that case, so the route posts this and the open screen
+ /// consumes the pending jump. A screen opening fresh picks the jump up
+ /// through its own appear/ready pass and ignores the post.
+ static let lchatJumpToMessage = Notification.Name("lchatJumpToMessage")
+}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index a0ba754..1b154e3 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -132,6 +132,10 @@ struct NativeChatScreen: View {
@State private var feedNote: String?
@State private var feedNoteGen = 0
@State private var revealing: Int64?
+ // A jump target was consumed for this open (search hit, media sheet, or a
+ // notification tap carrying its message id) — the guess-work fallback
+ // stands down when the tap said exactly where to land.
+ @State private var jumpTargeted = false
// Where the open thread presents (thread-pane spec §6, the iPad slice):
// in a REGULAR horizontal size class it docks — a flush right-hand pane
// beside the feed with its own composer, Slack-style; compact keeps the
@@ -521,6 +525,18 @@ struct NativeChatScreen: View {
}
}
}
+ // Coming back to the app with a thread pane open: the caret belongs
+ // to the CHANNEL's composer, not the pane's (Paul, 2026-08-29) —
+ // you return to a conversation, not to the reply you were parked
+ // in. Only ever a HANDOFF, never a claim: a keyboard that was up
+ // moves over, a keyboard that was down stays down (closeThread's
+ // rule). Deliberate moves INTO the pane — opening a thread, a
+ // pane bubble's Reply, a notification landing — are untouched.
+ .onChange(of: scenePhase) { _, phase in
+ guard phase == .active, threadDocked, threadRootID != nil, paneFocused else { return }
+ paneFocused = false
+ composerFocused = true
+ }
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
@@ -770,14 +786,18 @@ struct NativeChatScreen: View {
@State private var mountSpan: Perf.Span?
@State private var mountEnded = false
- // A search hit lands ON its message: jumpAndWave pages history back for it
- // through reveal() when it isn't loaded (the open still lands at the
- // bottom first), and says so inline when the walk can't reach it — a hit
- // deeper than the jump's reach is explained rather than silently ignored.
+ // A search hit — or a notification tap's stamped message — lands ON its
+ // message: jumpAndWave pages history back for it through reveal() when it
+ // isn't loaded (the open still lands at the bottom first), and says so
+ // inline when the walk can't reach it — a target deeper than the jump's
+ // reach is explained rather than silently ignored.
// (This used to carry its own hand-rolled walk ending in a bare `return`.)
private func runPendingJump() {
guard let j = Self.pendingJump, j.thread == thread.id, model.ready else { return }
Self.pendingJump = nil
+ // This open has a real target, so the notification tap's no-id
+ // heuristic must stand down (landOnUnreadThreadReply).
+ jumpTargeted = true
jumpAndWave(j.message)
}
@@ -877,12 +897,25 @@ struct NativeChatScreen: View {
landOnUnreadThreadReply()
}
}
+ // A notification tap for the chat that's ALREADY on screen: nothing
+ // re-appears and model.ready never flips, so the route posts and the
+ // pending jump is consumed here (the store catch-up below folds the
+ // NSE's row, and the walk waits for it).
+ .onReceive(NotificationCenter.default.publisher(for: .lchatJumpToMessage)) { _ in
+ runPendingJump()
+ }
// Foregrounding with this chat on screen: merge whatever the NSE
// filed while we were suspended straight from disk — the pushed
// message shows before the socket reconnects and re-pulls.
.onChange(of: scenePhase) { _, phase in
guard phase == .active else { return }
- Task { await model.catchUpFromStore() }
+ Task {
+ await model.catchUpFromStore()
+ // A jump armed by a tap that beat this foreground (or landed
+ // while the fold was still in flight) gets its second chance
+ // once the pushed row is actually in hand.
+ runPendingJump()
+ }
}
.onAppear {
// The mount clock starts at the OPENING appear only — modal
@@ -896,20 +929,24 @@ struct NativeChatScreen: View {
}
}
- // A notification tap for a reply that lives only in its thread: the tap
- // routes at THREAD granularity (no message id), the feed's unread anchor
- // (performInitialAnchor) searches its own rows and never sees the hidden
- // reply, and the open chat marks itself read on arrival — so the reader
- // tapped a banner and saw an unchanged transcript. When EVERY unread is
- // one of those, open the newest one's thread and land on it (jumpAndWave
- // → jumpIntoThread). The all-or-nothing gate lives in the pure
- // unreadThreadJump: with even one unread visible in the transcript the
- // feed's own anchor is already right (it lands on the OLDEST unread), and
- // opening a thread over it would cover the unreads the reader came for.
- // Same trigger as that anchor (the tap's cutoff — set only on a push
- // tap), same one-shot per open. Routing stays where it is
+ // The FALLBACK for a notification tap that couldn't say which message it
+ // was about. A tap normally carries the NSE's stamped row id and lands on
+ // it exactly (runPendingJump); a tap that doesn't — a push the extension
+ // couldn't decrypt, a banner delivered before the stamp existed, an older
+ // server that sends no id — still routes at THREAD granularity, and then
+ // the feed's unread anchor (performInitialAnchor) searches its own rows
+ // and never sees a reply hidden in its thread, while the open chat marks
+ // itself read on arrival: the reader tapped a banner and saw an unchanged
+ // transcript. When EVERY unread is one of those, open the newest one's
+ // thread and land on it (jumpAndWave → jumpIntoThread). The all-or-nothing
+ // gate lives in the pure unreadThreadJump: with even one unread visible in
+ // the transcript the feed's own anchor is already right (it lands on the
+ // OLDEST unread), and opening a thread over it would cover the unreads the
+ // reader came for. Same trigger as that anchor (the tap's cutoff — set
+ // only on a push tap), same one-shot per open. Routing stays where it is
// (NotificationRoute / LChatApp): this only reads the cutoff it left.
private func landOnUnreadThreadReply() {
+ guard !jumpTargeted else { return }
guard !unreadThreadChecked, let cutoff = unreadCutoff, model.ready else { return }
unreadThreadChecked = true
guard let id = ChatFeedModel.unreadThreadJump(in: model.messages,
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index 5f9a2ce..a9bbb92 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -1067,6 +1067,13 @@ public final class ChatFeedModel: ObservableObject {
/// deliberately in pageBack rather than discovered by burning a call.
private static let olderCooldown: UInt64 = 550_000_000
+ /// How many of a walk's passes a target ABOVE the loaded window may spend
+ /// simply WAITING to arrive (see pageBack). Four cooldowns ≈ two seconds —
+ /// generous for a row the notification extension already filed to disk,
+ /// and short enough that a genuinely unreachable target still gets its
+ /// answer inside one walk.
+ private static let arrivalWaitPasses = 4
+
/// Page history backward until message `id` is in the loaded window.
///
/// The native twin of the web's `pageBackTo`, and the fix for a jump whose
@@ -1090,10 +1097,30 @@ public final class ChatFeedModel: ObservableObject {
/// four extra seconds. The first pass skips the wait (the cooldown is
/// usually idle) and retries once if it turns out not to be, which is the
/// case where the reader had just scroll-loaded.
+ ///
+ /// One kind of target is NOT a history question: a message ABOVE the
+ /// loaded window. A notification tap knows its message id from the banner
+ /// and can land before this model has folded the row — the store catch-up
+ /// (or the socket's re-pull) brings it in a beat later. Page contiguity
+ /// only reasons about messages BELOW the window, so the `.gone` verdict
+ /// would be a flat lie there: "that message isn't available any more"
+ /// about one that is arriving as we speak. Such a target waits instead,
+ /// for a few passes of the same budget.
public func pageBack(to id: Int64) async -> JumpReach {
guard id > 0 else { return .gone }
+ var waited = 0
for pass in 0..<Self.jumpPageCap {
if messages.contains(where: { $0.id == id }) { return .found }
+ // Above every real row = still arriving; wait rather than page.
+ // Measured against the newest SERVER row: our own unconfirmed
+ // sends carry synthetic ids above all of them (pendingIDBase) and
+ // would otherwise make every target look like settled history.
+ if id > (messages.last(where: { !$0.pending })?.id ?? 0),
+ waited < Self.arrivalWaitPasses {
+ waited += 1
+ try? await Task.sleep(nanoseconds: Self.olderCooldown)
+ continue
+ }
guard let oldest = messages.first?.id else { return .gone }
if oldest < id || exhausted { return .gone }
if pass > 0 { try? await Task.sleep(nanoseconds: Self.olderCooldown) }
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 0e995b9..d0762c7 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -257,14 +257,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
completionHandler([.banner, .sound])
}
- // Tapping a notification brings Eleven forward and selects the message's instance.
+ // Tapping a notification brings Eleven forward and lands on the message it
+ // was about: its space, its chat, and the bubble itself (waved, its thread
+ // opened when the reply lives only there). The banner carries all three —
+ // notifyIfBackgrounded stamps instance/thread/message into userInfo — so
+ // this only has to hand them to the surfaces that own each step.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void) {
- let id = response.notification.request.content.userInfo["instance"] as? String
+ let info = response.notification.request.content.userInfo
+ let id = info["instance"] as? String
+ let thread = info["thread"] as? String
+ let message = (info["message"] as? NSNumber)?.int64Value
DispatchQueue.main.async {
NSApp.activate(ignoringOtherApps: true)
if let id { self.appState?.selection = id }
+ guard let thread else { return }
+ // Both requests PARK until the surface that owns them can act — the
+ // jump until its feed is the one on screen (consumeJump leaves a
+ // target that isn't this thread's alone), the chat until its space
+ // is active with threads in hand (consumeOpenThread) — so a tap into
+ // a space this session never opened lands just as well as one into
+ // the chat already in front of you. A turn after the space switch
+ // simply lets the common case land on the first pass.
+ DispatchQueue.main.async {
+ if let message, message > 0 {
+ self.appState?.nativeJump = .init(thread: thread, message: message)
+ }
+ self.appState?.nativeOpenThread = thread
+ }
}
completionHandler()
}
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift
index 4accc78..09fc06f 100644
--- a/ios/LChatMac/NativeChatView.swift
+++ b/ios/LChatMac/NativeChatView.swift
@@ -162,6 +162,10 @@ struct NativeChatView: View {
selectedID = store.threads.first(where: { !$0.hidden })?.id
}
startLive()
+ // A chat parked before this space had any threads to match it
+ // against (a notification tap into a space this session hadn't
+ // opened yet) — the list is in hand now.
+ consumeOpenThread()
// Prewarm the top chats so first taps land on already-folded
// feeds — the web's prebuild, in miniature.
NativeFeedModelCache.shared.warm(instance: instance,
@@ -212,6 +216,9 @@ struct NativeChatView: View {
dragZones.isActive = active
if active {
publishPaletteChats()
+ // A chat parked for THIS space while another was on screen (a
+ // notification tap switches space first): act on it now.
+ consumeOpenThread()
} else if app.nativeChatsOwner == instance.id {
app.nativeChats = []
app.nativeChatsOwner = nil
@@ -296,37 +303,46 @@ struct NativeChatView: View {
guard isActive, let name = note.userInfo?["app"] as? String, !name.isEmpty else { return }
app.nativeOpenThread = "app:" + name
}
- .onChange(of: app.nativeOpenThread) { _, id in
- guard isActive, let id else { return }
- // "user:"/"app:" rows have no thread yet — create (the server
- // dedupes DMs) and land in it.
- if id.hasPrefix("user:") {
- app.nativeOpenThread = nil
- let uid = String(id.dropFirst(5))
- Task {
- if let tid = await store.createChat(memberIDs: [uid], groupName: nil) {
- selectedID = tid
- }
+ .onChange(of: app.nativeOpenThread) { _, _ in consumeOpenThread() }
+ }
+
+ // A parked "open this chat" request (the ⌘K palette, App Studio's Open, a
+ // notification tap). Its own change is the usual trigger, but a request can
+ // also arrive for a space that isn't on screen — or isn't even mounted —
+ // yet: it stays parked until the space that owns it can act, so this is
+ // re-checked whenever this view becomes the active one and once its threads
+ // have loaded. Acting clears the request; a request for another space is
+ // simply left alone.
+ private func consumeOpenThread() {
+ guard isActive, let id = app.nativeOpenThread else { return }
+ // "user:"/"app:" rows have no thread yet — create (the server
+ // dedupes DMs) and land in it.
+ if id.hasPrefix("user:") {
+ app.nativeOpenThread = nil
+ let uid = String(id.dropFirst(5))
+ Task {
+ if let tid = await store.createChat(memberIDs: [uid], groupName: nil) {
+ selectedID = tid
}
- return
}
- if id.hasPrefix("app:") {
- app.nativeOpenThread = nil
- let appID = String(id.dropFirst(4))
- let title = store.registry.title(appID)
- Task {
- if let tid = await store.openOrCreateAppThread(app: appID, title: title) {
- selectedID = tid
- }
+ return
+ }
+ if id.hasPrefix("app:") {
+ app.nativeOpenThread = nil
+ let appID = String(id.dropFirst(4))
+ let title = store.registry.title(appID)
+ Task {
+ if let tid = await store.openOrCreateAppThread(app: appID, title: title) {
+ selectedID = tid
}
- return
}
- guard let thread = store.threads.first(where: { $0.id == id }) else { return }
- app.nativeOpenThread = nil
- selectedID = id
- store.markRead(thread)
- store.reopenIfHidden(thread)
+ return
}
+ guard let thread = store.threads.first(where: { $0.id == id }) else { return }
+ app.nativeOpenThread = nil
+ selectedID = id
+ store.markRead(thread)
+ store.reopenIfHidden(thread)
}
@EnvironmentObject private var app: MacAppState
@@ -1769,6 +1785,10 @@ struct NativeChatView: View {
let myUserId = store.myUserId
let instanceID = instance.id
let origin = instance.origin
+ // The row this banner is about — a tap lands ON it, not merely in the
+ // chat (the tap route's nativeJump). Never stamped for a reaction
+ // below: a reaction event has no bubble of its own to land on.
+ let messageID = frame.id ?? 0
let labels = store.senderLabels
let mutedPeople = store.mutedPeople
// The AES open runs detached — this fires for every message on the
@@ -1850,6 +1870,7 @@ struct NativeChatView: View {
}
content.sound = .default
content.userInfo = ["instance": instanceID, "thread": tid]
+ if messageID > 0 { content.userInfo["message"] = messageID }
// Sender's face on the banner where possible — same restyle the
// iOS extension applies (CommunicationNotification); a build
// without the communication entitlement just keeps the banner.
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index 4413caf..a93a6f5 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -1519,8 +1519,11 @@ struct NativeFeedView: View {
// The bar's isActive is its may-claim-first-responder gate; with
// the docked thread pane open, the caret belongs to the PANE's
// bar, so the channel bar stands down (closeThread hands focus
- // back explicitly).
+ // back explicitly). Regaining the APP is the exception: the caret
+ // comes back HERE even with a thread open, so this bar owns
+ // focusOnActivate whenever its space is the one on screen.
isActive: isActive && !(threadDocked && threadRootID != nil),
+ focusOnActivate: isActive,
settled: settled,
formatAsYouType: formatAsYouType,
roster: roster,
@@ -1932,6 +1935,11 @@ struct NativeFeedView: View {
allowCommands: false,
placeholder: "Reply…",
isActive: isActive,
+ // The pane never takes the caret on app activation — the channel
+ // bar does (see its focusOnActivate). Opening the thread, replying
+ // to a pane bubble and a notification landing all still focus here
+ // explicitly; this is only about coming back to the app.
+ focusOnActivate: false,
settled: settled,
formatAsYouType: formatAsYouType,
roster: roster,
@@ -2527,11 +2535,12 @@ struct NativeFeedView: View {
}
}
- // A pending open-on-message request (search): once this feed is the target
- // and ready, land on the message — jump() pages back for it through
- // reveal() when it isn't loaded, so a search hit that can't be reached is
- // explained inline like every other jump instead of doing nothing. (This
- // used to carry its own hand-rolled walk ending in a bare `return`.)
+ // A pending open-on-message request (search, or a notification tap's
+ // stamped message): once this feed is the target and ready, land on the
+ // message — jump() pages back for it through reveal() when it isn't
+ // loaded, so a target that can't be reached is explained inline like every
+ // other jump instead of doing nothing. (This used to carry its own
+ // hand-rolled walk ending in a bare `return`.)
private func consumeJump(_ proxy: ScrollViewProxy) {
guard let j = app.nativeJump, j.thread == threadID, model.ready else { return }
app.nativeJump = nil
@@ -2728,6 +2737,12 @@ struct MessageComposerBar<Panel: View>: View {
// off — every opened instance's pane stays mounted hidden (RootView's
// ZStack), so only the on-screen bar may claim first responder.
let isActive: Bool
+ // …and which bar takes the caret when the APP comes forward, which is not
+ // the same question: with a thread pane open the channel bar stands down
+ // from claiming on open/reveal (isActive false — the pane owns the caret
+ // there), yet regaining the app must still land in the CHANNEL's box, not
+ // the thread's (Paul, 2026-08-29). Exactly one bar per screen says true.
+ let focusOnActivate: Bool
let settled: Bool
let formatAsYouType: Bool
// Typeahead inputs (the web shell.js typeaheadItems' arguments).
@@ -3074,11 +3089,14 @@ struct MessageComposerBar<Panel: View>: View {
// same courtesy the webview shell extends (focusComposer) —
// but ONLY from the pane actually on screen (every instance's
// pane stays mounted at opacity 0, and their writes were
- // stealing focus from the visible composer), and never over
- // an open ⌘K palette (its own handler re-asserts its field).
+ // stealing focus from the visible composer), never over an
+ // open ⌘K palette (its own handler re-asserts its field), and
+ // from the CHANNEL's bar rather than an open thread's
+ // (focusOnActivate — you come back to the conversation, not
+ // to the reply you were parked in).
.onReceive(NotificationCenter.default.publisher(
for: NSApplication.didBecomeActiveNotification)) { _ in
- guard isActive, !app.showPalette else { return }
+ guard focusOnActivate, !app.showPalette else { return }
DispatchQueue.main.async {
if !app.showPalette { composerFocused = true }
}
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift
index 65ca178..b66c2a2 100644
--- a/ios/LChatNSE/NotificationService.swift
+++ b/ios/LChatNSE/NotificationService.swift
@@ -129,6 +129,22 @@ final class NotificationService: UNNotificationServiceExtension {
content.interruptionLevel = .passive
}
+ // The row this banner is about, resolved BEFORE the restyle below
+ // because two things need it: the store ingest at the bottom, and the
+ // tap's landing stamp — which has to ride the content the restyle
+ // copies from. Older servers/relays send no id (`renderable` also
+ // rules out state-only app events); both simply mean no target.
+ let rowID = (info["id"] as? NSNumber)?.int64Value ?? 0
+ let rowAt = (info["at"] as? NSNumber)?.int64Value ?? 0
+ let stored = rowID > 0 ? ChatFeedModel.renderable(blob, key: key, group: entry.group) : nil
+ // Stamp the message beside the origin, for the same delivery-time-truth
+ // reason: only here is the banner known to be about a real feed row, so
+ // the tap can land on THAT message instead of merely opening the chat
+ // (PushManager → routeNotificationTap → NativeChatScreen.pendingJump).
+ // Reaction pushes returned above and are deliberately never stamped —
+ // a reaction event has no bubble of its own to land on.
+ if stored != nil { content.userInfo["message"] = rowID }
+
// Restyle as a communication notification — the sender's photo (parked
// in SharedAvatarCache by the app) as the banner icon, the app icon
// badged on its corner. Only a real person-to-person message (named
@@ -150,10 +166,7 @@ final class NotificationService: UNNotificationServiceExtension {
// server id (older servers/relays don't — the banner still works, the
// app just fetches on open as before). Stored via the same projection
// the feed folds (renderable), so app events land as their chat lines.
- guard let id = (info["id"] as? NSNumber)?.int64Value, id > 0,
- let at = (info["at"] as? NSNumber)?.int64Value,
- let stored = ChatFeedModel.renderable(blob, key: key, group: entry.group)
- else {
+ guard let stored, rowAt > 0 else {
ship(deliver)
return
}
@@ -164,7 +177,7 @@ final class NotificationService: UNNotificationServiceExtension {
await MessageStore.shared.resume()
await MessageStore.shared.upsert(
instance: entry.origin, thread: thread,
- [.init(id: id, at: at, edited: false, reactions: [], payload: stored)])
+ [.init(id: rowID, at: rowAt, edited: false, reactions: [], payload: stored)])
// Drop the connection before shipping: this process suspends the
// moment the banner is handed over, and an app-group sqlite lock
// held across suspension is the 0xdead10cc kill — for the NSE