diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift index 6fb8b7358297..a496a7355a7d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift @@ -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). @@ -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 @@ -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"]) @@ -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. @@ -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 @@ -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. @@ -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", [:]) }) @@ -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`). @@ -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: "") @@ -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) } @@ -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 { @@ -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) } @@ -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) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift index 3d60b8feb2ee..9da15c912532 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift @@ -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) }, @@ -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 ? { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings index 1f9d6b276b44..05b7efce8ee5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/Resources/Localizable.xcstrings @@ -26616,49 +26616,49 @@ "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" } }, "en": { "stringUnit": { "state": "translated", - "value": "Copy Message" + "value": "Copy message" } }, "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" } }, @@ -26670,73 +26670,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": "拷貝訊息" } } @@ -38400,6 +38400,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" diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift index fa5119e38a2d..5973f39559cb 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneMenuLookTests.swift @@ -80,14 +80,76 @@ 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", 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( + 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: { rowId, sessionId in page.append(("pin", ["rowId": rowId, "sessionId": sessionId])) }, + ), + ) + let titles = webKit.items.map { $0.isSeparatorItem ? "-" : $0.title } + #expect(titles == [ + "Copy", + AgentPaneMenuStrings.copyMessage, + AgentPaneMenuStrings.copyMessageAsMarkdown, + "-", + AgentPaneMenuStrings.quoteInReply, + AgentPaneMenuStrings.attachSelectionAsContext, + "-", + AgentPaneMenuStrings.pinAsChapter, + AgentPaneMenuStrings.forkFromHere, + AgentForkMenu.title, + "-", + 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.map { $0.0 } == ["pin", "search"]) + #expect(page.map { $0.1 } == [["rowId": "summary-4", "sessionId": "chat-1"], ["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 77eaa2e04bdf..17823a93bb70 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -512,6 +512,7 @@ import { forksAnyTurn, latestForkSeq, messageMenuTarget, + messageMenuSessionMatches, openReportedImage, setMessageMenuSource, } from "./conversation/messageMenu"; @@ -549,6 +550,7 @@ import { useNeedsYou } from "./needsYou/useNeedsYou"; import { copyText, copyTextResult } from "./conversation/clipboard"; import { lastCodeBlock } from "./conversation/lastCodeBlock"; import { transcriptRangeText } from "./conversation/transcriptCopy"; +import { transcriptSelectionAttachment } from "./transcriptSelection"; import { rovingTabStopProps } from "../../ui/listRowKeyboard"; import type { ChatImage } from "./conversation/chatImages"; import { viewerImages } from "./summary/chatGallery"; @@ -11940,6 +11942,31 @@ function AcpmuxPane({ initialChat }: { initialChat?: InitialChat }) { "chat.quoteReply": async ({ markdown }) => { quoteIntoComposer(composerHandle, heldQuotes, String(markdown ?? "")); }, + // 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 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, sessionId }) => { + const match = /^(?:user|summary)-(\d+)$/.exec(String(rowId ?? "")); + const seq = match ? Number(match[1]) : NaN; + 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). "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 71d0f6db4e7e..cb9ca10c901f 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -18,6 +18,10 @@ import { replyMenuTime } from "./relativeTime"; 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; /// A reply's localized date and time, shown as its menu's non-interactive header. @@ -47,6 +51,18 @@ export type MessageMenuReport = Partial & { popupId?: string; }; +/// 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) => @@ -137,18 +153,27 @@ 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 : completedTurnSeq !== undefined ? `summary-${completedTurnSeq}` : undefined; + const sessionId = snapshot.sessionId ?? snapshot.summary?.sessionId; const target: MessageMenuTarget = // A prompt copies as its bubble reads: a Codex skill `/name`, not `/$name` as sent. row.kind === "user" - ? { text: withoutSkillDollars(row.text, snapshot.commands) } + ? { rowId: id, ...(sessionId ? { sessionId } : {}), text: withoutSkillDollars(row.text, snapshot.commands) } : { + ...(markRowId ? { rowId: markRowId } : {}), + ...(sessionId ? { sessionId } : {}), text: plainText(row.text), markdown: row.text, replyTime: replyMenuTime(row.at, Date.now(), currentLanguage()), 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 083004312af3..55767434309e 100644 --- a/webviews/test/message-more-button.test.tsx +++ b/webviews/test/message-more-button.test.tsx @@ -37,13 +37,23 @@ 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, openForkMenu, openMessageMenu, setMessageMenuSource } = - await import("../src/agent-session/acpmux/conversation/messageMenu"); +const { + installMessageMenuReporter, + messageMenuSessionMatches, + messageMenuTarget, + openForkMenu, + openMessageMenu, + 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 }; @@ -151,6 +161,35 @@ test("reporter teardown clears the open marker", async () => { removeReporter = installMessageMenuReporter(doc, handler); }); +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, 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(); 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(); +});