mac: "Reply in thread" — the verb, web parity (#1467)
Slice 6 of the thread-pane spec (§3.5): every Mac message-action surface gains "Reply in Thread…" right after Reply, same gating — the right-click NSMenu (MessageRightClick.buildMenu), the shared SwiftUI context menu (MessageBubble.menuItems), and Message ▸ Reply in Thread to Latest Message (⇧⌘R, ⌘R's twin: same tick shape, same gate, same hotkeyMessages read). MessageActions grows onReplyInThread (nil by default — iOS never sets it, so its menus are unchanged). The verb resolves the message's thread ROOT via ChatFeedModel.replyTarget (replying to a reply joins its existing thread, never forks one) and opens the docked pane on it with the caret in the pane's bar; already open on that root, it just refocuses the bar. The pane's own bubbles omit it: in there it would do exactly what the pane's Reply does (focus the bar), and two rows for one act is noise in a dense NSMenu — a deliberate divergence from the web, which keeps it as a harmless refocus. Plain titled menu items throughout — nothing icon-only. ReplyThreadView and iOS untouched. Builds warning-clean. 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>
4 files changed,
+59
−1
ios/LChatCore/Sources/LChatCore/MessageBubble.swift+14 −1ios/LChatCore/Sources/LChatCore/MessageRightClick.swift+5 −0ios/LChatMac/LChatMacApp.swift+8 −0ios/LChatMac/NativeFeedView.swift+32 −0
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index ac6b6c0..2a1fa46 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -66,6 +66,12 @@ public extension EnvironmentValues { |
| public struct MessageActions { |
| public var canEdit: Bool |
| public var onReply: (() -> Void)? |
| + /// "Reply in thread" (thread-pane spec §3.5): open the message's reply |
| + /// thread — resolved to its ROOT, so replying to a reply joins the |
| + /// existing thread — with the caret in the thread's composer, rather |
| + /// than staging a reply on the channel bar. Same gating as onReply; nil |
| + /// on surfaces where it's redundant (the docked pane's own bubbles). |
| + public var onReplyInThread: (() -> Void)? |
| public var onEdit: (() -> Void)? |
| public var onDelete: (() -> Void)? |
| public var onCopy: (() -> Void)? |
| @@ -84,7 +90,9 @@ public struct MessageActions { |
| public var onReport: (() -> Void)? |
| public init(canEdit: Bool = false, |
| - onReply: (() -> Void)? = nil, onEdit: (() -> Void)? = nil, |
| + onReply: (() -> Void)? = nil, |
| + onReplyInThread: (() -> Void)? = nil, |
| + onEdit: (() -> Void)? = nil, |
| onDelete: (() -> Void)? = nil, onCopy: (() -> Void)? = nil, |
| onReact: ((String) -> Void)? = nil, |
| timesShown: Bool = false, onToggleTimes: (() -> Void)? = nil, |
| @@ -93,6 +101,7 @@ public struct MessageActions { |
| onReport: (() -> Void)? = nil) { |
| self.canEdit = canEdit |
| self.onReply = onReply |
| + self.onReplyInThread = onReplyInThread |
| self.onEdit = onEdit |
| self.onDelete = onDelete |
| self.onCopy = onCopy |
| @@ -366,6 +375,10 @@ public struct MessageBubble: View { |
| if let reply = actions.onReply { |
| Button("Reply…", action: reply) |
| } |
| + // Right after Reply, as on the web — the label alone tells them apart. |
| + if let inThread = actions.onReplyInThread { |
| + Button("Reply in Thread…", action: inThread) |
| + } |
| if let copy = actions.onCopy { |
| Button("Copy", action: copy) |
| } |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift| index a2fe186..a10e3f0 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageRightClick.swift |
| @@ -65,6 +65,11 @@ struct MessageRightClick: NSViewRepresentable { |
| if let reply = actions.onReply { |
| menu.addItem(ClosureMenuItem(title: "Reply…", handler: reply)) |
| } |
| + // Right after Reply, as on the web — the label alone tells them apart |
| + // (the Mac menu carries no glyphs, so there's nothing icon-only here). |
| + if let inThread = actions.onReplyInThread { |
| + menu.addItem(ClosureMenuItem(title: "Reply in Thread…", handler: inThread)) |
| + } |
| if let copy = actions.onCopy { |
| menu.addItem(ClosureMenuItem(title: "Copy", handler: copy)) |
| } |
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift| index 0ed876e..0e995b9 100644 |
| --- a/ios/LChatMac/LChatMacApp.swift |
| +++ b/ios/LChatMac/LChatMacApp.swift |
| @@ -131,6 +131,11 @@ struct LChatMacApp: App { |
| CommandMenu("Message") { |
| Button("Reply to Latest Message") { app.replyLatestTick += 1 } |
| .keyboardShortcut("r", modifiers: .command) |
| + // ⌘R's thread-pane twin (thread-pane spec §3.5): opens the |
| + // latest message's thread in the docked pane instead of |
| + // staging a reply on the channel bar. |
| + Button("Reply in Thread to Latest Message") { app.replyInThreadLatestTick += 1 } |
| + .keyboardShortcut("r", modifiers: [.command, .shift]) |
| Button("React to Latest Message") { app.reactLatestTick += 1 } |
| .keyboardShortcut("t", modifiers: .command) |
| } |
| @@ -305,6 +310,9 @@ final class MacAppState: ObservableObject { |
| // Ticks like the two above — pressing the same shortcut twice must fire |
| // twice — consumed by whichever native feed is on screen. |
| @Published var replyLatestTick = 0 |
| + // Message ▸ Reply in Thread to Latest Message (⇧⌘R) — ⌘R's docked-pane |
| + // twin, the same tick shape. |
| + @Published var replyInThreadLatestTick = 0 |
| @Published var reactLatestTick = 0 |
| // File → New Space (and anything else app-level) asks for the create flow |
| // by bumping this; RootView owns the takeover and opens on the tick. |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 828d189..55628c3 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -658,6 +658,16 @@ struct NativeFeedView: View { |
| startReply(m) |
| } |
| } |
| + // ⇧⌘R (Message ▸ Reply in Thread to Latest Message) — ⌘R's |
| + // thread-pane twin, same gate and same "latest" read: the pane's |
| + // messages while it's open (so it resolves to the open thread and |
| + // just refocuses), the feed's otherwise (opens that message's |
| + // thread, root-resolved like the menu verb). |
| + .onChange(of: app.replyInThreadLatestTick) { _, _ in |
| + guard isActive, !model.dmClosed, |
| + let m = ChatFeedModel.latestReplyTarget(in: hotkeyMessages) else { return } |
| + replyInThread(m) |
| + } |
| .onChange(of: app.reactLatestTick) { _, _ in |
| guard isActive, |
| let m = ChatFeedModel.latestReactionTarget(in: hotkeyMessages) else { return } |
| @@ -1926,6 +1936,7 @@ struct NativeFeedView: View { |
| var actions = MessageActions( |
| canEdit: model.canEdit(m), |
| onReply: { startReply(m) }, |
| + onReplyInThread: { replyInThread(m) }, |
| onEdit: { startEdit(m) }, |
| onDelete: { model.deleteMessage(m.id) }, |
| onCopy: { |
| @@ -1963,6 +1974,12 @@ struct NativeFeedView: View { |
| private func paneMessageActions(_ m: ChatFeedModel.Message) -> MessageActions { |
| var actions = messageActions(m, times: false) |
| actions.onReply = { paneFocused = true } |
| + // "Reply in Thread" from inside the pane would resolve to the thread |
| + // already open and do exactly what Reply does here (put the caret in |
| + // the pane's bar) — two rows for one act is noise, so the pane's |
| + // menus omit it. (The web keeps it as a harmless refocus; the Mac |
| + // NSMenu is denser, so it drops.) |
| + actions.onReplyInThread = nil |
| actions.onEdit = { startPaneEdit(m) } |
| return actions |
| } |
| @@ -2006,6 +2023,21 @@ struct NativeFeedView: View { |
| }) |
| } |
| + // "Reply in Thread" (thread-pane spec §3.5; web openReplyThread): open |
| + // the message's thread anchored on its resolved ROOT — replyTarget |
| + // semantics, so replying to a reply joins the existing thread rather than |
| + // forking one on the reply — with the caret in the pane's bar (openThread |
| + // claims that focus). The thread already open on that root: just the |
| + // caret, the same as Reply on one of the pane's own bubbles. |
| + private func replyInThread(_ m: ChatFeedModel.Message) { |
| + guard let rootID = model.replyTarget(for: m).id, rootID > 0 else { return } |
| + if threadRootID == rootID { |
| + if threadDocked { paneFocused = true } else { stageThreadReply() } |
| + } else { |
| + openThread(rootID) |
| + } |
| + } |
| + |
| private func openThread(_ rootID: Int64) { |
| guard rootID > 0, threadRootID != rootID else { return } |
| // Freeze the header stand-in NOW (web threadFallback) — the deepest |