eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev e9c7c6a98d3d9ea815cd66220f0c7626c2a420e4 parent cb2984b
4 files changed, +59 −1
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +14 −1
  • ios/LChatCore/Sources/LChatCore/MessageRightClick.swift +5 −0
  • ios/LChatMac/LChatMacApp.swift +8 −0
  • ios/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