From ab48ced506353d78f0b1d5ece8d325e916273191 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:41:58 -0700 Subject: [PATCH 01/11] feat(next): render prompt quotes and compose message menus Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../AgentPaneContextMenu.swift | 87 ++++++++++++++----- .../AgentPaneView+ContextMenu.swift | 3 + .../AgentPaneMenuLookTests.swift | 60 ++++++++++++- webviews/src/agent-session/acpmux/App.tsx | 26 ++++++ .../acpmux/conversation/PromptText.tsx | 13 ++- .../acpmux/conversation/UserBubble.gallery.ts | 10 +++ .../acpmux/conversation/conversation.css | 9 ++ .../acpmux/conversation/messageMenu.ts | 4 +- webviews/test/user-prompt-collapse.test.tsx | 11 +++ 9 files changed, 192 insertions(+), 31 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift index 7ccef30d5dc1..1b57618013ab 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift @@ -5,6 +5,8 @@ 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 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). @@ -26,8 +28,9 @@ 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, forkSeq: Int? = nil, sideChat: Bool = false, retryRowId: String? = nil, + init(text: String, rowId: String? = nil, markdown: 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.text = text self.markdown = markdown self.forkSeq = forkSeq @@ -47,6 +50,7 @@ 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 } opensImage = (object["openImage"] as? NSNumber)?.boolValue ?? false hasImage = opensImage || ((object["image"] as? NSNumber)?.boolValue ?? false) part = AgentPaneMenuPart(report: object["part"]) @@ -69,13 +73,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. @@ -91,12 +94,18 @@ enum AgentPaneContextMenu { var fork: (Int) -> Void var retry: (String) -> Void = { _ in } var edit: (String) -> Void = { _ in } + /// Inserts a Markdown quote into the composer's existing draft. + 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 /// A path part's rows after Open (``LinkMenu``); nil (or a nil answer): Copy Path. var pathItems: ((String) -> [NSMenuItem]?)? = nil var search: (String) -> Void = { _ in } + /// Toggle the rail-owned bookmark for a transcript row. + var pinChapter: (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. @@ -176,17 +185,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.edit(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", [:]) }) @@ -204,12 +202,22 @@ enum AgentPaneContextMenu { let part = target?.part?.items(copy: actions.copy, openPath: { actions.page("chat.menu.openPath", ["path": $0]) }, open: actions.open, linkItems: actions.linkItems, pathItems: actions.pathItems) ?? [] 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) }) } @@ -219,6 +227,12 @@ enum AgentPaneContextMenu { if target.isPrompt { message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.editAndResend, symbol: "pencil") { actions.edit(target.text) }) } + if let rowId = target.rowId { + // The rail owns bookmark state; this callback is the menu's explicit seam. + message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.pinAsChapter, symbol: "bookmark") { + actions.pinChapter(rowId) + }) + } 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`). @@ -231,6 +245,20 @@ 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) : [] } + 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([image, part, copies, message, links, chat, inspectElement.map { [$0] } ?? inspect.map { [$0] } ?? []], to: menu) @@ -304,11 +332,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 { @@ -355,6 +388,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) } @@ -368,6 +405,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) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift index 340600f37c4f..ce5390d42588 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift @@ -207,10 +207,13 @@ extension AgentPaneView { fork: { [weak self] seq in self?.fork(through: seq) }, 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) }, search: { [weak self] text in self?.model.onSearchWeb?(text) }, + pinChapter: { [weak self] rowId in self?.runMessageAction("chat.pinChapter", ["rowId": rowId]) }, page: { [weak self] name, params in self?.runMessageAction(name, params) }, pasteTarget: webView, inspectElement: AgentPaneTunables.pointerInspector.value ? { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift index 1ac8236d35ef..b6adf5288f02 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift @@ -58,14 +58,66 @@ struct AgentPaneMenuLookTests { #expect(!menu.items.contains { $0.identifier?.rawValue == "WKMenuItemIdentifierReload" }) } - /// Choosing Copy Message on a reply copies its Markdown. + /// Message copies preserve both the rendered text and the source Markdown. @Test func aChosenPaneRowStillRuns() throws { var copied: [String] = [] let menu = Self.menu(target: Self.reply, copied: { copied.append($0) }) - let item = try #require(menu.items.first { $0.title == AgentPaneMenuStrings.copyMessage }) - try Self.click(item) + let plain = try #require(menu.items.first { $0.title == AgentPaneMenuStrings.copyMessage }) + let markdown = try #require(menu.items.first { $0.title == AgentPaneMenuStrings.copyMessageAsMarkdown }) + try Self.click(plain) + try Self.click(markdown) Self.drainRunLoop { !copied.isEmpty } - #expect(copied == [Self.reply.markdown]) + #expect(copied == [Self.reply.text, Self.reply.markdown]) + } + + @Test func selectedMessageKeepsCopyRowsThenQuoteContextPinForkAndSearch() throws { + var page: [(String, [String: String])] = [] + var quoted: [String] = [] + var attached: [String] = [] + let target = AgentPaneMessageTarget(text: "Rendered body", rowId: "summary-4", markdown: "**Body**", forkSeq: 4) + // Rebuild with the seam callbacks because the helper's defaults intentionally do nothing. + let webKit = Self.webKitMenu(MenuLookWebKitTarget()) + AgentPaneContextMenu.rebuild( + webKit, + target: target, + selection: "selected words", + devTools: false, + scope: ThemeScope.app.fullStrength, + actions: .init( + copy: { _ in }, + fork: { _ in }, + quoteReply: { quoted.append($0) }, + attachSelection: { attached.append($0) }, + search: { page.append(("search", ["text": $0])) }, + pinChapter: { page.append(("pin", ["rowId": $0])) }, + ), + ) + let titles = webKit.items.map { $0.isSeparatorItem ? "-" : $0.title } + #expect(titles == [ + "Copy", + AgentPaneMenuStrings.copyMessage, + AgentPaneMenuStrings.copyMessageAsMarkdown, + "-", + AgentPaneMenuStrings.quoteInReply, + AgentPaneMenuStrings.attachSelectionAsContext, + "-", + AgentPaneMenuStrings.pinAsChapter, + AgentPaneMenuStrings.forkFromHere, + "-", + AgentPaneMenuStrings.searchTheWeb, + AgentPaneMenuStrings.askAboutThis, + ]) + let quote = try #require(webKit.items.first { $0.title == AgentPaneMenuStrings.quoteInReply }) + let attach = try #require(webKit.items.first { $0.title == AgentPaneMenuStrings.attachSelectionAsContext }) + let pin = try #require(webKit.items.first { $0.title == AgentPaneMenuStrings.pinAsChapter }) + let search = try #require(webKit.items.first { $0.title == AgentPaneMenuStrings.searchTheWeb }) + try Self.click(quote) + try Self.click(attach) + try Self.click(pin) + try Self.click(search) + #expect(quoted == ["> selected words\n\n"]) + #expect(attached == ["selected words"]) + #expect(page == [("pin", ["rowId": "summary-4"]), ("search", ["text": "selected words"])]) } /// Fork from Here's neighbour Open in Side Chat on a prompt runs the page's side chat action. diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index d6b32b7e9568..41bbe556bf18 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -7816,6 +7816,32 @@ function AcpmuxPane() { restorePrompt(String(text ?? ""), []); composerHandle.current?.focus(); }, + // Native transcript menus insert a Markdown quote into the existing draft. The optional + // method keeps this action compatible with panes whose composer predates quoteReply. + "chat.quoteReply": async ({ markdown }) => { + const handle = composerHandle.current as + | (ComposerHandle & { + quoteReply?: (value: string) => void; + }) + | null; + handle?.quoteReply?.(String(markdown ?? "")); + handle?.focus(); + }, + // Selected transcript text can be attached as a context chip without changing the + // draft text. The rail/menu owns only the gesture; the composer owns chip rendering. + "chat.attachSelection": async ({ text }) => { + const value = String(text ?? "").trim(); + if (!value) return; + composerHandle.current?.attach({ + id: "transcript-selection", + kind: "text", + name: "Selection", + mimeType: "text/plain", + size: value.length, + text: value, + }); + composerHandle.current?.focus(); + }, "chat.history": () => client.loadOlder(), // Find in Chat (the app's Find commands on an agent pane). "chat.find": async ({ text }) => findRef.current?.show(typeof text === "string" ? text : undefined), diff --git a/webviews/src/agent-session/acpmux/conversation/PromptText.tsx b/webviews/src/agent-session/acpmux/conversation/PromptText.tsx index 433da648c4a5..6516bae88316 100644 --- a/webviews/src/agent-session/acpmux/conversation/PromptText.tsx +++ b/webviews/src/agent-session/acpmux/conversation/PromptText.tsx @@ -19,6 +19,7 @@ import { import { useTranscriptDisclosure } from "./transcriptDisclosure"; import { foldMotion, foldMs, markHeightFold } from "./fold"; import { holdHeader } from "./useAnchoredCollapse"; +import { Markdown } from "./Markdown"; export function PromptText({ rowId, @@ -154,9 +155,15 @@ export function PromptText({
{parts.map((part, index) => part.kind === "text" ? ( - - {show(part.text)} - + /(^|\n)\s*>/.test(part.text) ? ( + + {show(part.text)} + + ) : ( + + {show(part.text)} + + ) ) : ( The upload retries forever\n> when the network is offline.\n\nAdd a bounded backoff.", 9, { seq: 1 }), + assistant("Added a five-attempt cap and a reset on the next upload.", 8.5), + summary(8.5, { seq: 3, status: "completed" }), + ]), + ), + }, "pasted-chip": { note: "A pasted 2,300-line stack trace is a chip under the typed question: its first words and line count.", snapshot: pastedPrompt(), diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index ae011a29cd2d..3d93db01cb7b 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -330,6 +330,15 @@ .cv-user__text { display: contents; } +/* Sent prompts share the transcript Markdown renderer so a quote is a quiet block with a muted + bar, rather than literal `>` characters. The normal prompt text keeps the bubble's existing ink. */ +.cv-user__markdown { + min-width: 0; +} +.cv-user__markdown .cv-quote { + color: var(--cv-text-muted); + --cv-strong-rule: color-mix(in srgb, var(--cv-text-muted) 58%, transparent); +} .cv-user__text.is-clamped { display: block; max-height: calc(var(--cv-line-height) * 10); diff --git a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts index 4955e57c2b7e..b92ec0fe0ee0 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -14,6 +14,8 @@ import { menuPartAt, type MenuPart } from "./menuParts"; export const MESSAGE_MENU_HANDLER = "cmuxAgentContextMenu"; export type MessageMenuTarget = { + /// The transcript row id, used by the native menu's Pin as chapter seam. + rowId?: string; text: string; markdown?: string; forkSeq?: number; @@ -137,7 +139,7 @@ export function messageMenuTarget( ? turnSeq : undefined; const target: MessageMenuTarget = - row.kind === "user" ? { text: row.text } : { text: plainText(row.text), markdown: row.text }; + row.kind === "user" ? { rowId: id, text: row.text } : { rowId: id, text: plainText(row.text), markdown: row.text }; if (forkSeq !== undefined) target.forkSeq = forkSeq; if (forkSeq !== undefined && sideChat) target.sideChat = true; if (row.kind === "user" && row.failed) target.retryRowId = row.id; diff --git a/webviews/test/user-prompt-collapse.test.tsx b/webviews/test/user-prompt-collapse.test.tsx index f58d2d9172d2..3afac951e49f 100644 --- a/webviews/test/user-prompt-collapse.test.tsx +++ b/webviews/test/user-prompt-collapse.test.tsx @@ -157,6 +157,17 @@ try { expect(toggle()).toBeNull(); }); + test("a sent prompt renders Markdown blockquotes with the transcript quote treatment", () => { + resetTranscriptDisclosure(); + const text = "> The upload retries forever\n> when offline.\n\nAdd a bounded backoff."; + render(prompt("user-quote", text)); + const quote = host.querySelector(".cv-user__markdown .cv-quote"); + expect(quote).not.toBeNull(); + expect(quote?.textContent).toBe("The upload retries foreverwhen offline."); + expect(host.querySelector(".cv-user__markdown")?.textContent).not.toContain("> The"); + expect(host.querySelector(".cv-user__markdown > .cv-p")?.textContent).toBe("Add a bounded backoff."); + }); + // Leo 2026-10-21: prompts drew as empty grey bubbles. A prompt with no text to read draws no // bubble; one of images alone is its images (agent-pane-blank-prompt.test.ts covers the ingest). test("a prompt of images alone draws its images and no empty bubble", () => { From 9e54a36407f584b18344ccad7d62e25d58f7c517 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:50:10 -0700 Subject: [PATCH 02/11] fix(next): offer markdown copy for prompts Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift index 1b57618013ab..e6fe592d5b9f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift @@ -220,6 +220,13 @@ enum AgentPaneContextMenu { 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) + }) + } } if let row = target.retryRowId { message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.retry, symbol: "arrow.clockwise") { actions.retry(row) }) From 38e89d5497ca8351b6636ef314873536e30d2798 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:53:53 -0700 Subject: [PATCH 03/11] fix(next): match message copy label Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 1861fc78067e..a08fd4763958 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -23102,7 +23102,7 @@ "en": { "stringUnit": { "state": "translated", - "value": "Copy Message" + "value": "Copy message" } }, "es": { From 84b4ad0d3fcde4b2c0ee4384393f6af9798276e6 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 22:30:29 -0700 Subject: [PATCH 04/11] fix(next): make menu actions reach rail and composer Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- webviews/src/agent-session/acpmux/App.tsx | 34 +++++++++++-------- .../acpmux/conversation/messageMenu.ts | 6 +++- .../acpmux/transcriptSelection.ts | 16 +++++++++ webviews/test/message-more-button.test.tsx | 5 +++ webviews/test/transcript-selection.test.ts | 17 ++++++++++ 5 files changed, 63 insertions(+), 15 deletions(-) create mode 100644 webviews/src/agent-session/acpmux/transcriptSelection.ts create mode 100644 webviews/test/transcript-selection.test.ts diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 41bbe556bf18..121e52999e94 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -357,7 +357,8 @@ import { NeedsYouPill, type TranscriptNeedsYou } from "./needsYou/JumpPill"; import { useNeedsYou } from "./needsYou/useNeedsYou"; import { copyText, copyTextResult } from "./conversation/clipboard"; import { lastCodeBlock } from "./conversation/lastCodeBlock"; -import { transcriptRangeText } from "./conversation/transcriptCopy"; +import { hasTranscriptCopyText, transcriptRangeText } from "./conversation/transcriptCopy"; +import { transcriptSelectionAttachment } from "./transcriptSelection"; import { rovingTabStopProps } from "../../ui/listRowKeyboard"; import { chatImages, type ChatImage } from "./conversation/chatImages"; import { ImageViewer } from "./conversation/ImageViewer"; @@ -7816,32 +7817,37 @@ function AcpmuxPane() { restorePrompt(String(text ?? ""), []); composerHandle.current?.focus(); }, - // Native transcript menus insert a Markdown quote into the existing draft. The optional - // method keeps this action compatible with panes whose composer predates quoteReply. + // Native transcript menus insert a Markdown quote into the existing draft. Composer + // quoteReply (PR #26655) appends after the current draft; the restore fallback keeps an + // older pane functional while that stack dependency is landing. "chat.quoteReply": async ({ markdown }) => { const handle = composerHandle.current as | (ComposerHandle & { quoteReply?: (value: string) => void; }) | null; - handle?.quoteReply?.(String(markdown ?? "")); + const quote = String(markdown ?? ""); + if (handle?.quoteReply) handle.quoteReply(quote); + else restorePrompt(quote, []); handle?.focus(); }, // Selected transcript text can be attached as a context chip without changing the // draft text. The rail/menu owns only the gesture; the composer owns chip rendering. "chat.attachSelection": async ({ text }) => { - const value = String(text ?? "").trim(); - if (!value) return; - composerHandle.current?.attach({ - id: "transcript-selection", - kind: "text", - name: "Selection", - mimeType: "text/plain", - size: value.length, - text: value, - }); + const attachment = transcriptSelectionAttachment(String(text ?? "")); + if (!attachment) return; + composerHandle.current?.attach(attachment); composerHandle.current?.focus(); }, + // Pin as chapter is the native menu's named rail action. Marking true is idempotent: + // the bookmark transport publishes the resulting tick to every rail on this session. + "chat.pinChapter": async ({ rowId }) => { + const match = /^(?:user|summary)-(\d+)$/.exec(String(rowId ?? "")); + const seq = match ? Number(match[1]) : NaN; + const sessionId = client.selectedSession; + if (!sessionId || !Number.isSafeInteger(seq) || seq <= 0) return; + await client.setBookmark(sessionId, seq, true); + }, "chat.history": () => client.loadOlder(), // Find in Chat (the app's Find commands on an agent pane). "chat.find": async ({ text }) => findRef.current?.show(typeof text === "string" ? text : undefined), diff --git a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts index b92ec0fe0ee0..91dbf542c3ce 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -138,8 +138,12 @@ export function messageMenuTarget( turnSeq !== undefined && (forksAnyTurn(snapshot.summary?.family) || turnSeq === latestForkSeq(snapshot.rows)) ? turnSeq : undefined; + // Assistant bodies mark the turn's summary row in the rail. Prompts keep their own user row. + const markRowId = row.kind === "assistant" && turnSeq !== undefined ? `summary-${turnSeq}` : id; const target: MessageMenuTarget = - row.kind === "user" ? { rowId: id, text: row.text } : { rowId: id, text: plainText(row.text), markdown: row.text }; + row.kind === "user" + ? { rowId: id, text: row.text } + : { rowId: markRowId, text: plainText(row.text), markdown: row.text }; if (forkSeq !== undefined) target.forkSeq = forkSeq; if (forkSeq !== undefined && sideChat) target.sideChat = true; if (row.kind === "user" && row.failed) target.retryRowId = row.id; diff --git a/webviews/src/agent-session/acpmux/transcriptSelection.ts b/webviews/src/agent-session/acpmux/transcriptSelection.ts new file mode 100644 index 000000000000..0ca174f553f5 --- /dev/null +++ b/webviews/src/agent-session/acpmux/transcriptSelection.ts @@ -0,0 +1,16 @@ +import type { ComposerAttachment } from "./attachments"; + +/// A selected transcript span becomes one named context chip. Each invocation keeps its own id so +/// repeated selections remain available instead of replacing the previous chapter context. +export function transcriptSelectionAttachment(text: string): ComposerAttachment | undefined { + const value = text.trim(); + if (!value) return undefined; + return { + id: `transcript-selection-${crypto.randomUUID()}`, + kind: "text", + name: "Selection", + mimeType: "text/plain", + size: value.length, + text: value, + }; +} diff --git a/webviews/test/message-more-button.test.tsx b/webviews/test/message-more-button.test.tsx index d3ae20a9293d..7aeda7b61722 100644 --- a/webviews/test/message-more-button.test.tsx +++ b/webviews/test/message-more-button.test.tsx @@ -90,6 +90,11 @@ test("an answer's turn footer has More, which reports the answer as its right-cl expect(popUp).toBeDefined(); }); +test("an assistant body pins the turn summary row used by the rail", () => { + const target = messageMenuTarget({ ...snapshot, canFork: true } as unknown as AcpmuxSnapshot, answer.id); + expect(target?.rowId).toBe("summary-9"); +}); + test("a footer with no answer has no More", async () => { await act(async () => root.render()); expect(doc.querySelector('button[aria-label="More"]')).toBeNull(); diff --git a/webviews/test/transcript-selection.test.ts b/webviews/test/transcript-selection.test.ts new file mode 100644 index 000000000000..670c30201343 --- /dev/null +++ b/webviews/test/transcript-selection.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from "bun:test"; + +const { transcriptSelectionAttachment } = await import("../src/agent-session/acpmux/transcriptSelection"); + +test("each attached transcript selection gets a distinct named context id", () => { + const first = transcriptSelectionAttachment("one selected span"); + const second = transcriptSelectionAttachment("one selected span"); + expect(first).toMatchObject({ + kind: "text", + name: "Selection", + mimeType: "text/plain", + text: "one selected span", + }); + expect(second).toMatchObject({ text: "one selected span" }); + expect(first?.id).not.toBe(second?.id); + expect(transcriptSelectionAttachment(" ")).toBeUndefined(); +}); From 14fe5c0cfcae9ea34005d02c759358325e15fc5b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 22:35:51 -0700 Subject: [PATCH 05/11] fix(next): hide pin seam without a rail row Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../src/agent-session/acpmux/conversation/messageMenu.ts | 8 ++++++-- webviews/test/message-more-button.test.tsx | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts index 91dbf542c3ce..eb34259ae69f 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -139,11 +139,15 @@ export function messageMenuTarget( ? turnSeq : undefined; // Assistant bodies mark the turn's summary row in the rail. Prompts keep their own user row. - const markRowId = row.kind === "assistant" && turnSeq !== undefined ? `summary-${turnSeq}` : id; + const markRowId = row.kind === "user" ? id : turnSeq !== undefined ? `summary-${turnSeq}` : undefined; const target: MessageMenuTarget = row.kind === "user" ? { rowId: id, text: row.text } - : { rowId: markRowId, text: plainText(row.text), markdown: row.text }; + : { + ...(markRowId ? { rowId: markRowId } : {}), + text: plainText(row.text), + markdown: row.text, + }; if (forkSeq !== undefined) target.forkSeq = forkSeq; if (forkSeq !== undefined && sideChat) target.sideChat = true; if (row.kind === "user" && row.failed) target.retryRowId = row.id; diff --git a/webviews/test/message-more-button.test.tsx b/webviews/test/message-more-button.test.tsx index 7aeda7b61722..842bd5577cb0 100644 --- a/webviews/test/message-more-button.test.tsx +++ b/webviews/test/message-more-button.test.tsx @@ -95,6 +95,11 @@ test("an assistant body pins the turn summary row used by the rail", () => { expect(target?.rowId).toBe("summary-9"); }); +test("an assistant body without a completed summary has no pin target", () => { + const target = messageMenuTarget({ ...snapshot, isWorking: true } as unknown as AcpmuxSnapshot, answer.id); + expect(target?.rowId).toBeUndefined(); +}); + test("a footer with no answer has no More", async () => { await act(async () => root.render()); expect(doc.querySelector('button[aria-label="More"]')).toBeNull(); From 1615b84906cc18a1ec2c293971d47d894276b5ee Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 23:13:46 -0700 Subject: [PATCH 06/11] fix(next): guard transcript chapter pins by session Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../AgentPaneContextMenu.swift | 12 ++++--- .../AgentPaneView+ContextMenu.swift | 4 ++- .../AgentPaneMenuLookTests.swift | 14 ++++++-- webviews/src/agent-session/acpmux/App.tsx | 17 +++++++--- .../acpmux/conversation/messageMenu.ts | 34 +++++++++++++++---- webviews/test/message-more-button.test.tsx | 29 ++++++++++++++-- 6 files changed, 88 insertions(+), 22 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift index e6fe592d5b9f..f0e8d03097cf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift @@ -7,6 +7,8 @@ import CmuxNextDesign 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). @@ -28,9 +30,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, rowId: String? = nil, markdown: String? = nil, forkSeq: Int? = nil, sideChat: Bool = false, retryRowId: String? = nil, + init(text: String, rowId: String? = nil, sessionId: String? = nil, markdown: 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.forkSeq = forkSeq @@ -51,6 +54,7 @@ struct AgentPaneMessageTarget: Equatable, Sendable { 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"]) @@ -105,7 +109,7 @@ enum AgentPaneContextMenu { var pathItems: ((String) -> [NSMenuItem]?)? = nil var search: (String) -> Void = { _ in } /// Toggle the rail-owned bookmark for a transcript row. - var pinChapter: (String) -> Void = { _ in } + 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. @@ -234,10 +238,10 @@ enum AgentPaneContextMenu { if target.isPrompt { message.append(AgentPaneMenuAction.item(AgentPaneMenuStrings.editAndResend, symbol: "pencil") { actions.edit(target.text) }) } - if let rowId = target.rowId { + 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) + actions.pinChapter(rowId, sessionId) }) } if let seq = target.forkSeq { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift index ce5390d42588..c129fb1b9e3c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift @@ -213,7 +213,9 @@ extension AgentPaneView { linkItems: { LinkMenu().linkItems(for: $0) }, pathItems: { [weak self] path in self?.pathMenuItems(path) }, search: { [weak self] text in self?.model.onSearchWeb?(text) }, - pinChapter: { [weak self] rowId in self?.runMessageAction("chat.pinChapter", ["rowId": rowId]) }, + 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 ? { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift index b6adf5288f02..35a4c8c6344a 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift @@ -74,7 +74,7 @@ struct AgentPaneMenuLookTests { var page: [(String, [String: String])] = [] var quoted: [String] = [] var attached: [String] = [] - let target = AgentPaneMessageTarget(text: "Rendered body", rowId: "summary-4", markdown: "**Body**", forkSeq: 4) + let target = AgentPaneMessageTarget(text: "Rendered body", rowId: "summary-4", sessionId: "chat-1", markdown: "**Body**", forkSeq: 4) // Rebuild with the seam callbacks because the helper's defaults intentionally do nothing. let webKit = Self.webKitMenu(MenuLookWebKitTarget()) AgentPaneContextMenu.rebuild( @@ -89,7 +89,7 @@ struct AgentPaneMenuLookTests { quoteReply: { quoted.append($0) }, attachSelection: { attached.append($0) }, search: { page.append(("search", ["text": $0])) }, - pinChapter: { page.append(("pin", ["rowId": $0])) }, + pinChapter: { rowId, sessionId in page.append(("pin", ["rowId": rowId, "sessionId": sessionId])) }, ), ) let titles = webKit.items.map { $0.isSeparatorItem ? "-" : $0.title } @@ -117,7 +117,15 @@ struct AgentPaneMenuLookTests { try Self.click(search) #expect(quoted == ["> selected words\n\n"]) #expect(attached == ["selected words"]) - #expect(page == [("pin", ["rowId": "summary-4"]), ("search", ["text": "selected words"])]) + #expect(page == [("pin", ["rowId": "summary-4", "sessionId": "chat-1"]), ("search", ["text": "selected words"])]) + } + + @Test func reportCarriesTheOwningSessionForStaleMenuGuards() throws { + let target = try #require( + AgentPaneMessageTarget(report: ["text": "Reply", "rowId": "summary-4", "sessionId": "chat-1"]), + ) + #expect(target.rowId == "summary-4") + #expect(target.sessionId == "chat-1") } /// Fork from Here's neighbour Open in Side Chat on a prompt runs the page's side chat action. diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 121e52999e94..733b2b0294a8 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -331,6 +331,7 @@ import { forksAnyTurn, latestForkSeq, messageMenuTarget, + messageMenuSessionMatches, openReportedImage, setMessageMenuSource, } from "./conversation/messageMenu"; @@ -7841,12 +7842,20 @@ function AcpmuxPane() { }, // Pin as chapter is the native menu's named rail action. Marking true is idempotent: // the bookmark transport publishes the resulting tick to every rail on this session. - "chat.pinChapter": async ({ rowId }) => { + "chat.pinChapter": async ({ rowId, sessionId }) => { const match = /^(?:user|summary)-(\d+)$/.exec(String(rowId ?? "")); const seq = match ? Number(match[1]) : NaN; - const sessionId = client.selectedSession; - if (!sessionId || !Number.isSafeInteger(seq) || seq <= 0) return; - await client.setBookmark(sessionId, seq, true); + const requestedSessionId = typeof sessionId === "string" ? sessionId : ""; + // The native menu can outlive the transcript that opened it. Require both the client + // selection and the drawn snapshot to still name the target session before touching + // the rail; a stale menu after switching chats must be a no-op. + if ( + !messageMenuSessionMatches(requestedSessionId, client.selectedSession, snapshotRef.current?.sessionId) || + !Number.isSafeInteger(seq) || + seq <= 0 + ) + return; + await client.setBookmark(requestedSessionId, seq, true); }, "chat.history": () => client.loadOlder(), // Find in Chat (the app's Find commands on an agent pane). diff --git a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts index eb34259ae69f..3ded8717bea0 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -16,6 +16,8 @@ export const MESSAGE_MENU_HANDLER = "cmuxAgentContextMenu"; export type MessageMenuTarget = { /// The transcript row id, used by the native menu's Pin as chapter seam. rowId?: string; + /// The session that owns the transcript row. Native actions reject a stale menu after a chat switch. + sessionId?: string; text: string; markdown?: string; forkSeq?: number; @@ -41,6 +43,18 @@ export type MessageMenuReport = Partial & { popUp?: { left: number; top: number; right: number; bottom: number }; }; +/// A native menu action may run after its page report. Require the selected and drawn sessions to +/// still agree with that report before applying an action to a transcript row. +export function messageMenuSessionMatches( + requestedSessionId: string, + selectedSessionId: string | undefined, + drawnSessionId: string | undefined, +): boolean { + return ( + requestedSessionId.length > 0 && requestedSessionId === selectedSessionId && requestedSessionId === drawnSessionId + ); +} + /// Whether acpmux can fork the chat at all here: it serves forks, the pane is connected and the /// chat is on this computer (acpmux refuses the rest). const forkServed = (snapshot: AcpmuxSnapshot) => @@ -131,20 +145,26 @@ export function messageMenuTarget( const id = rowId.endsWith(":fold") ? rowId.slice(0, -":fold".length) : rowId; const row = snapshot.rows.find((candidate) => candidate.id === id); if (!row?.text || (row.kind !== "user" && row.kind !== "assistant")) return undefined; - const turnSeq = canFork(snapshot) - ? turnRows(snapshot.rows, id).find((candidate) => candidate.kind === "turnSummary")?.seq - : undefined; + // Pinning remains valid for an older completed turn while a later one works. Fork eligibility + // is a separate concern: a working chat cannot offer Fork from here, but its completed summary + // still owns the rail chapter marker shown by PinButton. + const completedTurnSeq = turnRows(snapshot.rows, id).find((candidate) => candidate.kind === "turnSummary")?.seq; const forkSeq = - turnSeq !== undefined && (forksAnyTurn(snapshot.summary?.family) || turnSeq === latestForkSeq(snapshot.rows)) - ? turnSeq + canFork(snapshot) && + completedTurnSeq !== undefined && + (forksAnyTurn(snapshot.summary?.family) || completedTurnSeq === latestForkSeq(snapshot.rows)) + ? completedTurnSeq : undefined; // Assistant bodies mark the turn's summary row in the rail. Prompts keep their own user row. - const markRowId = row.kind === "user" ? id : turnSeq !== undefined ? `summary-${turnSeq}` : undefined; + const markRowId = + row.kind === "user" ? id : completedTurnSeq !== undefined ? `summary-${completedTurnSeq}` : undefined; + const sessionId = snapshot.sessionId ?? snapshot.summary?.sessionId; const target: MessageMenuTarget = row.kind === "user" - ? { rowId: id, text: row.text } + ? { rowId: id, ...(sessionId ? { sessionId } : {}), text: row.text } : { ...(markRowId ? { rowId: markRowId } : {}), + ...(sessionId ? { sessionId } : {}), text: plainText(row.text), markdown: row.text, }; diff --git a/webviews/test/message-more-button.test.tsx b/webviews/test/message-more-button.test.tsx index 842bd5577cb0..ee14cf2aaafb 100644 --- a/webviews/test/message-more-button.test.tsx +++ b/webviews/test/message-more-button.test.tsx @@ -37,13 +37,17 @@ const { act } = await import("react"); const { createRoot } = await import("react-dom/client"); const { UserBubble } = await import("../src/agent-session/acpmux/conversation/UserBubble"); const { TurnFooter } = await import("../src/agent-session/acpmux/conversation/TurnRows"); -const { installMessageMenuReporter, messageMenuTarget, setMessageMenuSource } = +const { installMessageMenuReporter, messageMenuSessionMatches, messageMenuTarget, setMessageMenuSource } = await import("../src/agent-session/acpmux/conversation/messageMenu"); const prompt: AcpmuxRow = { id: "prompt", version: 1, at: 1, kind: "user", text: "Make the chart" }; const answer: AcpmuxRow = { id: "answer", version: 1, at: 2, kind: "assistant", text: "Here is **the chart**." }; const summary: AcpmuxRow = { id: "summary-9", version: 1, at: 3, kind: "turnSummary", seq: 9, durationMs: 1_000 }; -const snapshot = { rows: [prompt, answer, summary], connection: "connected" } as unknown as AcpmuxSnapshot; +const snapshot = { + rows: [prompt, answer, summary], + connection: "connected", + summary: { sessionId: "chat-a" }, +} as unknown as AcpmuxSnapshot; // The footer as the transcript draws it (turns.ts): the answer's text, and the answer's row. const footer: AcpmuxRow = { ...summary, text: answer.text, answerId: answer.id }; @@ -93,13 +97,32 @@ test("an answer's turn footer has More, which reports the answer as its right-cl test("an assistant body pins the turn summary row used by the rail", () => { const target = messageMenuTarget({ ...snapshot, canFork: true } as unknown as AcpmuxSnapshot, answer.id); expect(target?.rowId).toBe("summary-9"); + expect(target?.sessionId).toBe("chat-a"); +}); + +test("an older completed assistant body keeps its pin target while a later turn works", () => { + const target = messageMenuTarget( + { ...snapshot, canFork: false, isWorking: true } as unknown as AcpmuxSnapshot, + answer.id, + ); + expect(target?.rowId).toBe("summary-9"); + expect(target?.forkSeq).toBeUndefined(); }); test("an assistant body without a completed summary has no pin target", () => { - const target = messageMenuTarget({ ...snapshot, isWorking: true } as unknown as AcpmuxSnapshot, answer.id); + const target = messageMenuTarget( + { ...snapshot, rows: [prompt, answer], canFork: false, isWorking: true } as unknown as AcpmuxSnapshot, + answer.id, + ); expect(target?.rowId).toBeUndefined(); }); +test("a pin menu becomes a no-op when the chat changes before its action runs", () => { + expect(messageMenuSessionMatches("chat-a", "chat-a", "chat-a")).toBe(true); + expect(messageMenuSessionMatches("chat-a", "chat-b", "chat-b")).toBe(false); + expect(messageMenuSessionMatches("chat-a", "chat-a", "chat-b")).toBe(false); +}); + test("a footer with no answer has no More", async () => { await act(async () => root.render()); expect(doc.querySelector('button[aria-label="More"]')).toBeNull(); From 047aa3bf28a94f284d3b0f38c917f8b5abe1e9b3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 00:09:41 -0700 Subject: [PATCH 07/11] fix(next): localize transcript menu labels Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../Resources/Localizable.xcstrings | 431 +++++++++++++++++- 1 file changed, 412 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index a08fd4763958..55606eab7eda 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -23077,25 +23077,25 @@ "localizations": { "ar": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "نسخ الرسالة" } }, "bs": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Kopiraj poruku" } }, "da": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Kopiér besked" } }, "de": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Nachricht kopieren" } }, @@ -23107,19 +23107,19 @@ }, "es": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Copiar mensaje" } }, "fr": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Copier le message" } }, "it": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Copia messaggio" } }, @@ -23131,73 +23131,73 @@ }, "km": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "ចម្លងសារ" } }, "ko": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "메시지 복사" } }, "nb": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Kopier melding" } }, "pl": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Kopiuj wiadomość" } }, "pt-BR": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Copiar mensagem" } }, "ru": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Скопировать сообщение" } }, "th": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "คัดลอกข้อความ" } }, "tr": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Mesajı Kopyala" } }, "uk": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Копіювати повідомлення" } }, "vi": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "Sao chép tin nhắn" } }, "zh-Hans": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "拷贝信息" } }, "zh-Hant": { "stringUnit": { - "state": "needs_review", + "state": "translated", "value": "拷貝訊息" } } @@ -33551,6 +33551,399 @@ } } } + }, + "agentPane.menu.attachSelectionAsContext": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Attach selection as context" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إرفاق التحديد كسياق" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Priloži odabir kao kontekst" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Vedhæft markeringen som kontekst" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Auswahl als Kontext anhängen" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Adjuntar selección como contexto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Joindre la sélection comme contexte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Allega selezione come contesto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択範囲をコンテキストとして添付" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ភ្ជាប់ជម្រើសជាបរិបទ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택 항목을 컨텍스트로 첨부" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Legg ved utvalget som kontekst" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Dołącz zaznaczenie jako kontekst" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Anexar seleção como contexto" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Прикрепить выделение как контекст" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แนบส่วนที่เลือกเป็นบริบท" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Seçimi Bağlam Olarak Ekle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати виділене як контекст" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Đính kèm phần chọn làm ngữ cảnh" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "将所选内容作为上下文附加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "將選取內容作為上下文附加" + } + } + } + }, + "agentPane.menu.copyMessageAsMarkdown": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Copy message as Markdown" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نسخ الرسالة بتنسيق Markdown" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kopiraj poruku kao Markdown" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kopiér besked som Markdown" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nachricht als Markdown kopieren" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Copiar mensaje como Markdown" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Copier le message en Markdown" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Copia messaggio come Markdown" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "メッセージをMarkdownとしてコピー" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ចម្លងសារជា Markdown" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "메시지를 Markdown으로 복사" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kopier melding som Markdown" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kopiuj wiadomość jako Markdown" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Copiar mensagem como Markdown" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скопировать сообщение как Markdown" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คัดลอกข้อความเป็น Markdown" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Mesajı Markdown Olarak Kopyala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Копіювати повідомлення як Markdown" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Sao chép tin nhắn dưới dạng Markdown" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拷贝信息为 Markdown" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拷貝訊息為 Markdown" + } + } + } + }, + "agentPane.menu.pinAsChapter": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pin as chapter" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت كفصل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prikači kao poglavlje" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør som kapitel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Kapitel anheften" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Fijar como capítulo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Épingler comme chapitre" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Fissa come capitolo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "章としてピン留め" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បិទជំពូក" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "챕터로 고정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest som kapittel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przypnij jako rozdział" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar como capítulo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить как главу" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดเป็นบท" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bölüm Olarak Sabitle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Закріпити як розділ" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Ghim làm chương" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "固定为章节" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "固定為章節" + } + } + } } }, "version": "1.0" From 2ad2531ff87fb356fab4c21e9248f8e644418310 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 00:19:33 -0700 Subject: [PATCH 08/11] fix(next): correct Khmer chapter pin label Agent-Session: https://cmux.com/deeplink/agent?id=aid-e350b7ac8a53311e26ee --- .../Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 55606eab7eda..6026cd4242e1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -33874,7 +33874,7 @@ "km": { "stringUnit": { "state": "translated", - "value": "បិទជំពូក" + "value": "ខ្ទាស់ជាជំពូក" } }, "ko": { From f98d414456bdb89bf4bfcff1f4afd850fffbe6f5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 20:08:00 -0700 Subject: [PATCH 09/11] test(next): cover sent prompt Markdown beyond quotes Agent-Session: https://cmux.com/deeplink/agent?id=aid-85b82483735c2078a118 --- webviews/src/agent-session/acpmux/App.tsx | 2 +- .../acpmux/conversation/UserBubble.gallery.ts | 24 ++++ webviews/test/user-prompt-collapse.test.tsx | 48 +++++++ webviews/test/user-prompt-markdown.test.ts | 120 ++++++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 webviews/test/user-prompt-markdown.test.ts diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 733b2b0294a8..9ed0e5847418 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -358,7 +358,7 @@ import { NeedsYouPill, type TranscriptNeedsYou } from "./needsYou/JumpPill"; import { useNeedsYou } from "./needsYou/useNeedsYou"; import { copyText, copyTextResult } from "./conversation/clipboard"; import { lastCodeBlock } from "./conversation/lastCodeBlock"; -import { hasTranscriptCopyText, transcriptRangeText } from "./conversation/transcriptCopy"; +import { transcriptRangeText } from "./conversation/transcriptCopy"; import { transcriptSelectionAttachment } from "./transcriptSelection"; import { rovingTabStopProps } from "../../ui/listRowKeyboard"; import { chatImages, type ChatImage } from "./conversation/chatImages"; diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts index 996f8672a64a..23119c900de2 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.gallery.ts @@ -228,6 +228,30 @@ export default agentPaneEntry({ ]), ), }, + "markdown-parity": { + note: "Sent prompts render quotes, lists, inline code, fenced code and links with the transcript renderer. The second prompt has no quote marker.", + snapshot: claude( + chat([ + user( + "> The uploader retries forever.\n> It should stop after three attempts.\n\nKeep that limit visible.", + 9, + { + id: "markdown-quote", + seq: 1, + }, + ), + assistant("The quote remains distinct from your reply.", 8.5), + summary(8.5, { seq: 3, status: "completed" }), + user( + "1. Keep `retry_count` bounded.\n2. Report **exhausted** attempts.\n\n```text\nretry_count = 3\n```\n\n[Read the retry guide](https://example.com/retry)", + 6, + { id: "markdown-list", seq: 4 }, + ), + assistant("The list, code and link remain formatted after sending.", 5), + summary(5, { seq: 6, status: "completed" }), + ]), + ), + }, "pasted-chip": { note: "A pasted 2,300-line stack trace is a chip under the typed question: its first words and line count.", snapshot: pastedPrompt(), diff --git a/webviews/test/user-prompt-collapse.test.tsx b/webviews/test/user-prompt-collapse.test.tsx index 3afac951e49f..715ef0ccd2f7 100644 --- a/webviews/test/user-prompt-collapse.test.tsx +++ b/webviews/test/user-prompt-collapse.test.tsx @@ -54,6 +54,7 @@ Object.assign(scope, replacements); const { act } = await import("react"); const { createRoot } = await import("react-dom/client"); const { UserBubble } = await import("../src/agent-session/acpmux/conversation/UserBubble"); +const { TurnActionsContext } = await import("../src/agent-session/acpmux/conversation/turnActions"); const { FindReveal } = await import("../src/agent-session/acpmux/conversation/chatFind"); const { resetTranscriptDisclosure } = await import("../src/agent-session/acpmux/conversation/transcriptDisclosure"); const { pasteRanges, COLLAPSED_PROMPT_LINES } = await import("../src/agent-session/acpmux/conversation/promptCollapse"); @@ -168,6 +169,53 @@ try { expect(host.querySelector(".cv-user__markdown > .cv-p")?.textContent).toBe("Add a bounded backoff."); }); + test("a standalone Markdown list renders code and links while Copy keeps its original source", async () => { + resetTranscriptDisclosure(); + const text = "1. Keep `retry_count` bounded.\n2. Read the [retry guide](https://example.com/retry)."; + remount(prompt("user-markdown-list", text)); + expect(body().querySelectorAll("ol > li").length).toBe(2); + expect(body().querySelector("code.cv-code")?.textContent).toBe("retry_count"); + expect(body().querySelector("a")?.getAttribute("href")).toBe("https://example.com/retry"); + copied.length = 0; + await act(async () => { + host.querySelector(COPY)!.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + expect(copied).toEqual([text]); + }); + + test("editing a rendered prompt opens and resends the original Markdown source", async () => { + const text = "> Keep the source.\n\n1. Use `retry_count`.\n2. Read [the guide](https://example.com/retry)."; + const row = prompt("user-markdown-edit", text); + const sent: string[] = []; + act(() => + root.render( + { + sent.push(value); + return true; + }, + }, + }} + > + + , + ), + ); + click(host.querySelector(`button[aria-label="${translate("message.edit")}"]`)!); + expect(host.querySelector("textarea")?.value).toBe(text); + await act(async () => { + host + .querySelector(`button[aria-label="${translate("message.editSend")}"]`)! + .dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + }); + expect(sent).toEqual([text]); + }); + // Leo 2026-10-21: prompts drew as empty grey bubbles. A prompt with no text to read draws no // bubble; one of images alone is its images (agent-pane-blank-prompt.test.ts covers the ingest). test("a prompt of images alone draws its images and no empty bubble", () => { diff --git a/webviews/test/user-prompt-markdown.test.ts b/webviews/test/user-prompt-markdown.test.ts new file mode 100644 index 000000000000..df02c4238681 --- /dev/null +++ b/webviews/test/user-prompt-markdown.test.ts @@ -0,0 +1,120 @@ +// cmux-web-test-timeout: 180 +// l10n-allow-file: privacy-safe synthetic prompts, never private session content. +// Leo: "where is the rendering for this :(" — his own sent quote showed raw > lines. +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { chromium, type Browser } from "playwright"; +import { galleryFrame, warmGalleryFrame } from "./support/galleryFrame"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { installPaintedFrames } from "./support/paintedFrames"; + +await requireBrowserLane("user-prompt-markdown.test.ts", async () => { + setDefaultTimeout(120_000); + const { createServer } = await import("vite"); + let vite: Awaited>; + let browser: Browser; + const entry = "agent-pane.user-bubble"; + const variant = "markdown-parity"; + // Paired evidence only: replay the same fixture with the base PromptText source installed. + const plain = process.env.CMUX_USER_MARKDOWN_EXPECT === "plain"; + const capture = process.env.CMUX_USER_MARKDOWN_CAPTURE; + beforeAll(async () => { + vite = await createServer({ + configFile: path.resolve(import.meta.dir, "../vite.config.gallery-dev.ts"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + browser = await chromium.launch({ headless: true }); + await warmGalleryFrame(vite, entry, { browser, params: { variant } }); + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + }); + + for (const colorScheme of ["dark", "light"]) { + test(`${colorScheme}: a sent quote and standalone list keep Markdown semantics`, async () => { + const page = await browser.newPage({ viewport: { width: 900, height: 1000 } }); + try { + await installPaintedFrames(page); + const port = (vite.httpServer!.address() as { port: number }).port; + await page.goto( + `http://127.0.0.1:${port}${galleryFrame(entry, { + variant, + frame: "component", + width: 900, + height: 1000, + colorScheme, + })}`, + ); + await page.waitForFunction(() => document.documentElement.dataset.galleryReady === "1"); + const quote = page.locator('[data-row-id="markdown-quote"] .cv-user__bubble'); + const list = page.locator('[data-row-id="markdown-list"] .cv-user__bubble'); + await quote.waitFor(); + await list.waitFor(); + // Rendered blocks can be taller than ten text lines; reveal the existing disclosure. + const more = list.locator('.cv-user__more[aria-expanded="false"]'); + if (await more.count()) await more.click(); + await page.evaluate(async () => { + await document.fonts.ready; + await window.cmuxPaintFrame(); + await window.cmuxPaintFrame(); + }); + expect(await quote.textContent()).toContain("The uploader retries forever."); + expect(await list.textContent()).toContain("retry_count"); + const chrome = await quote.evaluate((node) => { + const style = getComputedStyle(node); + return { + padding: style.padding, + radius: style.borderRadius, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + color: style.color, + tint: style.getPropertyValue("--glass-tint").trim(), + width: node.getBoundingClientRect().width, + }; + }); + expect(chrome.padding).toBe("8px 12px"); + expect(chrome.radius).toBe("10px"); + expect(chrome.width).toBeLessThanOrEqual(630); + if (plain) { + expect(await quote.locator("blockquote").count()).toBe(0); + expect(await quote.textContent()).toContain("> The uploader"); + expect(await list.locator("ol").count()).toBe(0); + } else { + expect(await quote.locator("blockquote").count()).toBe(1); + expect(await quote.textContent()).not.toContain("> The uploader"); + expect(await list.locator("ol > li").count()).toBe(2); + expect(await list.locator("code.cv-code").textContent()).toBe("retry_count"); + expect(await list.locator("strong").textContent()).toBe("exhausted"); + expect(await list.locator(".cv-codeblock").count()).toBe(1); + expect(await list.locator('a[href="https://example.com/retry"]').textContent()).toContain("retry guide"); + expect(await list.textContent()).not.toContain("```text"); + const bar = await quote.locator("blockquote").evaluate((node) => { + const style = getComputedStyle(node, "::before"); + return { width: parseFloat(style.width), background: style.backgroundColor }; + }); + expect(bar.width).toBeGreaterThan(0); + expect(bar.background).not.toBe("rgba(0, 0, 0, 0)"); + } + console.log(`USER_MARKDOWN_CHROME ${JSON.stringify({ colorScheme, plain, ...chrome })}`); + if (capture) { + mkdirSync(capture, { recursive: true }); + const name = `${plain ? "before" : "after"}-${colorScheme}.png`; + const png = await page.screenshot(); + writeFileSync(path.join(capture, name), png); + console.log(`CMUX_SCREENSHOT_SHA256 ${name} ${createHash("sha256").update(png).digest("hex")}`); + const encoded = png.toString("base64"); + for (let at = 0; at < encoded.length; at += 12_000) + console.log(`CMUX_SCREENSHOT ${name} ${at} ${encoded.slice(at, at + 12_000)}`); + console.log(`CMUX_SCREENSHOT_END ${name} ${encoded.length}`); + } + } finally { + await page.close(); + } + }); + } +}); From 8e0c5dda55f4d5d0821cf21a4b520c2397145d00 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Wed, 28 Oct 2026 20:15:10 -0700 Subject: [PATCH 10/11] fix(next): render Markdown in sent user prompts Agent-Session: https://cmux.com/deeplink/agent?id=aid-85b82483735c2078a118 --- .../src/agent-session/acpmux/conversation/PromptText.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/conversation/PromptText.tsx b/webviews/src/agent-session/acpmux/conversation/PromptText.tsx index 6516bae88316..20b9d86b02e2 100644 --- a/webviews/src/agent-session/acpmux/conversation/PromptText.tsx +++ b/webviews/src/agent-session/acpmux/conversation/PromptText.tsx @@ -21,6 +21,10 @@ import { foldMotion, foldMs, markHeightFold } from "./fold"; import { holdHeader } from "./useAnchoredCollapse"; import { Markdown } from "./Markdown"; +// Ordinary text keeps its literal whitespace and cheap span. Anything that can contain Markdown +// uses the shared transcript parser, including a list, code or link with no quote in the prompt. +const PROMPT_MARKDOWN = /[`*_~[\]$<>|#\\]|https?:\/\/|(^|\n)[ \t]*(?:[-+]|\d+[.)])[ \t]/; + export function PromptText({ rowId, text, @@ -155,7 +159,7 @@ export function PromptText({
{parts.map((part, index) => part.kind === "text" ? ( - /(^|\n)\s*>/.test(part.text) ? ( + PROMPT_MARKDOWN.test(part.text) ? ( {show(part.text)} From 913cad4e5fc8b43bc78f3177605fd2a5512428c1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 2 Nov 2026 23:27:27 +0000 Subject: [PATCH 11/11] test(next): compare menu page actions field by field Arrays of tuples are not Equatable, so compare the action names and payloads as separate arrays. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-159afad10a5ab45ea16c --- .../Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift index 74ac4698c909..0711ca4d008c 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift @@ -118,7 +118,8 @@ struct AgentPaneMenuLookTests { try Self.click(search) #expect(quoted == ["> selected words\n\n"]) #expect(attached == ["selected words"]) - #expect(page == [("pin", ["rowId": "summary-4", "sessionId": "chat-1"]), ("search", ["text": "selected words"])]) + #expect(page.map { $0.0 } == ["pin", "search"]) + #expect(page.map { $0.1 } == [["rowId": "summary-4", "sessionId": "chat-1"], ["text": "selected words"]]) } @Test func reportCarriesTheOwningSessionForStaleMenuGuards() throws {