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