Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ab48ced
feat(next): render prompt quotes and compose message menus
teamleaderleo Oct 28, 2026
9e54a36
fix(next): offer markdown copy for prompts
teamleaderleo Oct 28, 2026
38e89d5
fix(next): match message copy label
teamleaderleo Oct 28, 2026
84b4ad0
fix(next): make menu actions reach rail and composer
teamleaderleo Oct 28, 2026
14fe5c0
fix(next): hide pin seam without a rail row
teamleaderleo Oct 28, 2026
1615b84
fix(next): guard transcript chapter pins by session
teamleaderleo Oct 28, 2026
047aa3b
fix(next): localize transcript menu labels
teamleaderleo Oct 28, 2026
2ad2531
fix(next): correct Khmer chapter pin label
teamleaderleo Oct 28, 2026
f98d414
test(next): cover sent prompt Markdown beyond quotes
teamleaderleo Oct 29, 2026
8e0c5dd
fix(next): render Markdown in sent user prompts
teamleaderleo Oct 29, 2026
a617089
Merge feat-cmux-next into codex/nx-quote-transcript-menu-1028
teamleaderleo Nov 2, 2026
913cad4
test(next): compare menu page actions field by field
teamleaderleo Nov 2, 2026
1dbae59
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
54fb99c
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
46a9cf9
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
128b6ac
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
7802f96
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
b00c6c8
Merge branch 'feat-cmux-next' into codex/nx-quote-transcript-menu-1028
cmux-bot[bot] Nov 3, 2026
9a16d26
Merge feat-cmux-next into codex/nx-quote-transcript-menu-1028
teamleaderleo Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import CmuxNextDesign
/// reports them on `contextmenu` (webviews conversation/messageMenu.ts), before WebKit asks the host
/// for the menu.
struct AgentPaneMessageTarget: Equatable, Sendable {
/// The transcript row id, used by the rail-owned Pin as chapter seam.
var rowId: String?
/// The session that owns the transcript row. Native actions reject a stale menu after a chat switch.
var sessionId: String?
/// The message as a person reads it; empty for an image outside a message (the gallery).
var text: String
/// An agent reply's Markdown source; nil for a prompt (it is plain text already).
Expand All @@ -28,8 +32,10 @@ struct AgentPaneMessageTarget: Equatable, Sendable {
/// The code block, command, path or link under the pointer (webviews conversation/menuParts.ts).
var part: AgentPaneMenuPart?

init(text: String, markdown: String? = nil, replyTime: String? = nil, forkSeq: Int? = nil, sideChat: Bool = false, retryRowId: String? = nil,
init(text: String, rowId: String? = nil, sessionId: String? = nil, markdown: String? = nil, replyTime: String? = nil, forkSeq: Int? = nil, sideChat: Bool = false, retryRowId: String? = nil,
links: [URL] = [], opensImage: Bool = false, hasImage: Bool? = nil, part: AgentPaneMenuPart? = nil) {
self.rowId = rowId
self.sessionId = sessionId
self.text = text
self.markdown = markdown
self.replyTime = replyTime
Expand All @@ -50,6 +56,8 @@ struct AgentPaneMessageTarget: Equatable, Sendable {
init?(report body: Any?) {
guard let object = body as? [String: Any] else { return nil }
let text = object["text"] as? String ?? ""
rowId = (object["rowId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
sessionId = (object["sessionId"] as? String).flatMap { $0.isEmpty ? nil : $0 }
opensImage = (object["openImage"] as? NSNumber)?.boolValue ?? false
hasImage = opensImage || ((object["image"] as? NSNumber)?.boolValue ?? false)
part = AgentPaneMenuPart(report: object["part"])
Expand All @@ -73,13 +81,12 @@ struct AgentPaneMessageTarget: Equatable, Sendable {
/// The agent pane's native context menu. The pane is app chrome, so WebKit's default menu (Reload,
/// Back, Look Up, Share...) never shows: on an image Open Image, Copy Image, Save Image As…, Quick Look and Show in Finder; the items
/// of the code block, command, path or link under the pointer (``AgentPaneMenuPart``, cx-2lm4);
/// Cut, Copy and Paste where WebKit offers them (Copy on a
/// selection, Cut and Paste in the composer); for the message under the pointer Copy Message (a
/// reply's Markdown) and Copy as Plain Text, Retry on a prompt that was not sent, Edit and Resend
/// on a prompt, Fork from Here when its turn can be forked, and Open Link for its links and images;
/// the chat's menu (Change Background, zoom...) on empty space; on selected text Copy, Quote in
/// Reply, Ask About This, Search the Web and Look Up; and Inspect Element in builds with developer
/// tools.
/// Cut, Copy and Paste where WebKit offers them (Copy on a selection, Cut and Paste in the
/// composer); for the message under the pointer Copy message and Copy message as Markdown,
/// Retry/Edit and Resend, the rail-owned Pin as chapter seam, Fork from Here and Open Link; the
/// chat's menu (Change Background, zoom...) on empty space; and on selected text the combined
/// Copy, Quote in Reply, Attach selection as context, Search the Web and Ask About This rows.
/// Inspect Element remains available in builds with developer tools.
@MainActor
enum AgentPaneContextMenu {
/// WebKit's edit items the pane keeps, in WebKit's order.
Expand All @@ -97,6 +104,8 @@ enum AgentPaneContextMenu {
var edit: (String) -> Void = { _ in }
/// Quote in Reply: the Markdown quote goes after the composer's draft, which it keeps.
var quoteReply: (String) -> Void = { _ in }
/// Adds selected transcript text as a composer context chip.
var attachSelection: (String) -> Void = { _ in }
var open: (URL) -> Void = { _ in }
/// A link part's rows (``LinkMenu``): Open in cmux Browser, ..., Copy Link; nil: Open Link and Copy Link.
var linkItems: ((URL) -> [NSMenuItem])? = nil
Expand All @@ -107,6 +116,8 @@ enum AgentPaneContextMenu {
/// A chat part's Go to Chat, nil when no tab shows the chat (``AgentPaneOfferOpen/goTo(_:sender:in:)``).
var goToSession: ((String, Bool) -> (@MainActor () -> Void)?)?
var search: (String) -> Void = { _ in }
/// Toggle the rail-owned bookmark for a transcript row.
var pinChapter: (String, String) -> Void = { _, _ in }
/// One of the page's own menu actions (`window.cmuxAcpmuxActions`) by name.
var page: (String, [String: String]) -> Void = { _, _ in }
/// The web view WebKit's Paste item runs `paste:` on (``AgentPaneDroppedPaths``); nil keeps WebKit's own action.
Expand Down Expand Up @@ -224,17 +235,6 @@ enum AgentPaneContextMenu {
item.image = webKitSymbols[item.identifier?.rawValue ?? ""].flatMap(icon)
}
menu.removeAllItems()
if let selection {
// Selected text gets the selection menu alone (macOS adds Services last).
let quote = Self.quote(selection)
let selected = edits + [
AgentPaneMenuAction.item(AgentPaneMenuStrings.quoteInReply, symbol: "text.quote") { actions.quoteReply(quote) },
AgentPaneMenuAction.item(AgentPaneMenuStrings.askAboutThis, symbol: "questionmark.bubble") { actions.edit(quote + AgentPaneMenuStrings.askAboutThisPrompt) },
]
let find = [AgentPaneMenuAction.item(AgentPaneMenuStrings.searchTheWeb, symbol: "magnifyingglass") { actions.search(selection) }] + (lookUp.map { [$0] } ?? [])
add([selected, find, inspectElement.map { [$0] } ?? inspect.map { [$0] } ?? []], to: menu)
return
}
var image: [NSMenuItem] = []
if target?.opensImage == true {
image.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.openImage, symbol: "photo.on.rectangle") { actions.page("chat.menu.openImage", [:]) })
Expand All @@ -253,25 +253,51 @@ enum AgentPaneContextMenu {
linkItems: actions.linkItems, pathItems: actions.pathItems,
openSession: actions.openSession, goToSession: actions.goToSession) ?? []
var copies = edits
var selectionActions: [NSMenuItem] = []
var message: [NSMenuItem] = []
var links: [NSMenuItem] = []
if let selection {
let quote = Self.quote(selection)
selectionActions.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.quoteInReply, symbol: "text.quote") {
actions.quoteReply(quote)
})
selectionActions.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.attachSelectionAsContext, symbol: "paperclip") {
actions.attachSelection(selection)
})
}
if let target, !target.text.isEmpty {
if let markdown = target.markdown {
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyMessage, symbol: "doc.on.doc") { actions.copy(markdown) })
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyAsPlainText, symbol: "doc.plaintext") { actions.copy(target.text) })
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyMessage, symbol: "doc.plaintext") { actions.copy(target.text) })
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyMessageAsMarkdown, symbol: "doc.on.doc") { actions.copy(markdown) })
} else {
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyMessage, symbol: "doc.on.doc") { actions.copy(target.text) })
if target.isPrompt {
// Prompts are already their Markdown source; keep the distinction from the
// rendered copy without changing isPrompt (which drives Edit and Resend).
copies.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.copyMessageAsMarkdown, symbol: "doc.on.doc") {
actions.copy(target.text)
})
}
}
// Quote in Reply without selecting first (u18673): the whole message, as the reader sees it in Markdown,
// appended to the draft like the selection's Quote in Reply, never Edit and Resend's replace.
let quoted = Self.quote(target.markdown ?? target.text)
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.quoteInReply, symbol: "text.quote") { actions.quoteReply(quoted) })
// With a selection, the selection's Quote in Reply stands for the message's.
if selection == nil {
let quoted = Self.quote(target.markdown ?? target.text)
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.quoteInReply, symbol: "text.quote") { actions.quoteReply(quoted) })
}
if let row = target.retryRowId {
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.retry, symbol: "arrow.clockwise") { actions.retry(row) })
}
if target.isPrompt {
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.editAndResend, symbol: "pencil") { actions.edit(target.text) })
}
if let rowId = target.rowId, let sessionId = target.sessionId {
// The rail owns bookmark state; this callback is the menu's explicit seam.
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.pinAsChapter, symbol: "bookmark") {
actions.pinChapter(rowId, sessionId)
})
}
if let seq = target.forkSeq {
message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.forkFromHere, symbol: "arrow.triangle.branch") { actions.fork(seq) })
// The chat menu's New side chat, through this turn (the page's `chat.side`).
Expand All @@ -289,8 +315,7 @@ enum AgentPaneContextMenu {
// On a link, the part's Open Link stands for the message's.
links = target.part?.link == nil ? linkItems(target.links, open: actions.open) : []
}
// Empty space (no message, no image, nothing to edit) gets the chat's own menu, its sections kept.
let chat = target == nil && edits.isEmpty && image.isEmpty ? chatMenu.filter { $0.menu == nil } : []
// A reply's menu opens on its non-interactive date and time header (not a selection's).
let header: [NSMenuItem]
if target?.markdown != nil, let time = target?.replyTime {
let item = NSMenuItem(title: time, action: nil, keyEquivalent: "")
Expand All @@ -299,6 +324,22 @@ enum AgentPaneContextMenu {
} else {
header = []
}
if let selection {
// Keep selected-text actions at the end of the native menu, with Ask About This last.
// Look Up remains WebKit-owned but precedes the app's web search rows.
var find = lookUp.map { [$0] } ?? []
find.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.searchTheWeb, symbol: "magnifyingglass") {
actions.search(selection)
})
find.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.askAboutThis, symbol: "questionmark.bubble") {
actions.edit(Self.quote(selection) + AgentPaneMenuStrings.askAboutThisPrompt)
})
add([image, part, copies, selectionActions, message, links,
inspectElement.map { [$0] } ?? inspect.map { [$0] } ?? [], find], to: menu)
return
}
// Empty space (no message, no image, nothing to edit) gets the chat's own menu, its sections kept.
let chat = target == nil && edits.isEmpty && image.isEmpty ? chatMenu.filter { $0.menu == nil } : []
add([header, image, part, copies, message, links, chat, inspectElement.map { [$0] } ?? inspect.map { [$0] } ?? []], to: menu)
}

