eleven / ocho Public

mac: docked thread pane — reply threads dock beside the chat (#1466)

Slice 5 of the thread-pane spec (§6 + §10.5): the Mac's reply-thread view
moves from a lifted overlay to a Slack-style docked column INSIDE the chat
surface, with its own composer. Always docked on Mac v1 (threadDocked is a
constant); the overlay code path is kept intact behind !threadDocked — it's
iOS's presentation and the escape hatch if docking ever grows a width
fallback.

The container fork: NativeFeedView's body becomes HStack { feedAndComposer;
Divider; threadPane } — the scroll view, channel composer inset and
integrity banner extract verbatim into feedAndComposer (the paneFrame
tracking rides it, so the times-gutter swipe never claims scrolls over the
pane). threadPane mounts ReplyThreadView's panel CONTENT via a new
docked: true presentation — ReplyThreadView is factored into panel
(the overlay's frosted floating card) over a shared panelContent
(header/✕, pinned root, paging note, reply rows) — 340pt fixed, leading
Divider, the theme's --panel background, a complementary a11y region (the
overlay keeps .isModal; the dock drops it). Root-paging and the
empty-thread auto-close live in ReplyThreadView and work docked unchanged.

The second composer: a second MessageComposerBar over its own state set
(paneDraft/paneFocused/paneStaged/paneEditingID/…). fixedReply marks it a
pane bar — the reply target is the thread's root, derived FRESH at send
(ChatFeedModel.threadReplyRef, the web pane's fixedReply contract) — and
allowCommands:false sends "/" text verbatim. The edit banner DOES render in
the pane (§10.5 decision: editing a thread reply from the pane is
legitimate, ✕ kept). The staging/upload pipeline gains a ComposerTarget so
both bars share it; the window drop zone decides its target AT DROP TIME
from live state — the pane bar when its field has focus, the channel bar
otherwise (it pre-empts the fields' own onStageDrop backstops, so a drop on
the pane's composer would otherwise chip the channel bar). The channel bar
stays ON the channel: openThread no longer stages the main composer to the
thread (that was the overlay model — stageThreadReply, cancelReply-closes-
thread, post-send re-stage and cancelEdit's hand-back are all gated to the
kept !threadDocked path).

Routing (§10.5): Reply/Edit on a bubble rendered inside the pane target the
pane's bar — the pane's row closure builds paneMessageActions (Reply
focuses the pane bar, its fixed reply already targets the root; Edit stages
on it), the per-surface twin of the overlay's threadMenuScope environment
split, which the pane also keeps for ⌘T. Main-feed bubbles are unchanged.
⌘R with the pane open focuses the pane bar. The bar's ↑-edits-latest moves
behind a new onEditLatest hook so each bar edits within its own scope (the
channel feed vs the open thread).

Focus + Escape: opening the pane focuses its composer; closing returns
focus to the channel bar — gated on isActive, since the empty-thread
auto-close also fires on the parked (hidden, still-mounted) spaces and must
never steal first responder from the visible one. Each bar's Escape peels
its OWN innermost layer first: the channel bar its command panel, then an
armed edit/reply (the same peel as the banner ✕, handled in the field
fallback too so it doesn't hinge on AppKit's key-equivalent-vs-keyDown
order), then a no-op as before — it never closes the pane; the pane bar an
armed edit, then a no-op while there's typed/staged content (keyboard
Escape is never destructive of a draft; deliberate close with content is
the ✕), and only an EMPTY pane composer closes the thread. Two bars can
show banners at once, so the banner ✕'s .cancelAction key equivalent is
handed to exactly one bar (ownsCancelKey): the bar whose field has focus,
the channel bar when focus is in neither — no two cancelAction buttons
race the key-equivalent walk. A11y (§10.5 shipping requirement): the bar's
icon-only controls — +, send, unstage ✕, banner ✕ — get accessibilityLabels,
covering both instances. Also seats slice 4's stranded @discardableResult
on ensureCommandHost where it was meant to land (kills the two standing
warnings).

iOS is untouched behaviourally: ReplyThreadView's docked flag defaults
false, its overlay tree/trait/gesture paths are byte-for-byte today's, and
NativeChatScreen isn't touched — the iOS build and the full LChatCore
suite (ReplyThreadVectorTests included) prove it.

No changelog entry — native holds for the release mint.


Claude-Session: https://claude.ai/code/session_01EXy6kzmYE9rChy3KgciJvZ
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev cb2984b4547b1e76153231ab35d3b47b10d27e94 parent 7132dc5
2 files changed, +531 −146
  • ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift +52 −21
  • ios/LChatMac/NativeFeedView.swift +479 −125
diff --git a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift
index d609fb7..d42fed5 100644
--- a/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift
+++ b/ios/LChatCore/Sources/LChatCore/ReplyThreadView.swift
@@ -16,6 +16,14 @@ import SwiftUI
// root (capped, with the inline loading note, falling back to the quoted
// snapshot as its header); and it closes itself when the whole thread is
// deleted underneath it.
+//
+// A SECOND presentation (the Mac's docked thread pane, thread-pane spec §6):
+// `docked: true` renders the same panel CONTENT — header/✕, pinned root,
+// paging note, reply rows — flush, with no dim, no floating card chrome and
+// no drag, for the container to mount as a right-hand column beside its feed
+// (the container supplies the column's width, divider, background and its
+// own composer). The container contract above holds unchanged either way;
+// iOS never passes the flag, so its overlay presentation is untouched.
public struct ReplyThreadView<Row: View>: View {
@ObservedObject private var model: ChatFeedModel
private let rootID: Int64
@@ -27,6 +35,9 @@ public struct ReplyThreadView<Row: View>: View {
/// iOS opts in: dragging the header down rides the panel with the finger
/// and releases into a close — the sheet gesture readers expect on touch.
private let swipeDownCloses: Bool
+ /// The docked presentation (see the header note): panel content only,
+ /// flush in the container's column — no dim, no card, no drag.
+ private let docked: Bool
private let onClose: () -> Void
/// Paging just surfaced the root — the container refreshes its staged
/// reply banner to the live snapshot (web pageInRoot's tail).
@@ -51,6 +62,7 @@ public struct ReplyThreadView<Row: View>: View {
public init(model: ChatFeedModel, rootID: Int64, fallback: ReplyRef?,
theme: ChatTheme, isGroup: Bool, swipeDownCloses: Bool = false,
+ docked: Bool = false,
onClose: @escaping () -> Void, onRootPaged: (() -> Void)? = nil,
@ViewBuilder row: @escaping (ChatFeedModel.Message, _ showSender: Bool) -> Row) {
self.model = model
@@ -59,6 +71,7 @@ public struct ReplyThreadView<Row: View>: View {
self.theme = theme
self.isGroup = isGroup
self.swipeDownCloses = swipeDownCloses
+ self.docked = docked
self.onClose = onClose
self.onRootPaged = onRootPaged
self.row = row
@@ -71,18 +84,25 @@ public struct ReplyThreadView<Row: View>: View {
let rootLoaded = items.first?.id == rootID
let replies = rootLoaded ? Array(items.dropFirst()) : items
GeometryReader { geo in
- ZStack {
- // Lighter than a sheet dim: the frosted panel reads the
- // transcript through itself, so the backdrop only needs to
- // recede, not vanish. Tapping it leaves the thread.
- Color.black.opacity(0.25)
- .contentShape(Rectangle())
- .onTapGesture(perform: onClose)
- panel(root: rootLoaded ? items.first : nil, replies: replies,
- cap: geo.size.height * 0.4)
- .padding(10)
- .offset(y: dragOffset)
- .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1)
+ if docked {
+ // Docked: the panel content fills the container's column
+ // flush — the container draws the column chrome around it.
+ panelContent(root: rootLoaded ? items.first : nil, replies: replies,
+ cap: geo.size.height * 0.4)
+ } else {
+ ZStack {
+ // Lighter than a sheet dim: the frosted panel reads the
+ // transcript through itself, so the backdrop only needs to
+ // recede, not vanish. Tapping it leaves the thread.
+ Color.black.opacity(0.25)
+ .contentShape(Rectangle())
+ .onTapGesture(perform: onClose)
+ panel(root: rootLoaded ? items.first : nil, replies: replies,
+ cap: geo.size.height * 0.4)
+ .padding(10)
+ .offset(y: dragOffset)
+ .opacity(dragOffset > 0 ? Double(1 - min(0.5, dragOffset / 400)) : 1)
+ }
}
}
// The whole thread got deleted under the open view — nothing left to
@@ -93,8 +113,25 @@ public struct ReplyThreadView<Row: View>: View {
.task(id: rootID) { await pageInRoot() }
}
+ // The overlay's floating card: the shared content wrapped in the frosted
+ // glass chrome. The docked presentation mounts panelContent directly.
private func panel(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message],
cap: CGFloat) -> some View {
+ panelContent(root: root, replies: replies, cap: cap)
+ // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) —
+ // material adapts to light/dark for free.
+ .background(.ultraThinMaterial,
+ in: RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
+ .strokeBorder(.quaternary, lineWidth: 1))
+ .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
+ .shadow(color: .black.opacity(0.25), radius: 20, y: 6)
+ }
+
+ // The panel CONTENT both presentations share: header/✕, the pinned root,
+ // the paging note, the reply rows.
+ private func panelContent(root: ChatFeedModel.Message?, replies: [ChatFeedModel.Message],
+ cap: CGFloat) -> some View {
VStack(spacing: 0) {
header(replyCount: replies.count)
Divider()
@@ -111,14 +148,6 @@ public struct ReplyThreadView<Row: View>: View {
}
repliesList(replies)
}
- // The frosted glass (web: color-mix(var(--panel)) + backdrop blur) —
- // material adapts to light/dark for free.
- .background(.ultraThinMaterial,
- in: RoundedRectangle(cornerRadius: 16, style: .continuous))
- .overlay(RoundedRectangle(cornerRadius: 16, style: .continuous)
- .strokeBorder(.quaternary, lineWidth: 1))
- .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
- .shadow(color: .black.opacity(0.25), radius: 20, y: 6)
// Inside the view every row already IS the thread — quotes/tallies off.
.environment(\.inReplyThread, true)
}
@@ -144,7 +173,9 @@ public struct ReplyThreadView<Row: View>: View {
.padding(EdgeInsets(top: 12, leading: 14, bottom: 10, trailing: 10))
.contentShape(Rectangle())
.gesture(headerDrag)
- .accessibilityAddTraits(.isModal)
+ // The overlay is modal (it lifts over a dimmed transcript); the
+ // docked pane is an ordinary sibling region, never a modal.
+ .accessibilityAddTraits(docked ? [] : .isModal)
}
// Ride the panel with a downward header drag; release past the threshold
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index add7485..828d189 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -249,6 +249,23 @@ struct NativeFeedView: View {
// threadFallback (chat.js openThread's ref argument).
@State private var threadRootID: Int64?
@State private var threadFallback: ReplyRef?
+ // Mac v1: the reply thread always DOCKS (thread-pane spec §6) — a flush
+ // right-hand pane inside the chat surface, Slack-style, with its own
+ // composer. The lifted-overlay path is kept intact below (it's iOS's
+ // presentation, and the escape hatch if docking ever grows a width
+ // fallback) but is unreachable while this stays constant true.
+ private let threadDocked = true
+ // The docked pane's OWN composer state — a second container state set,
+ // exactly what the MessageComposerBar extraction (slice 4) was for. No
+ // replyTo twin: the pane's reply target is fixed (the thread root,
+ // derived fresh at send), so nothing user-armed lives here.
+ @State private var paneDraft = ""
+ @State private var paneFocused = false
+ @State private var paneController = MacComposerController()
+ @State private var paneStaged: [Staged] = []
+ @State private var paneSendQueued = false
+ @State private var paneEditingID: Int64?
+ @State private var paneBusyText: String?
// This surface's message menus, keyed by message id, so ⌘T can pop the one
// belonging to the right bubble (issue #392). A plain reference handed down
// the Environment: the catchers register themselves as they mount, and
@@ -324,6 +341,91 @@ struct NativeFeedView: View {
}
var body: some View {
+ HStack(spacing: 0) {
+ feedAndComposer
+ // The docked thread pane (thread-pane spec §6): a flush right
+ // column INSIDE the chat surface, beside the feed — never over
+ // it. Insertion/removal animates with openThread/closeThread's
+ // withAnimation; the lifted-overlay presentation is the kept
+ // !threadDocked path inside feedAndComposer.
+ if let rootID = threadRootID, threadDocked {
+ Divider()
+ threadPane(rootID)
+ .transition(.move(edge: .trailing).combined(with: .opacity))
+ }
+ }
+ .alert("Integrity check", isPresented: .init(
+ get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
+ ) {
+ Button("OK", role: .cancel) { integrityReport = nil }
+ } message: {
+ Text(integrityReport ?? "")
+ }
+ .task(id: instance?.id) {
+ if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) }
+ }
+ .onAppear {
+ model.start() // idempotent — a parked model is already live
+ frames.isActive = isActive
+ installSwipeMonitor()
+ registerForDrops()
+ installProjector()
+ }
+ .onChange(of: isActive) { _, active in
+ frames.isActive = active
+ // Release or claim the composer with the pane: a hidden pane's
+ // stale focused=true would keep async-stealing first responder
+ // from the visible pane's field (the old @FocusState dropped
+ // itself automatically; the representable's @State cannot).
+ // With a docked thread open, the claim goes to ITS bar — the
+ // surface the reader was working in.
+ let paneOpen = threadDocked && threadRootID != nil
+ composerFocused = active && !paneOpen
+ paneFocused = active && paneOpen
+ }
+ .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
+ // One last capture at teardown, belt to the throttle's braces —
+ // captureAnchor no-ops if the row frames have already been
+ // cleared by the children's own teardown (order isn't
+ // guaranteed), in which case the throttled capture stands.
+ frames.anchorDebounce?.cancel()
+ frames.anchorDebounce = nil
+ captureAnchor()
+ if let key = instance?.origin {
+ PaneDropRouter.shared.unregister(key, token: dropToken)
+ }
+ }
+ // 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 owns the ONE drop surface for the whole window
+ // and PaneDropRouter (top of file) hands each drop to whichever
+ // registered feed belongs to the pane selected at drag time.
+ // @mentions: the roster index bubbles style "@Name" runs against
+ // (feed rows and the reply-thread view alike), and the chip-tap
+ // interception — a MentionLink URL opens the member's profile card;
+ // every real link keeps the system route.
+ .environment(\.mentionIndex, model.mentionIndex)
+ .environment(\.openURL, OpenURLAction { url in
+ guard let uid = MentionLink.uid(from: url), let onMemberProfile
+ else { return .systemAction }
+ onMemberProfile(uid)
+ return .handled
+ })
+ }
+
+ // The feed column — the scrolling transcript, the channel composer inset
+ // and the top integrity banner — split out of body so the docked thread
+ // pane can sit BESIDE it (spec §6). The paneFrame tracking stays on this
+ // column: the times-gutter swipe must never claim scrolls made over the
+ // docked pane's own lists.
+ private var feedAndComposer: some View {
ScrollViewReader { proxy in
// A ScrollView + LazyVStack, NOT a List: List's NSTableView-backed
// rows clip their bounds, which beheaded the wave animation — stack
@@ -546,7 +648,15 @@ struct NativeFeedView: View {
.onChange(of: app.replyLatestTick) { _, _ in
guard isActive, !model.dmClosed,
let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return }
- startReply(m)
+ // With the docked pane open the thread's composer IS the
+ // pane's bar — ⌘R just puts the caret there (its fixed reply
+ // already targets the root); the overlay path stages on the
+ // channel bar as before.
+ if threadDocked, threadRootID != nil {
+ paneFocused = true
+ } else {
+ startReply(m)
+ }
}
.onChange(of: app.reactLatestTick) { _, _ in
guard isActive,
@@ -671,8 +781,9 @@ struct NativeFeedView: View {
// The thread view lifts over the FEED pane only (the web's
// .chatapp-feedwrap overlay) — applied before the composer inset, so
// the real composer below stays live, staged to the thread's root.
+ // Kept as the !threadDocked path only: Mac v1 always docks (§6).
.overlay {
- if let rootID = threadRootID {
+ if let rootID = threadRootID, !threadDocked {
threadOverlay(rootID)
.environment(\.messageMenuScope, threadMenuScope)
.transition(.opacity.combined(with: .offset(y: 12)))
@@ -707,69 +818,9 @@ struct NativeFeedView: View {
}
}
}
- .alert("Integrity check", isPresented: .init(
- get: { integrityReport != nil }, set: { if !$0 { integrityReport = nil } })
- ) {
- Button("OK", role: .cancel) { integrityReport = nil }
- } message: {
- Text(integrityReport ?? "")
- }
- .task(id: instance?.id) {
- if let instance { await NativeFeedModelCache.shared.pollIntegrity(for: instance) }
- }
// Track the feed's window frame so the swipe monitor only claims
// scroll events that happen over it (read lazily — see paneFrame).
.trackingFrame(into: paneFrame)
- .onAppear {
- model.start() // idempotent — a parked model is already live
- frames.isActive = isActive
- installSwipeMonitor()
- registerForDrops()
- installProjector()
- }
- .onChange(of: isActive) { _, active in
- frames.isActive = active
- // Release or claim the composer with the pane: a hidden pane's
- // stale focused=true would keep async-stealing first responder
- // from the visible pane's field (the old @FocusState dropped
- // itself automatically; the representable's @State cannot).
- composerFocused = 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
- // One last capture at teardown, belt to the throttle's braces —
- // captureAnchor no-ops if the row frames have already been
- // cleared by the children's own teardown (order isn't
- // guaranteed), in which case the throttled capture stands.
- frames.anchorDebounce?.cancel()
- frames.anchorDebounce = nil
- captureAnchor()
- if let key = instance?.origin {
- PaneDropRouter.shared.unregister(key, token: dropToken)
- }
- }
- // 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 owns the ONE drop surface for the whole window
- // and PaneDropRouter (top of file) hands each drop to whichever
- // registered feed belongs to the pane selected at drag time.
- // @mentions: the roster index bubbles style "@Name" runs against
- // (feed rows and the reply-thread view alike), and the chip-tap
- // interception — a MentionLink URL opens the member's profile card;
- // every real link keeps the system route.
- .environment(\.mentionIndex, model.mentionIndex)
- .environment(\.openURL, OpenURLAction { url in
- guard let uid = MentionLink.uid(from: url), let onMemberProfile
- else { return .systemAction }
- onMemberProfile(uid)
- return .handled
- })
}
private func updateJumpButtonVisible(gap: CGFloat) {
@@ -784,7 +835,15 @@ struct NativeFeedView: View {
// there — same behaviour the per-feed type-list gate used to give.
private func registerForDrops() {
guard defaultCommand == nil, let key = instance?.origin else { return }
- PaneDropRouter.shared.register(key, token: dropToken) { stageDrop($0) }
+ // 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)
+ }
}
static let bottomSentinel = "bottom-sentinel"
@@ -1303,7 +1362,11 @@ struct NativeFeedView: View {
fixedReply: nil,
allowCommands: true,
placeholder: defaultCommand?.placeholder ?? "Message",
- isActive: isActive,
+ // 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).
+ isActive: isActive && !(threadDocked && threadRootID != nil),
settled: settled,
formatAsYouType: formatAsYouType,
roster: roster,
@@ -1324,7 +1387,7 @@ struct NativeFeedView: View {
onSend: sendDraft,
// The "+" and paste/drop staging are gone in a default-command
// thread (To-do) — text-only surface, matching the web and iOS.
- onPickFiles: defaultCommand == nil ? pickFiles : nil,
+ onPickFiles: defaultCommand == nil ? { pickFiles(into: .main) } : nil,
onUnstage: { s in
staged.removeAll { $0.id == s.id }
fireQueuedSend() // cancels or releases a queued send
@@ -1348,9 +1411,30 @@ struct NativeFeedView: View {
commandHost?.dismissPanel()
return true
}
+ // This bar's OWN armed layer next — exactly what its banner
+ // ✕ does. Handled here as well as by the ✕'s cancelAction so
+ // the result doesn't hinge on AppKit's key-equivalent-vs-
+ // keyDown order (whichever fires first consumes the key).
+ if editingID != nil {
+ cancelEdit()
+ return true
+ }
+ if replyTo != nil {
+ cancelReply()
+ return true
+ }
+ // Nothing armed: a no-op, as before. The channel bar never
+ // closes the docked pane — that's the pane bar's Escape, or
+ // its ✕ — so a reflex Escape here can't take the thread away.
return false
},
- startEdit: startEdit,
+ ownsCancelKey: !paneFocused,
+ onEditLatest: {
+ guard let last = model.messages.last(where: { $0.mine && !$0.action })
+ else { return false }
+ startEdit(last)
+ return true
+ },
// Paste an image (or a copied file) straight onto the draft — it
// stages as an upload, like the web composer's paste; a file DRAG
// landing on the field stages too. Absent in a default-command
@@ -1415,7 +1499,6 @@ struct NativeFeedView: View {
// moment a draft turns into a "/" — see onChange(of: draft)). Re-points
// `sink` at THIS mount every call, like the model-cache re-point pattern,
// so a revisited thread's cached host talks to the pane on screen.
- @discardableResult
// The app-host projection queries (docs/native-apps.md phase 5): the
// model asks the thread's command host to project app events it has no
// built-in projection for. Lazy — the host webview only spins up when a
@@ -1430,6 +1513,7 @@ struct NativeFeedView: View {
}
}
+ @discardableResult // the "/" pre-warm wants the side effect, not the host
private func ensureCommandHost() -> CommandHost? {
guard let instance else { return nil }
let host = commandHost ?? CommandHostCache.shared.host(
@@ -1605,10 +1689,11 @@ struct NativeFeedView: View {
if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
staged = []
- // Sending from an open thread view keeps it open and re-stages the
- // reply to its root — the new reply folds in live and the next
- // send continues the same thread, like iMessage (web parity).
- if threadRootID != nil { stageThreadReply() } else { replyTo = nil }
+ // Overlay model only: sending from an open thread view keeps it
+ // open and re-stages the reply to its root, like iMessage (web
+ // parity). Docked, the channel bar stays on the channel — the
+ // pane's own composer carries the thread (sendPaneDraft).
+ if threadRootID != nil, !threadDocked { stageThreadReply() } else { replyTo = nil }
sendQueued = false
}
}
@@ -1643,6 +1728,182 @@ struct NativeFeedView: View {
row: { m, showSender in threadBubble(m, showSender: showSender) })
}
+ // The DOCKED thread pane (spec §6): ReplyThreadView's panel content —
+ // header/✕, pinned root, paging note, reply rows — as a flush right-hand
+ // column with its OWN composer at the bottom, the web's
+ // .chatapp-thread-panel--docked. Root-paging and the empty-thread
+ // auto-close live in ReplyThreadView and ride along unchanged. No
+ // onRootPaged: there's no staged banner to refresh — the pane's reply
+ // target derives fresh at send. Its own menu scope, like the overlay, so
+ // ⌘T pops the catcher on the surface in front of the reader.
+ private func threadPane(_ rootID: Int64) -> some View {
+ VStack(spacing: 0) {
+ ReplyThreadView(model: model, rootID: rootID, fallback: threadFallback,
+ theme: theme, isGroup: isGroup, docked: true,
+ onClose: closeThread,
+ row: { m, showSender in
+ threadBubble(m, showSender: showSender, pane: true)
+ })
+ // An ended DM refuses new messages — the pane goes composer-less,
+ // like the channel bar giving way to the closed banner.
+ if !model.dmClosed {
+ Divider()
+ paneComposer
+ }
+ }
+ .frame(width: 340)
+ // The web dock's background: var(--panel), not the overlay's frosted
+ // material — a flush column sits on the window, not over the feed.
+ .background(theme.panel.resolved(scheme))
+ .environment(\.messageMenuScope, threadMenuScope)
+ // The web dock is a complementary landmark ("Reply thread"); the
+ // panel content inside carries the header/controls.
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("Reply thread")
+ }
+
+ // The pane's composer — a SECOND MessageComposerBar over its own state
+ // set. fixedReply marks it a pane bar (no reply banner, no ✕ — the
+ // target is the thread's root, derived fresh at send in sendPaneDraft);
+ // commands stay off, so "/" text sends verbatim (spec risk 3). The edit
+ // banner DOES render here (§10.5): editing a thread reply from the pane
+ // is legitimate, with its ✕ cancelling back to the plain reply state.
+ private var paneComposer: some View {
+ MessageComposerBar(
+ model: model,
+ theme: theme,
+ fixedReply: { threadRootID.map { model.threadReplyRef(root: $0) } },
+ allowCommands: false,
+ placeholder: "Reply…",
+ isActive: isActive,
+ settled: settled,
+ formatAsYouType: formatAsYouType,
+ roster: roster,
+ myUserId: myUserId,
+ hasBot: hasBot,
+ appCommands: [],
+ hostRows: [],
+ mentionAvatar: mentionAvatar,
+ draft: $paneDraft,
+ composerFocused: $paneFocused,
+ controller: paneController,
+ staged: paneStaged,
+ sendQueued: paneSendQueued,
+ replyTo: nil,
+ editingID: paneEditingID,
+ routeNotice: nil,
+ busyText: paneBusyText,
+ onSend: sendPaneDraft,
+ onPickFiles: defaultCommand == nil ? { pickFiles(into: .pane) } : nil,
+ onUnstage: { s in
+ paneStaged.removeAll { $0.id == s.id }
+ firePaneQueuedSend() // cancels or releases a queued send
+ },
+ onCancelReply: {}, // never rendered — fixedReply is non-nil
+ onCancelEdit: cancelPaneEdit,
+ onDraftChanged: { text in
+ // Same thread, same typing beats; no "/" host pre-warm — the
+ // pane runs no commands.
+ model.typingBeat(active: !text.isEmpty && paneEditingID == nil)
+ },
+ onEscapeFallback: {
+ // The pane's ladder, innermost first (after the bar's own
+ // typeahead): an armed edit cancels — the same peel as its
+ // banner ✕, handled here too so it doesn't hinge on AppKit's
+ // key-equivalent-vs-keyDown order — then typed content makes
+ // Escape a no-op (keyboard Escape is never destructive of a
+ // draft or staged files, the channel bar's rule; a deliberate
+ // close with content is the ✕), and only an EMPTY pane
+ // composer closes the thread — the overlay's contract.
+ if paneEditingID != nil {
+ cancelPaneEdit()
+ return true
+ }
+ if paneHasContent { return false }
+ closeThread()
+ return true
+ },
+ ownsCancelKey: paneFocused,
+ onEditLatest: {
+ // ↑ edits the latest own message IN THIS THREAD (root
+ // included — it's pinned right here), not the channel's.
+ guard let rootID = threadRootID,
+ let last = ChatFeedModel.threadItems(in: model.messages, root: rootID)
+ .last(where: { $0.mine && !$0.action })
+ else { return false }
+ startPaneEdit(last)
+ return true
+ },
+ onStagePaste: defaultCommand == nil ? { stagePasteboard($0, into: .pane) } : nil,
+ onStageDrop: defaultCommand == nil ? { stageDrop($0, into: .pane) } : nil,
+ panel: EmptyView()
+ )
+ }
+
+ private var paneHasContent: Bool {
+ !paneDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !paneStaged.isEmpty
+ }
+
+ private var paneUploadsInFlight: Bool {
+ paneStaged.contains { $0.attachment == nil }
+ }
+
+ private var paneWireDraft: String {
+ let wire = paneController.serializedMarkdown(escapeLiterals: !formatAsYouType)
+ return wire.isEmpty ? paneDraft : wire
+ }
+
+ // The pane's send: an ORDINARY replyTo reply — no command routing, no
+ // default-command detour (matching the web pane composer). The reply
+ // target derives FRESH here (ChatFeedModel.replyTarget semantics): the
+ // root may have paged in, or the chain's deepest snapshot improved,
+ // since the pane opened.
+ private func sendPaneDraft() {
+ // An edit replaces the message in place — same rule as the channel bar.
+ if let id = paneEditingID {
+ if model.edit(id, text: paneWireDraft) { cancelPaneEdit() }
+ return
+ }
+ guard paneHasContent, let rootID = threadRootID else { return }
+ // Mid-upload, the send queues instead of blocking (web PR #269).
+ guard !paneUploadsInFlight else {
+ paneSendQueued = true
+ return
+ }
+ // Only clear once the send reaches the socket — a down socket keeps
+ // the draft to resend, never silently drops it (the web's rule).
+ if model.send(paneWireDraft, attachments: paneStaged.compactMap(\.attachment),
+ replyTo: model.threadReplyRef(root: rootID)) {
+ paneDraft = ""
+ paneStaged = []
+ paneSendQueued = false
+ paneFocused = true // the next reply continues the thread (web parity)
+ }
+ }
+
+ private func firePaneQueuedSend() {
+ guard paneSendQueued else { return }
+ guard paneHasContent else {
+ paneSendQueued = false
+ return
+ }
+ if !paneUploadsInFlight {
+ paneSendQueued = false
+ sendPaneDraft()
+ }
+ }
+
+ private func startPaneEdit(_ m: ChatFeedModel.Message) {
+ paneEditingID = m.id
+ paneDraft = m.raw.text ?? m.text
+ paneFocused = true
+ }
+
+ private func cancelPaneEdit() {
+ paneEditingID = nil
+ paneDraft = ""
+ }
+
// The feed hands the lightbox its downsampled thumbnail so the zoom opens
// instantly; the full-fidelity decode (off-main, cached under its own size
// class) swaps in underneath once it lands — unless the lightbox has
@@ -1695,17 +1956,30 @@ struct NativeFeedView: View {
return actions
}
+ // Routing (spec §10.5): Reply/Edit on a bubble rendered INSIDE the docked
+ // pane target the PANE's bar — Reply just puts the caret there (its fixed
+ // reply already targets this thread's root), Edit stages on it. Main-feed
+ // bubbles keep the channel bar (messageActions above), unchanged.
+ private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions {
+ var actions = messageActions(m, times: false)
+ actions.onReply = { paneFocused = true }
+ actions.onEdit = { startPaneEdit(m) }
+ return actions
+ }
+
@ViewBuilder
- private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ private func threadBubble(_ m: ChatFeedModel.Message, showSender: Bool,
+ pane: Bool = false) -> some View {
// The muted fold holds inside the thread view too (#578).
if model.foldsAsMuted(m) {
MutedMessageStub { model.revealMuted(m.id) }
} else {
- threadMessageBubble(m, showSender: showSender)
+ threadMessageBubble(m, showSender: showSender, pane: pane)
}
}
- private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool) -> some View {
+ private func threadMessageBubble(_ m: ChatFeedModel.Message, showSender: Bool,
+ pane: Bool = false) -> some View {
MessageBubble(message: m, showSender: showSender,
senderName: model.senderName(m),
theme: theme,
@@ -1718,7 +1992,7 @@ struct NativeFeedView: View {
onFileTap: { att in download(att) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: videoDownload,
- actions: messageActions(m, times: false),
+ actions: pane ? paneMessageActions(m) : messageActions(m, times: false),
dragItem: { att in
guard att.isImage || att.isVideo else { return nil }
return AttachmentDragItem(filename: att.name ?? (att.isImage ? "photo.jpg" : "video.mp4"),
@@ -1737,8 +2011,20 @@ struct NativeFeedView: View {
// Freeze the header stand-in NOW (web threadFallback) — the deepest
// quoted snapshot in loaded state, in case the root never pages in.
threadFallback = model.threadReplyRef(root: rootID)
+ // Switching roots with the pane open: the old thread's half-typed
+ // draft or in-flight edit must not leak into the new one.
+ if threadDocked, threadRootID != nil { resetPaneComposer() }
withAnimation(.easeOut(duration: 0.18)) { threadRootID = rootID }
- stageThreadReply()
+ if threadDocked {
+ // The channel bar stays on the channel — no staging; the pane's
+ // composer derives its reply fresh at send. Opening puts the
+ // caret in the pane's bar (its onAppear claims focus on first
+ // mount; this write covers a root SWITCH, when the bar is
+ // already mounted — the channel field resigns via onFocusChange).
+ paneFocused = true
+ } else {
+ stageThreadReply()
+ }
}
// Stage the composer's reply on the thread's root — the view's composer
@@ -1755,9 +2041,34 @@ struct NativeFeedView: View {
guard let rootID = threadRootID else { return }
withAnimation(.easeOut(duration: 0.15)) { threadRootID = nil }
threadFallback = nil
- // Leaving the thread drops its staged reply — never an unrelated one
- // staged since (an edit clears replyTo itself, so that survives).
- if replyTo?.id == rootID { replyTo = nil }
+ if threadDocked {
+ // The pane's composer dies with the pane (the web's docked pane
+ // block is destroyed on close); focus returns to the channel bar
+ // — but only on the pane actually on screen: every visited space
+ // stays mounted hidden, and the empty-thread auto-close fires on
+ // parked views too, which must never steal first responder from
+ // the visible space (the same isActive gate every focus claim
+ // in this file wears).
+ resetPaneComposer()
+ paneFocused = false
+ if isActive { composerFocused = true }
+ } else {
+ // Leaving the overlay drops its staged reply — never an unrelated
+ // one staged since (an edit clears replyTo itself, so that
+ // survives). Docked, a reply armed on the root from the MAIN feed
+ // is the reader's own staging — leave it alone.
+ if replyTo?.id == rootID { replyTo = nil }
+ }
+ }
+
+ // ---- the docked pane's composer state (thread-pane spec §6) ------------
+
+ private func resetPaneComposer() {
+ paneDraft = ""
+ paneStaged = []
+ paneEditingID = nil
+ paneSendQueued = false
+ paneBusyText = nil
}
// ---- reply / edit staging (the composer banners) ----------------------
@@ -1769,11 +2080,13 @@ struct NativeFeedView: View {
composerFocused = true
}
- // While the thread view is open its staged reply IS the view's composer
- // state — cancelling it (the banner's ✕, or Escape via .cancelAction)
- // means leaving the thread, web parity.
+ // While the OVERLAY thread view is open its staged reply IS the view's
+ // composer state — cancelling it (the banner's ✕, or Escape via
+ // .cancelAction) means leaving the thread, web parity. Docked, the
+ // channel bar's banner is always the reader's own staging: cancel just
+ // cancels, and the pane closes through its own Escape/✕.
private func cancelReply() {
- if threadRootID != nil { closeThread() } else { replyTo = nil }
+ if threadRootID != nil, !threadDocked { closeThread() } else { replyTo = nil }
}
private func startEdit(_ m: ChatFeedModel.Message) {
@@ -1786,10 +2099,11 @@ struct NativeFeedView: View {
private func cancelEdit() {
editingID = nil
draft = ""
- // An edit inside an open thread view borrowed the composer (startEdit
- // clears replyTo); hand it back so the next send still continues the
- // thread (web cancelEdit).
- if threadRootID != nil { stageThreadReply() }
+ // Overlay model: an edit inside an open thread view borrowed the
+ // composer (startEdit clears replyTo); hand it back so the next send
+ // still continues the thread (web cancelEdit). Docked edits ride the
+ // pane's own bar (cancelPaneEdit), so nothing to hand back.
+ if threadRootID != nil, !threadDocked { stageThreadReply() }
}
// A queued send resolves whenever the staged set changes: nothing left to
@@ -1806,17 +2120,36 @@ struct NativeFeedView: View {
}
}
+ // Which composer a staged file lands on: the channel bar or the docked
+ // thread pane's. Threaded through the one staging/upload pipeline below
+ // so both bars share it — each keeps its own chip list and busy line.
+ private enum ComposerTarget { case main, pane }
+
+ private func stagedBinding(_ target: ComposerTarget) -> Binding<[Staged]> {
+ target == .main ? $staged : $paneStaged
+ }
+
+ private func setStageBusy(_ text: String?, _ target: ComposerTarget) {
+ switch target {
+ case .main: busyText = text
+ case .pane: paneBusyText = text
+ }
+ }
+
// Stage photos / allowlisted documents: read, downscale+re-encode (images),
// encrypt, upload — all native (ChatFeedModel.uploadImage/uploadFile); the
// chip flips from spinner to icon when its blob id comes back.
- private func pickFiles() {
+ private func pickFiles(into target: ComposerTarget = .main) {
let panel = NSOpenPanel()
panel.canChooseFiles = true
panel.allowsMultipleSelection = true
panel.begin { response in
guard response == .OK else { return }
- for url in panel.urls { stage(url) }
- composerFocused = true
+ for url in panel.urls { stage(url, into: target) }
+ switch target {
+ case .main: composerFocused = true
+ case .pane: paneFocused = true
+ }
}
}
@@ -1825,17 +2158,18 @@ struct NativeFeedView: View {
// paste-to-upload). Returns whether anything staged, so the field knows
// the paste was (partly) consumed. Drops ride their own pipeline
// (stageDrop) — a drag can also carry file PROMISES, which a paste never does.
- private func stagePasteboard(_ pb: NSPasteboard) -> Bool {
+ private func stagePasteboard(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool {
if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] {
let files = urls.filter(\.isFileURL)
if !files.isEmpty {
- files.forEach { stage($0) }
+ files.forEach { stage($0, into: target) }
return true
}
}
for type in [NSPasteboard.PasteboardType.png, .tiff] {
guard let data = pb.data(forType: type) else { continue }
- stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff)
+ stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff,
+ into: target)
return true
}
return false
@@ -1848,7 +2182,7 @@ struct NativeFeedView: View {
// URLs win over promises when a drag carries both, so one item never
// stages twice.
@discardableResult
- private func stageDrop(_ pb: NSPasteboard) -> Bool {
+ private func stageDrop(_ pb: NSPasteboard, into target: ComposerTarget = .main) -> Bool {
// Verification tap (types, never content) — drags arrive in wildly
// different shapes per source app; this is how the next "my drag
// doesn't work" gets diagnosed without an instrumented build.
@@ -1856,7 +2190,7 @@ struct NativeFeedView: View {
if let urls = pb.readObjects(forClasses: [NSURL.self],
options: [.urlReadingFileURLsOnly: true]) as? [URL],
!urls.isEmpty {
- urls.forEach { stage($0) }
+ urls.forEach { stage($0, into: target) }
return true
}
if let receivers = pb.readObjects(forClasses: [NSFilePromiseReceiver.self]) as? [NSFilePromiseReceiver],
@@ -1871,7 +2205,7 @@ struct NativeFeedView: View {
Logger.identity.error("drop promise failed: \(String(describing: error), privacy: .public)")
return
}
- DispatchQueue.main.async { stage(url) }
+ DispatchQueue.main.async { stage(url, into: target) }
}
}
return true
@@ -1879,7 +2213,7 @@ struct NativeFeedView: View {
for type in [NSPasteboard.PasteboardType.png, .tiff] {
guard let data = pb.data(forType: type) else { continue }
stage(data: data, name: type == .png ? "dropped.png" : "dropped.tiff",
- type: type == .png ? .png : .tiff)
+ type: type == .png ? .png : .tiff, into: target)
return true
}
return false
@@ -1893,7 +2227,7 @@ struct NativeFeedView: View {
return q
}()
- private func stage(_ url: URL) {
+ private func stage(_ url: URL, into target: ComposerTarget = .main) {
// A dropped/picked file can be tens of MB — the read happens off main
// (Data(contentsOf:) is a synchronous disk read on the staging frame,
// #465's Mac twin).
@@ -1902,21 +2236,25 @@ struct NativeFeedView: View {
operation: { try? Data(contentsOf: url) }).value
else { return }
stage(data: data, name: url.lastPathComponent,
- type: UTType(filenameExtension: url.pathExtension))
+ type: UTType(filenameExtension: url.pathExtension), into: target)
}
}
- private func stage(data: Data, name: String, type: UTType?) {
+ private func stage(data: Data, name: String, type: UTType?,
+ into target: ComposerTarget = .main) {
+ // The target bar's chip list (main or the docked pane's) — the whole
+ // pipeline below reads/writes through this one binding.
+ let staged = stagedBinding(target)
let isImage = type?.conforms(to: .image) ?? false
let isVideo = type?.conforms(to: .movie) ?? false
// Any file type stages — size is the only gate (matching the web).
if isVideo, data.count > maxVideoUploadBytes {
- busyText = "\(name): videos are capped at 50 MB"
- DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
+ setStageBusy("\(name): videos are capped at 50 MB", target)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) }
return
}
let entry = Staged(name: name, isImage: isImage)
- staged.append(entry)
+ staged.wrappedValue.append(entry)
if isImage {
// The chip's thumbnail is a full image decode + downscale — off
// main like the upload's own processing; the chip upgrades from
@@ -1925,8 +2263,8 @@ struct NativeFeedView: View {
Task {
if let thumb = await Task.detached(priority: .userInitiated,
operation: { processImage(data, maxDim: 288) }).value,
- let i = staged.firstIndex(where: { $0.id == entryID }) {
- staged[i].preview = PlatformImage(data: thumb.bytes)
+ let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) {
+ staged.wrappedValue[i].preview = PlatformImage(data: thumb.bytes)
}
}
}
@@ -1936,14 +2274,14 @@ struct NativeFeedView: View {
let entryID = entry.id
Task {
if let poster = await videoPosterImage(data: data, mimeType: type?.preferredMIMEType),
- let i = staged.firstIndex(where: { $0.id == entryID }) {
- staged[i].preview = poster
+ let i = staged.wrappedValue.firstIndex(where: { $0.id == entryID }) {
+ staged.wrappedValue[i].preview = poster
}
}
}
let onProgress: @MainActor (Double) -> Void = { p in
- if let i = staged.firstIndex(where: { $0.id == entry.id }) {
- staged[i].progress = p
+ if let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
+ staged.wrappedValue[i].progress = p
}
}
Task {
@@ -1957,14 +2295,18 @@ struct NativeFeedView: View {
att = await model.uploadFile(data, name: name, mimeType: type?.preferredMIMEType,
onProgress: onProgress)
}
- if let att, let i = staged.firstIndex(where: { $0.id == entry.id }) {
- staged[i].attachment = att
+ if let att, let i = staged.wrappedValue.firstIndex(where: { $0.id == entry.id }) {
+ staged.wrappedValue[i].attachment = att
} else {
- staged.removeAll { $0.id == entry.id }
- if att == nil { busyText = "couldn't upload \(name)" }
- DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil }
+ staged.wrappedValue.removeAll { $0.id == entry.id }
+ if att == nil { setStageBusy("couldn't upload \(name)", target) }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) { setStageBusy(nil, target) }
+ }
+ // The batch resolving may release a queued send — on ITS bar.
+ switch target {
+ case .main: fireQueuedSend()
+ case .pane: firePaneQueuedSend()
}
- fireQueuedSend() // the batch resolving may release a queued send
}
}
@@ -2155,7 +2497,17 @@ struct MessageComposerBar<Panel: View>: View {
let onCancelEdit: () -> Void
let onDraftChanged: (String) -> Void // typing beat + "/" host pre-warm
let onEscapeFallback: () -> Bool // the container's next Escape layer (command panel)
- let startEdit: (ChatFeedModel.Message) -> Void // ↑ on an empty draft
+ // Whether THIS bar's banner ✕ carries the Escape key equivalent
+ // (.cancelAction). Two bars can show banners at once (channel + docked
+ // pane), and two cancelAction buttons would race on AppKit's
+ // key-equivalent walk — so the container hands the key to exactly one:
+ // the bar whose field has focus, the channel bar when focus is in
+ // neither (today's single-bar behaviour).
+ let ownsCancelKey: Bool
+ // ↑ on an empty draft edits the latest editable message in THIS bar's
+ // SCOPE — the channel feed, or the docked pane's thread — so the
+ // container finds it and stages the edit; returns whether it did.
+ let onEditLatest: () -> Bool
let onStagePaste: ((NSPasteboard) -> Bool)?
let onStageDrop: ((NSPasteboard) -> Bool)?
// The container's command-panel slot, between the typeahead and the
@@ -2189,10 +2541,12 @@ struct MessageComposerBar<Panel: View>: View {
panel
if fixedReply == nil, let replyTo {
composerBanner("Replying to \(replyTo.name ?? "")",
- detail: replyTo.snippet) { onCancelReply() }
+ detail: replyTo.snippet,
+ cancelLabel: "Cancel reply") { onCancelReply() }
}
if editingID != nil {
- composerBanner("Editing message", detail: nil) { onCancelEdit() }
+ composerBanner("Editing message", detail: nil,
+ cancelLabel: "Cancel edit") { onCancelEdit() }
}
VStack(spacing: 0) {
// A default command's push-back ("when should I remind you?")
@@ -2362,6 +2716,7 @@ struct MessageComposerBar<Panel: View>: View {
.foregroundStyle(.white, .gray)
}
.buttonStyle(.plain)
+ .accessibilityLabel("Remove \(s.name)")
}
private var composerRow: some View {
@@ -2381,6 +2736,7 @@ struct MessageComposerBar<Panel: View>: View {
.tertiary)
}
.buttonStyle(.plain)
+ .accessibilityLabel("Attach files")
.padding(.bottom, 4)
}
// ONE pill around attachments and text, the Messages read: what's
@@ -2414,11 +2770,7 @@ struct MessageComposerBar<Panel: View>: View {
taIndex = (taIndex + taItems.count - 1) % taItems.count
return true
}
- if draft.isEmpty, editingID == nil,
- let last = model.messages.last(where: { $0.mine && !$0.action }) {
- startEdit(last)
- return true
- }
+ if draft.isEmpty, editingID == nil { return onEditLatest() }
return false
},
onMoveDown: {
@@ -2503,6 +2855,7 @@ struct MessageComposerBar<Panel: View>: View {
}
.buttonStyle(.plain)
.disabled(!hasContent)
+ .accessibilityLabel("Send")
.padding(.bottom, 4)
}
// Messages.app gives the input pill real breathing room below — the
@@ -2516,7 +2869,7 @@ struct MessageComposerBar<Panel: View>: View {
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !staged.isEmpty
}
- private func composerBanner(_ title: String, detail: String?,
+ private func composerBanner(_ title: String, detail: String?, cancelLabel: String,
onCancel: @escaping () -> Void) -> some View {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 1) {
@@ -2532,7 +2885,8 @@ struct MessageComposerBar<Panel: View>: View {
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
- .keyboardShortcut(.cancelAction)
+ .keyboardShortcut(ownsCancelKey ? .cancelAction : nil)
+ .accessibilityLabel(cancelLabel)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)