Expand Down Expand Up @@ -370,11 +411,16 @@ enum AgentPaneMenuStrings {
}

static var copyMessage: String {
String(localized: "agentPane.menu.copyMessage", defaultValue: "Copy Message", bundle: .module)
String(localized: "agentPane.menu.copyMessage", defaultValue: "Copy message", bundle: .module)
}

static var copyMessageAsMarkdown: String {
String(localized: "agentPane.menu.copyMessageAsMarkdown", defaultValue: "Copy message as Markdown", bundle: .module)
}

/// Compatibility for callers that still refer to the old plain-text key.
static var copyAsPlainText: String {
String(localized: "agentPane.menu.copyAsPlainText", defaultValue: "Copy as Plain Text", bundle: .module)
copyMessage
}

static var retry: String {
Expand Down Expand Up @@ -421,6 +467,10 @@ enum AgentPaneMenuStrings {
String(localized: "agentPane.menu.quoteInReply", defaultValue: "Quote in Reply", bundle: .module)
}

static var attachSelectionAsContext: String {
String(localized: "agentPane.menu.attachSelectionAsContext", defaultValue: "Attach selection as context", bundle: .module)
}

static var askAboutThis: String {
String(localized: "agentPane.menu.askAboutThis", defaultValue: "Ask About This", bundle: .module)
}
Expand All @@ -434,6 +484,10 @@ enum AgentPaneMenuStrings {
String(localized: "agentPane.menu.searchTheWeb", defaultValue: "Search the Web", bundle: .module)
}

static var pinAsChapter: String {
String(localized: "agentPane.menu.pinAsChapter", defaultValue: "Pin as chapter", bundle: .module)
}

static var forkFromHere: String {
String(localized: "agentPane.menu.forkFromHere", defaultValue: "Fork from Here", bundle: .module)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ extension AgentPaneView {
retry: { [weak self] row in self?.runMessageAction("chat.retryPrompt", ["rowId": row]) },
edit: { [weak self] text in self?.runMessageAction("chat.editPrompt", ["text": text]) },
quoteReply: { [weak self] markdown in self?.runMessageAction("chat.quoteReply", ["markdown": markdown]) },
attachSelection: { [weak self] text in self?.runMessageAction("chat.attachSelection", ["text": text]) },
open: { [weak self] url in self?.openLink(url) },
linkItems: { LinkMenu().linkItems(for: $0) },
pathItems: { [weak self] path in self?.pathMenuItems(path) },
Expand All @@ -315,6 +316,9 @@ extension AgentPaneView {
}
},
search: { [weak self] text in self?.model.onSearchWeb?(text) },
pinChapter: { [weak self] rowId, sessionId in
self?.runMessageAction("chat.pinChapter", ["rowId": rowId, "sessionId": sessionId])
},
page: { [weak self] name, params in self?.runMessageAction(name, params) },
pasteTarget: webView,
inspectElement: AgentPaneTunables.pointerInspector.value ? {
Expand Down
Loading
Loading