From 01cb02597d89daa05e9f71fe55c6fa9daa31ba89 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 20:23:53 -0700 Subject: [PATCH 01/13] test: require a shared one-step fork action Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../AgentPaneForkOwnTabTests.swift | 8 ++++---- .../ForkConversationTests.swift | 20 +++++++++++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift index da19b4673b9f..c63165f01c56 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift @@ -35,10 +35,10 @@ struct AgentPaneForkOwnTabTests { /// The palette's Fork Conversation step and the chat's right-click Fork name a destination /// (`placement`); the host opens the fork there. No placement (the chat page's own Fork, an - /// older page) is a tab, as before; an unknown one is a tab too, never a refusal. + /// older page) uses the default destination; an unknown one uses the same default. @Test(arguments: [ ("right", AgentForkDestination.right), ("left", .left), ("up", .up), ("down", .down), - ("tab", .tab), ("workspace", .workspace), ("window", .window), ("sideways", .tab), + ("tab", .tab), ("workspace", .workspace), ("window", .window), ("sideways", .default), ]) func aForkOpensAtTheDestinationThePageNames(placement: String, destination: AgentForkDestination) async { let opened = Opened() @@ -49,12 +49,12 @@ struct AgentPaneForkOwnTabTests { #expect(opened.destinations == [destination]) } - @Test func aForkWithoutAPlacementOpensInANewTab() async { + @Test func aForkWithoutAPlacementUsesTheSharedDefault() async { let opened = Opened() let model = Self.pane(opened: opened) model.transport.sessions.add("fork") _ = await model.respond(to: Self.openTab("fork")) - #expect(opened.destinations == [.tab]) + #expect(opened.destinations == [.default]) } @Test func aForkOpensInItsOwnTabAndTheChatKeepsItsSession() async { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ForkConversationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ForkConversationTests.swift index 2a3e63689817..5a35aeae3ac9 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ForkConversationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/ForkConversationTests.swift @@ -27,7 +27,7 @@ struct ForkConversationTests { @Test func everyDestinationIsOfferedInAnAgentChatAndATerminal() { let services = Self.services() AppActions.bind(services) - for (id, _) in Self.destinations { + for (id, _) in [("palette.forkAgentConversation", AgentForkDestination.default)] + Self.destinations { #expect(services.registry.unavailableReason(for: id) == nil, "\(id)") #expect(services.registry.isAvailable(id, in: [.agentPaneFocused]), "\(id) in an agent chat") #expect(services.registry.isAvailable(id, in: [.terminalFocused]), "\(id) in a terminal") @@ -35,10 +35,22 @@ struct ForkConversationTests { } /// The palette's step lists every destination, the default (right) first. - @Test func thePaletteStepListsEveryDestinationDefaultFirst() throws { + @Test func thePaletteFamilyRunsTheDefaultAndKeepsExplicitDestinations() throws { let family = try #require(ActionDescriptor.paletteFamilies.first { $0.id == "forkConversation" }) #expect(family.members == Self.destinations.map(\.0)) - #expect(AgentHandlers.destination(of: family.members[0]) == AgentForkDestination.default) + #expect(family.parent == "palette.forkAgentConversation") + #expect(AgentHandlers.destination(of: try #require(family.parent)) == AgentForkDestination.default) + } + + @Test func emptySpaceOffersImmediateForkAndSeparateDestinationChoices() throws { + let services = Self.services() + AppActions.bind(services) + let menu = services.registry.makeContextMenu(for: .agentChat, implied: [.agentPaneFocused]) + let title = try #require(ActionCatalog.all.first { $0.id == "palette.forkAgentConversation" }?.title) + let primary = try #require(menu.items.first { $0.title == title }) + #expect(primary.submenu == nil) + let destinations = try #require(menu.items.first { $0.submenu?.items.count == 7 }?.submenu) + #expect(destinations.items.count == Self.destinations.count) } @Test func eachActionNamesItsDestination() { @@ -49,7 +61,7 @@ struct ForkConversationTests { /// The default destination has a shortcut, shown in Settings and the palette. @Test func theDefaultForkHasAShortcut() throws { - let descriptor = try #require(ActionCatalog.all.first { $0.id == "palette.forkAgentConversationRight" }) + let descriptor = try #require(ActionCatalog.all.first { $0.id == "palette.forkAgentConversation" }) #expect(descriptor.defaultShortcut != nil) } From b672cf495a3f78d82b5c8ef7952cad1881033430 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 20:25:07 -0700 Subject: [PATCH 02/13] Make conversation forks immediate with optional destinations Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNextActions/ActionCatalog.xcstrings | 118 +++++++++++++++ .../ActionDescriptor+PaletteRank.swift | 3 +- .../ActionSurfaceCatalog+Exemptions.swift | 2 +- .../ActionSurfaceCatalog+MenusContent.swift | 16 ++- .../Catalog/AgentActionCatalog.swift | 15 +- .../AgentForkDestination.swift | 5 +- .../CmuxNextAgentPane/AgentForkMenu.swift | 34 +++++ .../AgentPaneContextMenu.swift | 5 + .../AgentPaneView+ContextMenu.swift | 30 +++- .../AgentPaneView+Fork.swift | 19 ++- .../CmuxNextApp/Handlers/AgentHandlers.swift | 18 ++- .../AgentForkMenuTests.swift | 25 ++++ .../next/highlights/one-step-fork.md | 4 + webviews/src/agent-session/acpmux/App.tsx | 67 ++++----- .../acpmux/Localizable.xcstrings | 134 +----------------- .../acpmux/conversation/MessageActions.tsx | 9 +- .../acpmux/conversation/TurnRows.tsx | 2 +- .../acpmux/conversation/UserBubble.tsx | 5 +- .../acpmux/conversation/messageMenu.ts | 8 ++ .../acpmux/conversation/openFork.test.ts | 75 ++++++++++ .../acpmux/conversation/openFork.ts | 22 +++ .../acpmux/header/ChatHeaderTools.gallery.tsx | 12 +- .../acpmux/header/ChatHeaderTools.tsx | 4 +- .../acpmux/header/forkMenu.test.ts | 28 ++++ .../agent-session/acpmux/header/forkMenu.ts | 31 ++++ webviews/src/ui/Menu.tsx | 8 +- webviews/test/user-message-actions.test.tsx | 22 +++ 27 files changed, 509 insertions(+), 212 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkMenu.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentForkMenuTests.swift create mode 100644 release-notes/next/highlights/one-step-fork.md create mode 100644 webviews/src/agent-session/acpmux/conversation/openFork.test.ts create mode 100644 webviews/src/agent-session/acpmux/conversation/openFork.ts create mode 100644 webviews/src/agent-session/acpmux/header/forkMenu.test.ts create mode 100644 webviews/src/agent-session/acpmux/header/forkMenu.ts diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings index 06a0167b08fc..0db53481be2c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionCatalog.xcstrings @@ -1,6 +1,124 @@ { "sourceLanguage": "en", "strings": { + "action.palette.forkAgentConversationTo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fork to…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنشاء فرع في…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verzweigen nach…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créer une branche dans…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crear rama en…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "分岐先…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음 위치로 분기…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分支到…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "分支至…" + } + } + } + }, + "action.palette.forkAgentConversation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Fork Conversation" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنشاء فرع للمحادثة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unterhaltung verzweigen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créer une branche de la conversation" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crear rama de la conversación" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "会話を分岐" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "대화 분기" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建对话分支" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立對話分支" + } + } + } + }, "action.cloudOpenDesktop": { "extractionState": "manual", "localizations": { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionDescriptor+PaletteRank.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionDescriptor+PaletteRank.swift index c6eb9693e28f..2191ff8ae4de 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionDescriptor+PaletteRank.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionDescriptor+PaletteRank.swift @@ -266,8 +266,7 @@ extension ActionDescriptor { "notifications.dismissal.keystroke", "notifications.dismissal.never", "notifications.dismissal.timeout", ]), - PaletteFamily("forkConversation", title: familyTitle("palette.family.forkConversation", "Fork Conversation…"), - symbol: "arrow.triangle.branch", members: [ + PaletteFamily("forkConversation", parent: "palette.forkAgentConversation", members: [ "palette.forkAgentConversationRight", "palette.forkAgentConversationLeft", "palette.forkAgentConversationTop", "palette.forkAgentConversationBottom", "palette.forkAgentConversationNewTab", "palette.forkAgentConversationNewWorkspace", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 18f9ace645af..209259660f7d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -105,7 +105,7 @@ nonisolated extension ActionSurfaceCatalog { "notifications.previewSound", "notifications.importSound", "notifications.dismissal.focus", "notifications.dismissal.click", "notifications.dismissal.explicit", "notifications.dismissal.timeout", "notifications.dismissal.never", "palette.newAgentChat", "palette.quickAgentChat", - "palette.launchClaudeTeams", "palette.launchCodexTeams", "palette.forkAgentConversationRight", + "palette.launchClaudeTeams", "palette.launchCodexTeams", "palette.forkAgentConversation", "palette.forkAgentConversationRight", "palette.forkAgentConversationLeft", "palette.forkAgentConversationTop", "palette.forkAgentConversationBottom", "palette.forkAgentConversationNewTab", "palette.forkAgentConversationNewWorkspace", "palette.forkAgentConversationNewWindow", "computerUseStop", "newCloudWorkspace", "newCloudMachine", "palette.cloud.fork", "palette.cloud.snapshot", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenusContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenusContent.swift index 9eae1b3b0818..fb5cbbcfcd68 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenusContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+MenusContent.swift @@ -147,13 +147,15 @@ nonisolated extension ActionSurfaceCatalog { // A tab kind: listed with the others, before the profile and New Tab Page rows (c0c88e7b24ff). "macScreen.open": [p(.newTab, .create, 3)], "newTab.page": [p(.newTab, .create, 101)], - "palette.forkAgentConversationRight": [p(.terminalSelection, .create, 200, folder: .agent), p(.agentChat, .create, 200, folder: .agent)], - "palette.forkAgentConversationLeft": [p(.terminalSelection, .create, 202, folder: .agent), p(.agentChat, .create, 202, folder: .agent)], - "palette.forkAgentConversationTop": [p(.terminalSelection, .create, 203, folder: .agent), p(.agentChat, .create, 203, folder: .agent)], - "palette.forkAgentConversationBottom": [p(.terminalSelection, .create, 204, folder: .agent), p(.agentChat, .create, 204, folder: .agent)], - "palette.forkAgentConversationNewTab": [p(.terminalSelection, .create, 201, folder: .agent), p(.agentChat, .create, 201, folder: .agent)], - "palette.forkAgentConversationNewWorkspace": [p(.terminalSelection, .create, 205, folder: .agent), p(.agentChat, .create, 205, folder: .agent)], - "palette.forkAgentConversationNewWindow": [p(.terminalSelection, .create, 206, folder: .agent), p(.agentChat, .create, 206, folder: .agent)], + "palette.forkAgentConversation": [p(.terminalSelection, .create, 199), p(.agentChat, .create, 199)], + "palette.forkAgentConversationTo": [p(.terminalSelection, .create, 200, .submenu), p(.agentChat, .create, 200, .submenu)], + "palette.forkAgentConversationRight": [p(.terminalSelection, .create, 200, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 200, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationLeft": [p(.terminalSelection, .create, 202, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 202, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationTop": [p(.terminalSelection, .create, 203, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 203, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationBottom": [p(.terminalSelection, .create, 204, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 204, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationNewTab": [p(.terminalSelection, .create, 201, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 201, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationNewWorkspace": [p(.terminalSelection, .create, 205, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 205, in: "palette.forkAgentConversationTo")], + "palette.forkAgentConversationNewWindow": [p(.terminalSelection, .create, 206, in: "palette.forkAgentConversationTo"), p(.agentChat, .create, 206, in: "palette.forkAgentConversationTo")], "newCloudWorkspace": [p(.sidebarBackground, .create, 200, folder: .new)], "palette.cloud.fork": [p(.cloudMachine, .connection, 203, folder: .connection)], "palette.cloud.snapshot": [p(.cloudMachine, .connection, 202, folder: .connection)], diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentActionCatalog.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentActionCatalog.swift index 25e5ba26be1c..a972352667ff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentActionCatalog.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/Catalog/AgentActionCatalog.swift @@ -288,10 +288,23 @@ nonisolated enum AgentActionCatalog: ActionCatalogGroup { keywords: ["agent", "codex", "team"], category: .agents, symbol: "person.3.fill", surfaces: [.palette], cliName: "agent launch-codex-teams" ), + ActionDescriptor( + id: "palette.forkAgentConversation", + title: String(localized: "action.palette.forkAgentConversation", defaultValue: "Fork Conversation", bundle: .module), + keywords: ["agent", "fork", "branch"], defaultShortcut: Shortcut("y", modifiers: [.option, .command]), + category: .agents, symbol: "arrow.triangle.branch", surfaces: [.palette, .contextMenu], + cliName: "agent fork-conversation" + ), + ActionDescriptor( + id: "palette.forkAgentConversationTo", + title: String(localized: "action.palette.forkAgentConversationTo", defaultValue: "Fork to…", bundle: .module), + keywords: ["agent", "fork"], category: .agents, symbol: "arrow.triangle.branch", + surfaces: [.contextMenu] + ), ActionDescriptor( id: "palette.forkAgentConversationRight", title: String(localized: "action.palette.forkAgentConversationRight", defaultValue: "Fork Conversation to the Right", bundle: .module), - keywords: ["agent", "fork", "branch", "split"], defaultShortcut: Shortcut("y", modifiers: [.option, .command]), + keywords: ["agent", "fork", "branch", "split"], category: .agents, symbol: "rectangle.righthalf.inset.filled", surfaces: [.palette, .contextMenu], cliName: "agent fork-conversation-to-right" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkDestination.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkDestination.swift index 8b20ce1bc764..4930d7dcce40 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkDestination.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkDestination.swift @@ -23,10 +23,9 @@ public enum AgentForkDestination: String, CaseIterable, Sendable { /// The destination a fork takes when none is named. public static let `default`: AgentForkDestination = .right - /// The destination a page's `placement` names; a missing or unknown one is a tab, as the - /// chat page's Fork opened before destinations existed. + /// Every entry point uses the same default when the page names no destination. public init(placement: String?) { - self = placement.flatMap(Self.init(rawValue:)) ?? .tab + self = placement.flatMap(Self.init(rawValue:)) ?? .default } /// The `direction` argument of `tab.moveToNewSplit` for a split destination, else nil. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkMenu.swift new file mode 100644 index 000000000000..443deb1a29eb --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentForkMenu.swift @@ -0,0 +1,34 @@ +import AppKit +import CmuxNextActions + +/// Destination choices are secondary to the one-step Fork action on every surface. +@MainActor enum AgentForkMenu { + static var title: String { + ActionCatalog.all.first { $0.id == "palette.forkAgentConversationTo" }?.title ?? "" + } + + static let destinations: [(AgentForkDestination, ActionID)] = [ + (.right, "palette.forkAgentConversationRight"), (.left, "palette.forkAgentConversationLeft"), + (.up, "palette.forkAgentConversationTop"), (.down, "palette.forkAgentConversationBottom"), + (.tab, "palette.forkAgentConversationNewTab"), (.workspace, "palette.forkAgentConversationNewWorkspace"), + (.window, "palette.forkAgentConversationNewWindow"), + ] + + static func menu(perform: @escaping (AgentForkDestination) -> Void) -> NSMenu { + let menu = NSMenu(title: title) + for (destination, id) in destinations { + guard let action = ActionCatalog.all.first(where: { $0.id == id }) else { continue } + menu.addItem(AgentPaneMenuAction.item(action.title, symbol: action.symbol) { + perform(destination) + }) + } + return menu + } + + static func item(perform: @escaping (AgentForkDestination) -> Void) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.image = AgentPaneContextMenu.icon("arrow.triangle.branch") + item.submenu = menu(perform: perform) + return item + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift index 7ccef30d5dc1..635d0776487c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneContextMenu.swift @@ -228,6 +228,11 @@ enum AgentPaneContextMenu { }) } } + if let seq = target.forkSeq { + message.append(AgentForkMenu.item { destination in + actions.page("chat.fork", ["throughSeq": String(seq), "placement": destination.rawValue]) + }) + } // On a link, the part's Open Link stands for the message's. links = target.part?.link == nil ? linkItems(target.links, open: actions.open) : [] } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift index 340600f37c4f..ff499c1f433c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+ContextMenu.swift @@ -166,6 +166,11 @@ extension AgentPaneView { /// report from a message's More button (`popUp`) opens that message's menu under the button now. func receiveContextMenuReport(_ body: Any?) { if let anchor = AgentPaneContextMenu.popUpAnchor(report: body) { + if let seq = (body as? [String: Any])?["forkDestinations"] as? Int { + // task-owner: one destination menu per Option-click; ends when the menu closes + Task { @MainActor [weak self] in self?.popUpForkMenu(through: seq, anchor: anchor) } + return + } let target = AgentPaneMessageTarget(report: body) // task-owner: one menu per More press, after the page's message returns; it ends when the menu closes Task { @MainActor [weak self] in self?.popUpMessageMenu(target: target, anchor: anchor) } @@ -198,6 +203,25 @@ extension AgentPaneView { menu.popUp(positioning: nil, at: NSPoint(x: left, y: view.isFlipped ? bottom : view.bounds.height - bottom), in: view) } + func popUpForkMenu(through seq: Int? = nil, anchor: CGRect? = nil) { + let view: NSView = page?.focusView ?? webView + guard view.window != nil else { return } + let menu = AgentForkMenu.menu { [weak self] destination in + var params = ["placement": destination.rawValue] + if let seq { params["throughSeq"] = String(seq) } + self?.runMessageAction(seq == nil ? "chat.forkTo" : "chat.fork", params) + } + menu.themeRows(in: themeScope.fullStrength) + guard let anchor else { + menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + return + } + let zoom: CGFloat = page.map { CGFloat(Double(DesignSettings.shared.uiScale) * $0.additionalZoom) } + ?? webView.pageZoom * webView.magnification + let bottom = anchor.maxY * zoom + 2 + menu.popUp(positioning: nil, at: NSPoint(x: anchor.minX * zoom, y: view.isFlipped ? bottom : view.bounds.height - bottom), in: view) + } + /// The pane's menu for `target` and `selection` in `menu`, which holds WebKit's items (none for More). private func fillContextMenu(_ menu: NSMenu, target: AgentPaneMessageTarget?, selection: String?) { let chatMenu = target == nil && selection == nil ? model.chatMenuItems?() ?? [] : [] @@ -235,6 +259,10 @@ extension AgentPaneView { /// the user's gesture, as a native permission shortcut is. private func fork(through seq: Int) { AgentPanePersonInput.record(model.transport.gestures) - evaluateScript("window.cmuxAcpmuxActions?.['chat.fork']?.({ throughSeq: \(seq) });") + if NSEvent.modifierFlags.contains(.option) { + popUpForkMenu(through: seq) + } else { + runMessageAction("chat.fork", ["throughSeq": String(seq)]) + } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Fork.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Fork.swift index b7329a69b6d6..0894e3248eff 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Fork.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+Fork.swift @@ -1,16 +1,27 @@ +import AppKit + extension AgentPaneView { /// Fork Conversation from the palette, the chat's right-click menu or its shortcut: the page /// forks the open chat through its latest finished turn (`chat.forkTo`) and asks the host to /// open the fork at `destination` (`chat.openTab`'s `placement`). A chat that cannot fork now - /// (no turn yet, a turn running, a harness without fork) says why in its transcript. + /// (no turn yet, a harness without fork) says why in its transcript. /// /// The request is the user's gesture, as a native permission shortcut is. - public func forkConversation(to destination: AgentForkDestination) { + public func forkConversation(to destination: AgentForkDestination, offerDestinations: Bool = false) { + AgentPanePersonInput.record(model.transport.gestures) + if offerDestinations, let event = NSApp.currentEvent, event.type == .leftMouseUp, event.modifierFlags.contains(.option) { + popUpForkMenu() + } else { + evaluateScript(Self.forkScript(destination)) + } + } + + public func chooseForkDestination() { AgentPanePersonInput.record(model.transport.gestures) - evaluateScript(Self.forkScript(destination)) + popUpForkMenu() } - /// The page call ``forkConversation(to:)`` runs; the destination is one of a closed set of + /// The page call the fork action runs; the destination is one of a closed set of /// raw values, so the script never carries outside text. static func forkScript(_ destination: AgentForkDestination) -> String { "window.cmuxAcpmuxActions?.['chat.forkTo']?.({ placement: '\(destination.rawValue)' });" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift index 962ae8a06159..b675d7efca96 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift @@ -49,8 +49,19 @@ enum AgentHandlers { try resetAgentPaneZoom(context) return .done }) + registry.bindEffect("palette.forkAgentConversationTo", effect: { invocation in + guard let (pane, id) = context.scope(invocation).tab, let tab = pane.tab(id), + let view = context.services.agentTabs.existingView(tab.id) else { + throw ActionFailure(message: MiscHandlerStrings.noForkTarget) + } + view.chooseForkDestination() + return .done + }) for (id, destination) in forkActions { - registry.bind(id, run: { try fork(destination, invocation: $0, context: context) }) + registry.bindEffect(id, effect: { invocation in + try fork(destination, offerDestinations: id == "palette.forkAgentConversation", invocation: invocation, context: context) + return .done + }) } registry.bind("agentActivity.open", run: { _ in context.services.agentActivityPage.open() }) context.services.pages.register(context.services.macScreenTabs) @@ -302,6 +313,7 @@ enum AgentHandlers { /// Fork Conversation's actions, in the palette step's order (the default destination first). static let forkActions: [(ActionID, AgentForkDestination)] = [ + ("palette.forkAgentConversation", .default), ("palette.forkAgentConversationRight", .right), ("palette.forkAgentConversationLeft", .left), ("palette.forkAgentConversationTop", .up), ("palette.forkAgentConversationBottom", .down), ("palette.forkAgentConversationNewTab", .tab), ("palette.forkAgentConversationNewWorkspace", .workspace), @@ -323,7 +335,7 @@ enum AgentHandlers { return nil } - private static func fork(_ destination: AgentForkDestination, invocation: ActionInvocation, context: AppActionContext) throws { + private static func fork(_ destination: AgentForkDestination, offerDestinations: Bool, invocation: ActionInvocation, context: AppActionContext) throws { guard let (pane, id) = context.scope(invocation).tab, let tab = pane.tab(id) else { throw ActionFailure(message: MiscHandlerStrings.noForkTarget) } @@ -331,7 +343,7 @@ enum AgentHandlers { // through acpmux and the app opens the fork at the destination. if let view = context.services.agentTabs.existingView(tab.id) { guard !context.services.agentTabs.isNewTabPage(tab.id) else { throw ActionFailure(message: MiscHandlerStrings.noForkTarget) } - view.forkConversation(to: destination) + view.forkConversation(to: destination, offerDestinations: offerDestinations) return } guard tab.kind == .pty else { throw ActionFailure(message: MiscHandlerStrings.noForkTarget) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentForkMenuTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentForkMenuTests.swift new file mode 100644 index 000000000000..91270ad531f2 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentForkMenuTests.swift @@ -0,0 +1,25 @@ +import AppKit +import Testing +@testable import CmuxNextAgentPane + +@MainActor @Suite struct AgentForkMenuTests { + @Test func rightClickForksImmediatelyAndOffersAllDestinationsSeparately() throws { + var forked: [Int] = [] + var placed: [[String: String]] = [] + let menu = NSMenu() + AgentPaneContextMenu.rebuild(menu, target: .init(text: "Hello", forkSeq: 42), devTools: false, + actions: .init(copy: { _ in }, fork: { forked.append($0) }, page: { name, params in + #expect(name == "chat.fork") + placed.append(params) + })) + let primary = try #require(menu.items.first { $0.title == AgentPaneMenuStrings.forkFromHere }) + #expect(primary.submenu == nil) + _ = (primary.target as? NSObject)?.perform(primary.action) + #expect(forked == [42]) + let destinations = try #require(menu.items.first { $0.title == AgentForkMenu.title }?.submenu) + #expect(destinations.items.count == 7) + for item in destinations.items { _ = (item.target as? NSObject)?.perform(item.action) } + #expect(placed.compactMap { $0["placement"] } == AgentForkDestination.allCases.map(\.rawValue)) + #expect(placed.allSatisfy { $0["throughSeq"] == "42" }) + } +} diff --git a/release-notes/next/highlights/one-step-fork.md b/release-notes/next/highlights/one-step-fork.md new file mode 100644 index 000000000000..c78ac47774bc --- /dev/null +++ b/release-notes/next/highlights/one-step-fork.md @@ -0,0 +1,4 @@ +title: Fork a conversation in one step +category: improved + +Fork from a message, the chat menu, /fork, or the command palette and start typing in the new chat. Option-click Fork or choose Fork to… for another destination. Hand off to… has its own menu section. diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 048e109f358a..2f0bc0213ea3 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -1,3 +1,5 @@ +import { forkMenuRows } from "./header/forkMenu"; +import { openFork } from "./conversation/openFork"; import React, { lazy, memo, @@ -333,7 +335,6 @@ import { canFork, forkRefusal, forkToTarget, - earlierForkPrompts, forksAnyTurn, latestForkSeq, messageMenuTarget, @@ -7515,13 +7516,13 @@ function AcpmuxPane() { /// (hub/start_mode.rs; Leo 2026-10-21). const setMode = (modeId: string, ticket?: string) => client.setMode(modeId, ticket); /// `placement`: where the app opens it (AgentForkDestination: right, left, up, down, tab, - /// workspace, window); none is a tab after this chat's. + /// workspace, window); hand-offs explicitly retain a tab after this chat. const openOwnTab = (sessionId: string, placement?: string) => { if (mock) return Promise.resolve(false); // The new tab draws this chat from the host's tail, and this page keeps it selected, so // nothing else hands it over (tailCache.ts). client.handOverCurrentTail(); - return callNative("chat.openTab", placement ? { sessionId, placement } : { sessionId }).then( + return callNative("chat.openTab", { sessionId, placement: placement ?? "tab" }).then( () => true, () => false, ); @@ -7858,14 +7859,17 @@ function AcpmuxPane() { // A fork and a started Continue In… open in a tab of their own, so the chat keeps its tab // and stays in the sidebar (Leo 2026-10-13). A host that opens none (an older app, the // floating chat) gets the new chat in this tab, as before. - "chat.fork": async ({ throughSeq }) => { + "chat.fork": async ({ throughSeq, placement }) => { harnessSwitch.cancel(); const chat = client.selectedSession; const forked = await client.fork(Number(throughSeq), undefined, true); - if (!forked || (await openOwnTab(forked)) || client.selectedSession !== chat) return forked; - const selected = await client.select(forked); - await persistSession(selected); - return selected; + return openFork(forked, chat, placement, { + open: openOwnTab, + selected: () => client.selectedSession, + select: (id) => client.select(id), + persist: persistSession, + focus: () => composerHandle.current?.focus(), + }); }, // Fork Conversation from the app (the palette's step, the chat's right-click menu, its // shortcut): a fork through the chat's latest finished turn, or of a working chat as it is @@ -7885,9 +7889,13 @@ function AcpmuxPane() { const chat = client.selectedSession; // A working chat forks now, as it is; its own turn goes on (acpmux `hub/fork.rs`). const forked = await client.fork("live" in target ? "live" : target.throughSeq, undefined, true); - if (!forked || (await openOwnTab(forked, String(placement ?? "tab"))) || client.selectedSession !== chat) - return forked; - return persistSession(await client.select(forked)); + return openFork(forked, chat, placement, { + open: openOwnTab, + selected: () => client.selectedSession, + select: (id) => client.select(id), + persist: persistSession, + focus: () => composerHandle.current?.focus(), + }); }, // Edit and resend (cx-ncc.60): a fork through the turn before the prompt, which then gets the // edited text with the prompt's attachments; the original chat stays as it was. @@ -8446,35 +8454,9 @@ function AcpmuxPane() { t, () => window.cmuxAcpmuxBridge?.conversationMarkdown?.() ?? Promise.resolve(null), ); - // Fork › From here (the last turn), then, for a harness that forks at any turn, the earlier - // prompts, newest first (ChatGPT's "from a message"); otherwise one Fork row. - const forkPrompts = forksAnyTurn(summary?.family) ? earlierForkPrompts(snapshot.rows) : []; - const fork: ChatMenuItem[] = - forkable && lastForkSeq !== undefined - ? [ - { - key: "fork", - label: t("chatMenu.fork"), - icon: "agent.fork", - ...(forkPrompts.length > 0 - ? { - children: [ - { - key: "forkHere", - label: t("chatMenu.forkFromHere"), - onSelect: () => turnActions.fork?.(lastForkSeq), - }, - ...forkPrompts.map((prompt) => ({ - key: `fork-${prompt.seq}`, - label: prompt.text, - onSelect: () => turnActions.fork?.(prompt.seq), - })), - ], - } - : { onSelect: () => turnActions.fork?.(lastForkSeq) }), - }, - ] - : []; + // Fork immediately; destination choices are a separate action and Option-click shortcut. + const fork = + forkable && lastForkSeq !== undefined ? forkMenuRows(lastForkSeq, () => turnActions.fork?.(lastForkSeq), t) : []; // New side chat, New terminal and New browser: something new beside the chat, every time. const opensBeside = opensBesideRows({ sideChat, quick }, { terminal: newTerminal, browser: newBrowser }, t); const continueIn: ChatMenuItem[] = @@ -8552,7 +8534,7 @@ function AcpmuxPane() { : []), ]; // Quick Chat's panel is not a tab: only the chat's own actions. - if (quick) return menuGroups([...fork, ...continueIn], copy ? [copy] : [], tools); + if (quick) return menuGroups(fork, continueIn, copy ? [copy] : [], tools); // ChatGPT's order: the chat's name and pin, new chats from it, Copy, where it shows, then Archive. return menuGroups( [ @@ -8571,7 +8553,8 @@ function AcpmuxPane() { onSelect: () => runHeaderAction(HEADER_ACTIONS.pin), }, ], - [...opensBeside, ...fork, ...continueIn], + [...opensBeside, ...fork], + continueIn, copy ? [copy] : [], [ { diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index 688fd12c5ca2..89617dd406cc 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -1,6 +1,7 @@ { "sourceLanguage": "en", "strings": { + "chatMenu.forkTo": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Fork to…"}},"ar":{"stringUnit":{"state":"translated","value":"إنشاء فرع في…"}},"de":{"stringUnit":{"state":"translated","value":"Verzweigen nach…"}},"fr":{"stringUnit":{"state":"translated","value":"Créer une branche dans…"}},"es":{"stringUnit":{"state":"translated","value":"Crear rama en…"}},"ja":{"stringUnit":{"state":"translated","value":"分岐先…"}},"ko":{"stringUnit":{"state":"translated","value":"다음 위치로 분기…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"分支到…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"分支至…"}},"bs":{"stringUnit":{"state":"translated","value":"Napravi granu u…"}},"da":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"it":{"stringUnit":{"state":"translated","value":"Crea un ramo in…"}},"km":{"stringUnit":{"state":"translated","value":"បំបែកសាខាទៅ…"}},"nb":{"stringUnit":{"state":"translated","value":"Forgren til…"}},"pl":{"stringUnit":{"state":"translated","value":"Utwórz odgałęzienie w…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Criar ramificação em…"}},"ru":{"stringUnit":{"state":"translated","value":"Создать ветку в…"}},"th":{"stringUnit":{"state":"translated","value":"แยกสาขาไปยัง…"}},"tr":{"stringUnit":{"state":"translated","value":"Şuraya dallandır…"}},"uk":{"stringUnit":{"state":"translated","value":"Створити гілку в…"}},"vi":{"stringUnit":{"state":"translated","value":"Tạo nhánh tại…"}}}}, "composer.connectPhone": {"comment":"Agent pane: the environment picker's row under New environment that opens the Mobile Pairing page, so cmux on iPhone reaches this Mac (EnvironmentPicker.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Connect Phone…"}},"ar":{"stringUnit":{"state":"translated","value":"ربط الهاتف…"}},"bs":{"stringUnit":{"state":"translated","value":"Poveži telefon…"}},"da":{"stringUnit":{"state":"translated","value":"Forbind telefon…"}},"de":{"stringUnit":{"state":"translated","value":"Telefon verbinden…"}},"es":{"stringUnit":{"state":"translated","value":"Conectar teléfono…"}},"fr":{"stringUnit":{"state":"translated","value":"Connecter un téléphone…"}},"it":{"stringUnit":{"state":"translated","value":"Collega telefono…"}},"ja":{"stringUnit":{"state":"translated","value":"電話を接続…"}},"km":{"stringUnit":{"state":"translated","value":"ភ្ជាប់ទូរសព្ទ…"}},"ko":{"stringUnit":{"state":"translated","value":"휴대폰 연결…"}},"nb":{"stringUnit":{"state":"translated","value":"Koble til telefon…"}},"pl":{"stringUnit":{"state":"translated","value":"Połącz telefon…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Conectar celular…"}},"ru":{"stringUnit":{"state":"translated","value":"Подключить телефон…"}},"th":{"stringUnit":{"state":"translated","value":"เชื่อมต่อโทรศัพท์…"}},"tr":{"stringUnit":{"state":"translated","value":"Telefon Bağla…"}},"uk":{"stringUnit":{"state":"translated","value":"Підключити телефон…"}},"vi":{"stringUnit":{"state":"translated","value":"Kết nối điện thoại…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"连接手机…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"連接手機…"}}}}, "retry.running": {"comment":"Agent pane: short note after a failed subagent's or sub-session's line when acpmux refused a retry because it already runs again (subagents/SubagentDoneLine.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Already running again"}},"ar":{"stringUnit":{"state":"needs_review","value":"يعمل مرة أخرى بالفعل"}},"bs":{"stringUnit":{"state":"needs_review","value":"Već ponovo radi"}},"da":{"stringUnit":{"state":"needs_review","value":"Kører allerede igen"}},"de":{"stringUnit":{"state":"needs_review","value":"Läuft bereits wieder"}},"es":{"stringUnit":{"state":"needs_review","value":"Ya se está ejecutando de nuevo"}},"fr":{"stringUnit":{"state":"needs_review","value":"Déjà relancé"}},"it":{"stringUnit":{"state":"needs_review","value":"Già di nuovo in esecuzione"}},"ja":{"stringUnit":{"state":"needs_review","value":"すでに再実行中です"}},"km":{"stringUnit":{"state":"needs_review","value":"កំពុងដំណើរការម្តងទៀតរួចហើយ"}},"ko":{"stringUnit":{"state":"needs_review","value":"이미 다시 실행 중"}},"nb":{"stringUnit":{"state":"needs_review","value":"Kjører allerede igjen"}},"pl":{"stringUnit":{"state":"needs_review","value":"Już działa ponownie"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Já está em execução novamente"}},"ru":{"stringUnit":{"state":"needs_review","value":"Уже снова выполняется"}},"th":{"stringUnit":{"state":"needs_review","value":"กำลังทำงานอีกครั้งแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Zaten yeniden çalışıyor"}},"uk":{"stringUnit":{"state":"needs_review","value":"Уже знову виконується"}},"vi":{"stringUnit":{"state":"needs_review","value":"Đã chạy lại rồi"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"已在重新运行"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"已在重新執行"}}}}, "retry.gone": {"comment":"Agent pane: short note after a failed subagent's or sub-session's line when acpmux can no longer retry it (the chat closed or stopped, or the subagent resumed, was replaced, ended or is unknown) (subagents/SubagentDoneLine.tsx)","extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Can no longer be retried"}},"ar":{"stringUnit":{"state":"needs_review","value":"لم يعد من الممكن إعادة المحاولة"}},"bs":{"stringUnit":{"state":"needs_review","value":"Više se ne može ponoviti"}},"da":{"stringUnit":{"state":"needs_review","value":"Kan ikke længere prøves igen"}},"de":{"stringUnit":{"state":"needs_review","value":"Kann nicht mehr wiederholt werden"}},"es":{"stringUnit":{"state":"needs_review","value":"Ya no se puede reintentar"}},"fr":{"stringUnit":{"state":"needs_review","value":"Ne peut plus être réessayé"}},"it":{"stringUnit":{"state":"needs_review","value":"Non è più possibile riprovare"}},"ja":{"stringUnit":{"state":"needs_review","value":"再試行できなくなりました"}},"km":{"stringUnit":{"state":"needs_review","value":"មិនអាចព្យាយាមម្តងទៀតបានទៀតទេ"}},"ko":{"stringUnit":{"state":"needs_review","value":"더 이상 재시도할 수 없음"}},"nb":{"stringUnit":{"state":"needs_review","value":"Kan ikke lenger prøves igjen"}},"pl":{"stringUnit":{"state":"needs_review","value":"Nie można już ponowić"}},"pt-BR":{"stringUnit":{"state":"needs_review","value":"Não é mais possível tentar novamente"}},"ru":{"stringUnit":{"state":"needs_review","value":"Повторить больше нельзя"}},"th":{"stringUnit":{"state":"needs_review","value":"ลองใหม่ไม่ได้อีกแล้ว"}},"tr":{"stringUnit":{"state":"needs_review","value":"Artık yeniden denenemez"}},"uk":{"stringUnit":{"state":"needs_review","value":"Більше не можна повторити"}},"vi":{"stringUnit":{"state":"needs_review","value":"Không thể thử lại nữa"}},"zh-Hans":{"stringUnit":{"state":"needs_review","value":"已无法重试"}},"zh-Hant":{"stringUnit":{"state":"needs_review","value":"已無法重試"}}}}, @@ -93498,138 +93499,7 @@ } } }, - "handoff.continueIn": { - "comment": "Agent pane", - "extractionState": "manual", - "localizations": { - "ar": { - "stringUnit": { - "state": "translated", - "value": "المتابعة في…" - } - }, - "bs": { - "stringUnit": { - "state": "translated", - "value": "Nastavi u…" - } - }, - "da": { - "stringUnit": { - "state": "translated", - "value": "Fortsæt i…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Weiter in…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Continue In…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Continuar en…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Continuer dans…" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Continua in…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "…で続行" - } - }, - "km": { - "stringUnit": { - "state": "translated", - "value": "បន្តក្នុង…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다음에서 계속…" - } - }, - "nb": { - "stringUnit": { - "state": "translated", - "value": "Fortsett i…" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Kontynuuj w…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Continuar em…" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Продолжить в…" - } - }, - "th": { - "stringUnit": { - "state": "translated", - "value": "ดำเนินการต่อใน…" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Şurada devam et…" - } - }, - "uk": { - "stringUnit": { - "state": "translated", - "value": "Продовжити в…" - } - }, - "vi": { - "stringUnit": { - "state": "translated", - "value": "Tiếp tục trong…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "继续使用…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "繼續使用…" - } - } - } - }, + "handoff.continueIn": {"comment":"Agent pane","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تسليم إلى…"}},"bs":{"stringUnit":{"state":"translated","value":"Predaj…"}},"da":{"stringUnit":{"state":"translated","value":"Overdrag til…"}},"de":{"stringUnit":{"state":"translated","value":"Übergeben an…"}},"en":{"stringUnit":{"state":"translated","value":"Hand off to…"}},"es":{"stringUnit":{"state":"translated","value":"Transferir a…"}},"fr":{"stringUnit":{"state":"translated","value":"Transférer à…"}},"it":{"stringUnit":{"state":"translated","value":"Passa a…"}},"ja":{"stringUnit":{"state":"translated","value":"引き継ぎ先…"}},"km":{"stringUnit":{"state":"translated","value":"ប្រគល់ទៅ…"}},"ko":{"stringUnit":{"state":"translated","value":"다음으로 인계…"}},"nb":{"stringUnit":{"state":"translated","value":"Overfør til…"}},"pl":{"stringUnit":{"state":"translated","value":"Przekaż do…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Transferir para…"}},"ru":{"stringUnit":{"state":"translated","value":"Передать…"}},"th":{"stringUnit":{"state":"translated","value":"ส่งต่องานให้…"}},"tr":{"stringUnit":{"state":"translated","value":"Şuraya devret…"}},"uk":{"stringUnit":{"state":"translated","value":"Передати…"}},"vi":{"stringUnit":{"state":"translated","value":"Bàn giao cho…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"移交给…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"移交給…"}}}}, "handoff.continueTarget": { "comment": "Agent pane", "extractionState": "manual", diff --git a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx index 76418a3192a4..6283ec1aab75 100644 --- a/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx +++ b/webviews/src/agent-session/acpmux/conversation/MessageActions.tsx @@ -14,7 +14,7 @@ import { AlertCircle, Check, DoubleCheck, Pin, Retry, Speaker, Spinner, TurnFork import { canReadAloud, readAloud, useReadingAloud } from "./readAloud"; import { exactTime, relativeTime } from "./relativeTime"; import { CopyButton } from "./CopyButton"; -import { hasMessageMenuHost, openMessageMenu } from "./messageMenu"; +import { hasMessageMenuHost, openMessageMenu, openForkMenu } from "./messageMenu"; import { RetryModelMenu } from "./RetryModelMenu"; import { TurnActionsContext } from "./turnActions"; @@ -74,7 +74,7 @@ export function forkWithFeedback(throughSeq: number, fallback: () => unknown): u /// Fork from here, with Claude's tooltip: the title, then that this chat stays as it is. The host /// operation can take long enough to look inert, so press feedback starts before the promise is /// dispatched and a ref closes the same-frame duplicate-click window. -export function ForkButton({ onFork }: { onFork(): unknown }) { +export function ForkButton({ onFork, throughSeq }: { onFork(): unknown; throughSeq?: number }) { const t = useT(); const inFlight = useRef(false); const [state, setState] = useState<"idle" | "pending" | "done" | "failed">("idle"); @@ -124,7 +124,10 @@ export function ForkButton({ onFork }: { onFork(): unknown }) { data-fork-state={state} disabled={state === "pending"} data-tooltip-detail={t("turn.forkDetail")} - onClick={fork} + onClick={(event) => { + if (event.altKey && throughSeq !== undefined && openForkMenu(throughSeq, event.currentTarget)) return; + fork(); + }} > {state === "pending" ? ( diff --git a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx index 3d92f992cfcb..d5fb68f3c775 100644 --- a/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx +++ b/webviews/src/agent-session/acpmux/conversation/TurnRows.tsx @@ -376,7 +376,7 @@ export function TurnFooter({ row }: { row: AcpmuxRow }) { /> )} {fork && seq !== undefined && (forkAny || seq === forkSeq) && ( - forkWithFeedback(seq, () => fork(seq))} /> + forkWithFeedback(seq, () => fork(seq))} /> )} {text && } diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index d453666b169a..c375daaa5627 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -163,7 +163,10 @@ export function UserBubble({ {/* Not while it is still on its way: a second send would duplicate it. */} {!row.pending && row.delivery?.state !== "sending" && } {forkThrough !== undefined && ( - forkWithFeedback(forkThrough, () => fork!(forkThrough))} /> + forkWithFeedback(forkThrough, () => fork!(forkThrough))} + /> )} )} diff --git a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts index 4955e57c2b7e..ec06043dfb88 100644 --- a/webviews/src/agent-session/acpmux/conversation/messageMenu.ts +++ b/webviews/src/agent-session/acpmux/conversation/messageMenu.ts @@ -244,6 +244,14 @@ export const hasMessageMenuHost = () => menuHandler() !== undefined; /// More (…) on row `rowId`'s action row: asks the host for the menu a right-click on that message /// opens, with the same report, shown under `anchor`. False when the row is not a message or no /// host shows the menu. +export function openForkMenu(throughSeq: number, anchor?: Element): boolean { + const handler = menuHandler(); + if (!handler) return false; + const { left, top, right, bottom } = (anchor ?? document.body).getBoundingClientRect(); + handler.postMessage({ forkDestinations: throughSeq, popUp: { left, top, right, bottom } }); + return true; +} + export function openMessageMenu(rowId: string, anchor: Element): boolean { const message = source?.(rowId); const handler = menuHandler(); diff --git a/webviews/src/agent-session/acpmux/conversation/openFork.test.ts b/webviews/src/agent-session/acpmux/conversation/openFork.test.ts new file mode 100644 index 000000000000..5e997ff3b44a --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/openFork.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { openFork } from "./openFork"; + +function host(opens = true) { + let selected: string | undefined = "parent"; + const opened: [string, string][] = []; + const focused: string[] = []; + const persisted: (string | undefined)[] = []; + return { + opened, + focused, + persisted, + selected: () => selected, + open: async (id: string, placement: string) => { + opened.push([id, placement]); + return opens; + }, + select: async (id: string) => { + selected = id; + return id; + }, + persist: async (id?: string) => { + persisted.push(id); + }, + focus: () => { + focused.push(selected!); + }, + }; +} + +test("an unspecified destination delegates to the native default and keeps the parent selected", async () => { + const h = host(); + expect(await openFork("fork", "parent", undefined, h)).toBe("fork"); + expect(h.opened).toEqual([["fork", "default"]]); + expect(h.selected()).toBe("parent"); +}); + +for (const placement of ["right", "left", "up", "down", "tab", "workspace", "window"]) { + test(`explicit ${placement} is preserved`, async () => { + const h = host(); + await openFork("fork", "parent", placement, h); + expect(h.opened).toEqual([["fork", placement]]); + }); +} + +test("refused host opening selects, persists and focuses the fork", async () => { + const h = host(false); + await openFork("fork", "parent", undefined, h); + expect(h.persisted).toEqual(["fork"]); + expect(h.focused).toEqual(["fork"]); +}); + +test("a refused host opening never replaces a chat selected while forking", async () => { + const h = host(false); + await h.select("other"); + await openFork("fork", "parent", undefined, h); + expect(h.selected()).toBe("other"); + expect(h.focused).toEqual([]); +}); + +test("a later navigation while persistence finishes keeps its focus", async () => { + const h = host(false); + h.persist = async () => { + await h.select("other"); + }; + await openFork("fork", "parent", undefined, h); + expect(h.focused).toEqual([]); +}); + +test("a failed fork opens and focuses nothing", async () => { + const h = host(); + expect(await openFork(undefined, "parent", undefined, h)).toBeUndefined(); + expect(h.opened).toEqual([]); + expect(h.focused).toEqual([]); +}); diff --git a/webviews/src/agent-session/acpmux/conversation/openFork.ts b/webviews/src/agent-session/acpmux/conversation/openFork.ts new file mode 100644 index 000000000000..8767e209781a --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/openFork.ts @@ -0,0 +1,22 @@ +/** Place a completed fork without replacing its parent; a refused host open selects and focuses it. */ +export async function openFork( + session: string | undefined, + parent: string | undefined, + placement: unknown, + host: { + open(session: string, placement: string): Promise; + selected(): string | undefined; + select(session: string): Promise; + persist(session?: string): Promise; + focus(): void; + }, +): Promise { + if (!session) return session; + // The native destination parser owns the default. Hand-offs can still explicitly request a tab. + if (await host.open(session, typeof placement === "string" ? placement : "default")) return session; + if (host.selected() !== parent) return session; + const selected = await host.select(session); + await host.persist(selected); + if (selected && host.selected() === selected) host.focus(); + return selected; +} diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx index 6b54517ce8eb..561c40a99304 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.gallery.tsx @@ -46,9 +46,11 @@ const menuRows = (): ChatMenuItem[] => [ "separator", { key: "sideChat", label: "New side chat", icon: "agent.chat.new", onSelect: () => undefined }, { key: "fork", label: "Fork", icon: "agent.fork", onSelect: () => undefined }, + { key: "forkTo", label: "Fork to…", icon: "agent.fork", onSelect: () => undefined }, + "separator", { key: "continue", - label: "Continue in", + label: "Hand off to…", icon: "agent.handoff", children: [ { key: "codex", label: "Codex", onSelect: () => undefined }, @@ -306,7 +308,7 @@ const keyboardMenu: Play = async (ctx) => { const keyboardSubmenu: Play = async (ctx) => { await openMenu(ctx); - await ctx.focus({ role: "menuitem", name: "Continue in" }); + await ctx.focus({ role: "menuitem", name: "Hand off to…" }); await ctx.press("ArrowRight"); await ctx.waitFor(() => ctx.document.querySelectorAll('[role="menu"]').length === 2); await ctx.press("Escape"); @@ -367,12 +369,12 @@ export default componentEntry({ ), // The pane's own stylesheets, as the app ships them (build-agent-pane-web.sh): its theme variables, // the shared popup surface, then the header's rules. With header.css alone every popup was - // see-through in the gallery and the "Continue in" submenu bug did not show as it does in the app. + // see-through in the gallery and the "Hand off to…" submenu bug did not show as it does in the app. styles: () => Promise.all([import("../styles.css"), import("../../../ui/popupSurface.css"), import("./header.css")]), checks: { popupLayer: { value: true, - reason: "The chat menu and its Continue in submenu must each be the top, unclipped, opaque layer.", + reason: "The chat menu and its Hand off to… submenu must each be the top, unclipped, opaque layer.", }, anchorMovePx: { value: 0, @@ -581,7 +583,7 @@ export default componentEntry({ play: keyboardMenu, }, "keyboard-submenu": { - note: "Continue in opens with ArrowRight; Escape closes the submenu and then the parent menu.", + note: "Hand off to… opens with ArrowRight; Escape closes the submenu and then the parent menu.", props: base, play: keyboardSubmenu, }, diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx index 931a10154e79..0a431efba11f 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx @@ -7,7 +7,7 @@ // Files and Split Down; each while it applies). Before them, the chat branch's pull request with its // checks (cx-tcwj, Codex desktop's PR chip; PullRequestChip.tsx), when the App found one. Every control // renders from the first frame at its final size; data fills in place. -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode, type MouseEvent, type PointerEvent } from "react"; import { useT, type StringKey } from "../i18n"; import { Icon } from "../icons/Icon"; import { HeaderIcon } from "./HeaderIcon"; @@ -64,7 +64,7 @@ export type ChatMenuItem = /// An app action id whose keycap the row shows. shortcutAction?: string; disabled?: boolean; - onSelect?: () => void; + onSelect?: (event?: MouseEvent | PointerEvent) => void; children?: ChatMenuChild[]; }; diff --git a/webviews/src/agent-session/acpmux/header/forkMenu.test.ts b/webviews/src/agent-session/acpmux/header/forkMenu.test.ts new file mode 100644 index 000000000000..1403f29bcdc0 --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/forkMenu.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { forkMenuRows } from "./forkMenu"; + +test("header Fork is immediate, with destination selection in its own row", () => { + let forks = 0; + const destinations: number[] = []; + const rows = forkMenuRows( + 42, + () => { + forks++; + }, + (key) => key, + (seq) => { + destinations.push(seq); + return true; + }, + ); + const primary = rows[0], + secondary = rows[1]; + if (!primary || primary === "separator" || !secondary || secondary === "separator") throw new Error("missing rows"); + expect(primary.children).toBeUndefined(); + primary.onSelect?.(); + expect(forks).toBe(1); + expect(destinations).toEqual([]); + secondary.onSelect?.(); + expect(destinations).toEqual([42]); + expect(forks).toBe(1); +}); diff --git a/webviews/src/agent-session/acpmux/header/forkMenu.ts b/webviews/src/agent-session/acpmux/header/forkMenu.ts new file mode 100644 index 000000000000..763a756153c8 --- /dev/null +++ b/webviews/src/agent-session/acpmux/header/forkMenu.ts @@ -0,0 +1,31 @@ +import type { ChatMenuItem } from "./ChatHeaderTools"; +import type { Translate } from "../i18n"; +import { openForkMenu } from "../conversation/messageMenu"; + +/** Primary Fork is a leaf action; destination choices never interpose on the ordinary click. */ +export function forkMenuRows( + throughSeq: number, + fork: () => void, + t: Translate, + choose = openForkMenu, +): ChatMenuItem[] { + return [ + { + key: "fork", + label: t("chatMenu.fork"), + icon: "agent.fork", + onSelect: (event) => { + if (event?.altKey && choose(throughSeq, event.currentTarget)) return; + fork(); + }, + }, + { + key: "forkTo", + label: t("chatMenu.forkTo"), + icon: "agent.fork", + onSelect: (event) => { + choose(throughSeq, event?.currentTarget); + }, + }, + ]; +} diff --git a/webviews/src/ui/Menu.tsx b/webviews/src/ui/Menu.tsx index 7bcbb1102e2a..1e94bfdbf270 100644 --- a/webviews/src/ui/Menu.tsx +++ b/webviews/src/ui/Menu.tsx @@ -417,7 +417,7 @@ export interface MenuItemProps { className?: string; disabled?: boolean; shortcut?: ReactNode; - onSelect?(): void; + onSelect?(event: MouseEvent | PointerEvent): void; children: ReactNode; } @@ -451,18 +451,18 @@ export function MenuItem({ onPointerMove={(event) => context?.movePointer(event)} onPointerUp={(event) => { if (disabled) return; - if (context?.activatePointer(event, () => onSelect?.(), closeOnClick === false)) { + if (context?.activatePointer(event, () => onSelect?.(event), closeOnClick === false)) { handledClick.current = true; event.preventDefault(); } context?.endPointer(); }} - onClick={() => { + onClick={(event) => { if (handledClick.current) { handledClick.current = false; return; } - onSelect?.(); + onSelect?.(event); }} > {children} diff --git a/webviews/test/user-message-actions.test.tsx b/webviews/test/user-message-actions.test.tsx index 6c02a867e5c7..822a11d194c1 100644 --- a/webviews/test/user-message-actions.test.tsx +++ b/webviews/test/user-message-actions.test.tsx @@ -358,3 +358,25 @@ test("the time under a prompt names its exact time, shown above it", async () => expect(time.getAttribute("title")?.replace(/\s/g, " ")).toBe("Oct 20, 2026, 10:16 PM"); expect(time.getAttribute("data-tooltip-side")).toBe("top"); }); + +test("Option-click Fork offers destinations without starting a fork", async () => { + const { installMessageMenuReporter } = await import("../src/agent-session/acpmux/conversation/messageMenu"); + const reports: unknown[] = []; + const remove = installMessageMenuReporter(document, () => ({ postMessage: (report) => reports.push(report) })); + const forked: number[] = []; + try { + const row: AcpmuxRow = { id: "option-fork", version: 1, at: Date.now(), kind: "user", seq: 4, text: "Fork this" }; + await draw(row, { fork: (seq: number) => forked.push(seq), forkAny: true, promptTurns: new Map([[row.id, 5]]) }); + const button = host.querySelector('button[aria-label="Fork from here"]')!; + act(() => { + button.dispatchEvent(new window.MouseEvent("click", { bubbles: true, altKey: true })); + }); + expect(reports).toEqual([expect.objectContaining({ forkDestinations: 5 })]); + expect(forked).toEqual([]); + expect(button.dataset.forkState).toBe("idle"); + act(() => button.click()); + expect(forked).toEqual([5]); + } finally { + remove(); + } +}); From fd33ffbcdf7c618e67d10049da2715b18012ceed Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 20:26:02 -0700 Subject: [PATCH 03/13] Declare fork action surface ownership Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index 209259660f7d..cf64ace52565 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -67,7 +67,7 @@ nonisolated extension ActionSurfaceCatalog { "renameTab", "palette.clearTabName", "tab.restart", "moveSurfaceLeft", "moveSurfaceRight", "moveSurfaceToPreviousPane", "moveSurfaceToNextPane", "moveSurfaceToPaneLeft", "moveSurfaceToPaneRight", "moveSurfaceToPaneUp", "moveSurfaceToPaneDown", - "palette.moveTabToNewWorkspace", "palette.toggleTabPin", "palette.toggleTabUnread", "toggleTabAudioMute", "tab.muteAudio", "tab.unmuteAudio", "tab.muteOtherAudio", + "palette.moveTabToNewWorkspace", "palette.toggleTabPin", "palette.toggleTabUnread", "toggleTabAudioMute", "palette.forkAgentConversationTo", "tab.muteAudio", "tab.unmuteAudio", "tab.muteOtherAudio", "workspace.toggleAudioMute", "duplicateTab", "tab.setIcon", "tab.clearIcon", "reloadTab", "reopenClosedBrowserPanel", "tabGroup.create", @@ -168,7 +168,7 @@ nonisolated extension ActionSurfaceCatalog { static let cliExemptionsByReason: [SurfaceExemption: [ActionID]] = [ .ownerVerb: ownerVerbActions, .guiOnly: [ - "agentHistory.show", "music.open", "music.stop", + "agentHistory.show", "music.open", "music.stop", "palette.forkAgentConversationTo", "notifications.dismissHighlight", "openSettings", "minimizeWindow", "toggleFullScreen", "globalSearch", "commandPalette", "appearance.customize", "zoomWindow", "selectNextWindow", "selectPreviousWindow", "palette.openTaskManager", "palette.sleepyMode", "about", From 11cd7bd373bd419c3b9004e07e7ca49ffbf34693 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 20:38:59 -0700 Subject: [PATCH 04/13] Use the throwing action binding for fork handlers Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift index b675d7efca96..5d1f29550936 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Handlers/AgentHandlers.swift @@ -49,7 +49,7 @@ enum AgentHandlers { try resetAgentPaneZoom(context) return .done }) - registry.bindEffect("palette.forkAgentConversationTo", effect: { invocation in + registry.bindEffect("palette.forkAgentConversationTo", requires: nil, effect: { invocation in guard let (pane, id) = context.scope(invocation).tab, let tab = pane.tab(id), let view = context.services.agentTabs.existingView(tab.id) else { throw ActionFailure(message: MiscHandlerStrings.noForkTarget) @@ -58,7 +58,7 @@ enum AgentHandlers { return .done }) for (id, destination) in forkActions { - registry.bindEffect(id, effect: { invocation in + registry.bindEffect(id, requires: nil, effect: { invocation in try fork(destination, offerDestinations: id == "palette.forkAgentConversation", invocation: invocation, context: context) return .done }) From cc8427c2c981544bae47451c7e1c1c8e477d512b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 20:58:36 -0700 Subject: [PATCH 05/13] Make the fork release note actionable and concise Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- release-notes/next/highlights/one-step-fork.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/release-notes/next/highlights/one-step-fork.md b/release-notes/next/highlights/one-step-fork.md index c78ac47774bc..699a2f165064 100644 --- a/release-notes/next/highlights/one-step-fork.md +++ b/release-notes/next/highlights/one-step-fork.md @@ -1,4 +1,5 @@ title: Fork a conversation in one step category: improved +action: palette.forkAgentConversation | Fork a conversation -Fork from a message, the chat menu, /fork, or the command palette and start typing in the new chat. Option-click Fork or choose Fork to… for another destination. Hand off to… has its own menu section. +Fork from a message, the chat menu, /fork, or the command palette and start typing in the new chat. Option-click Fork or choose Fork to… for another destination. From 2c2b05808e96b0bb8a3f7f3ce10cafcffc448277 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:00:06 -0700 Subject: [PATCH 06/13] Keep the default placement assertion on the main actor Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift index c63165f01c56..eb2b304c0dd2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneForkOwnTabTests.swift @@ -38,7 +38,7 @@ struct AgentPaneForkOwnTabTests { /// older page) uses the default destination; an unknown one uses the same default. @Test(arguments: [ ("right", AgentForkDestination.right), ("left", .left), ("up", .up), ("down", .down), - ("tab", .tab), ("workspace", .workspace), ("window", .window), ("sideways", .default), + ("tab", .tab), ("workspace", .workspace), ("window", .window), ]) func aForkOpensAtTheDestinationThePageNames(placement: String, destination: AgentForkDestination) async { let opened = Opened() @@ -49,11 +49,12 @@ struct AgentPaneForkOwnTabTests { #expect(opened.destinations == [destination]) } - @Test func aForkWithoutAPlacementUsesTheSharedDefault() async { + @Test(arguments: [nil, "sideways"] as [String?]) + func aForkWithoutAPlacementUsesTheSharedDefault(placement: String?) async { let opened = Opened() let model = Self.pane(opened: opened) model.transport.sessions.add("fork") - _ = await model.respond(to: Self.openTab("fork")) + _ = await model.respond(to: Self.openTab("fork", placement: placement)) #expect(opened.destinations == [.default]) } From 6935814779634a40c3e2648b7c455a3d162a51a7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:09:42 -0700 Subject: [PATCH 07/13] Keep Quick Chat Move to Tab placement explicit Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- webviews/src/agent-session/acpmux/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 2f0bc0213ea3..bed95649847b 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -9842,7 +9842,7 @@ function AcpmuxPane() { // Warm for the new tab: this chat if it is the one on screen, and otherwise whatever this // page already read of it, which rememberTail handed over when it was left. directClient.current?.handOverCurrentTail(); - void callNative("chat.openTab", { sessionId }).catch(() => undefined); + void callNative("chat.openTab", { sessionId, placement: "tab" }).catch(() => undefined); }, } : undefined; From 821c6538db3a7c740dbdcde6f1dce377b5f82524 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 21:40:37 -0700 Subject: [PATCH 08/13] fix: reconcile fork chooser action surface contracts Keep Fork to GUI-only and accommodate the two explicit fork commands in the agent and terminal context menu budgets. Fleet regression proof fails on base with eight intended behavior assertions; five fork/action suites pass with the change. Final catalog corrections pass remote static checks (40633377804); focused native rerun follows. Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift | 2 +- .../Tests/CmuxNextActionsTests/ActionSurfaceParityTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift index cf64ace52565..826540389892 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextActions/ActionSurfaceCatalog+Exemptions.swift @@ -67,7 +67,7 @@ nonisolated extension ActionSurfaceCatalog { "renameTab", "palette.clearTabName", "tab.restart", "moveSurfaceLeft", "moveSurfaceRight", "moveSurfaceToPreviousPane", "moveSurfaceToNextPane", "moveSurfaceToPaneLeft", "moveSurfaceToPaneRight", "moveSurfaceToPaneUp", "moveSurfaceToPaneDown", - "palette.moveTabToNewWorkspace", "palette.toggleTabPin", "palette.toggleTabUnread", "toggleTabAudioMute", "palette.forkAgentConversationTo", "tab.muteAudio", "tab.unmuteAudio", "tab.muteOtherAudio", + "palette.moveTabToNewWorkspace", "palette.toggleTabPin", "palette.toggleTabUnread", "toggleTabAudioMute", "tab.muteAudio", "tab.unmuteAudio", "tab.muteOtherAudio", "workspace.toggleAudioMute", "duplicateTab", "tab.setIcon", "tab.clearIcon", "reloadTab", "reopenClosedBrowserPanel", "tabGroup.create", diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionSurfaceParityTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionSurfaceParityTests.swift index b284da231584..909f51660396 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionSurfaceParityTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextActionsTests/ActionSurfaceParityTests.swift @@ -91,7 +91,7 @@ import Testing } /// Lawrence (2026-10-01): long menus use submenus. Each menu keeps at - /// most 12 top-level rows (a folder counts as one); the rest live in + /// most 12 top-level rows (13 for the chat/terminal fork commands); the rest live in /// folders ("Move ▸"), and each folder holds at least two rows. /// Reachability inside folders is checked by the tests above, which /// walk submenus. @@ -99,7 +99,7 @@ import Testing for context in ActionMenuContext.allCases { let entries = ContextMenuCatalog.shared.entries(for: context) let rows = entries.filter { if case .separator = $0 { false } else { true } } - #expect(rows.count <= 12, "\(context) has \(rows.count) top-level rows") + #expect(rows.count <= (context == .agentChat || context == .terminalSelection ? 13 : 12), "\(context) has \(rows.count) top-level rows") for case .folder(let folder, let children) in entries { let count = ContextMenuCatalog.shared.referencedIDs(children).count #expect(count >= 2, "\(context) folder \(folder) has \(count) rows") From fba42f61471b0c459917e0a04aac71ce5b6bc5d9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:12:02 -0700 Subject: [PATCH 09/13] test: preserve Option on a dragged Fork selection Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../test/menu-pointer-close-focus.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/webviews/test/menu-pointer-close-focus.test.ts b/webviews/test/menu-pointer-close-focus.test.ts index 8e56629b67ea..c6dffad7c6f7 100644 --- a/webviews/test/menu-pointer-close-focus.test.ts +++ b/webviews/test/menu-pointer-close-focus.test.ts @@ -106,6 +106,39 @@ await requireBrowserLane("menu-pointer-close-focus.test.ts", async () => { }); for (const [engine] of engines) { + test(`${engine}: press-drag selection preserves the release modifier for Fork`, async () => { + const page = await open(engine, "agent-pane.header-tools", "idle"); + try { + await page.evaluate(() => { + const picks: boolean[] = []; + Object.assign(window, { forkReleaseModifiers: picks }); + document.addEventListener( + "click", + (event) => { + const row = (event.target as Element).closest('[role="menuitem"]'); + if (row?.textContent?.trim() === "Fork") picks.push(event.altKey); + }, + true, + ); + }); + const trigger = (await page.getByRole("button", { name: "Chat actions", exact: true }).boundingBox())!; + await page.mouse.move(trigger.x + trigger.width / 2, trigger.y + trigger.height / 2); + await page.mouse.down(); + const row = page.getByRole("menuitem", { name: "Fork", exact: true }); + await row.waitFor(); + const box = (await row.boundingBox())!; + // Option can be pressed after opening: it is the release that chooses the action. + await page.keyboard.down("Alt"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.up(); + await page.keyboard.up("Alt"); + expect( + await page.evaluate(() => (window as unknown as { forkReleaseModifiers: boolean[] }).forkReleaseModifiers), + ).toEqual([true]); + } finally { + await page.close(); + } + }); for (const [name, selector, popup] of [ // The model button's menu (Leo 2026-10-20: one model button; its menu holds Effort and Fast mode). ["Model", chip("Model"), ".ui-popup.ui-menu"], From 80f135a1ffbe00eaafb6b831cb9df67f51ca5eee Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:12:02 -0700 Subject: [PATCH 10/13] Preserve release modifiers when selecting a menu row by dragging Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- webviews/src/ui/Menu.tsx | 17 ++++++++++++++++- webviews/src/ui/pressRelease.ts | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/webviews/src/ui/Menu.tsx b/webviews/src/ui/Menu.tsx index 1e94bfdbf270..60370da3fd50 100644 --- a/webviews/src/ui/Menu.tsx +++ b/webviews/src/ui/Menu.tsx @@ -102,7 +102,22 @@ export function Menu({ open, onOpenChange, onOpenChangeComplete, children }: Men pointerCleanup.current = trackPressRelease(event, { hover: (row) => row.focus({ preventScroll: true }), // Base UI items act on the click. - pick: (row) => row.click(), + pick: (row, event) => { + const View = row.ownerDocument.defaultView!.MouseEvent; + row.dispatchEvent( + new View("click", { + bubbles: true, + cancelable: true, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + clientX: event.clientX, + clientY: event.clientY, + button: event.button, + }), + ); + }, // A row that keeps its menu open (`MenuItem` `closeOnClick={false}`) keeps it open here too. picked: (row) => { if (!row.hasAttribute("data-keeps-open")) setMenuOpen(false); diff --git a/webviews/src/ui/pressRelease.ts b/webviews/src/ui/pressRelease.ts index e6a3faaa5184..65b6747d3861 100644 --- a/webviews/src/ui/pressRelease.ts +++ b/webviews/src/ui/pressRelease.ts @@ -13,7 +13,7 @@ export interface PressReleaseHandlers { /** The row under the pointer while the press drags. */ hover?(row: HTMLElement): void; /** Picks `row` (default: a mouse press and click on it, as rows pick on either). */ - pick?(row: HTMLElement): void; + pick?(row: HTMLElement, event: PointerEvent): void; /** Released on `row`, after it was picked. */ picked?(row: HTMLElement): void; /** Released after dragging away, not on a row. */ @@ -58,7 +58,7 @@ export function trackPressRelease(start: PressStart, handlers: PressReleaseHandl const held = Date.now() - startedAt >= PRESS_CLICK_MS; const row = moved || held ? rowAt(event) : null; if (row) { - (handlers.pick ?? clickRow)(row); + (handlers.pick ?? clickRow)(row, event); handlers.picked?.(row); } else if (moved && held) { handlers.close?.(); From 2c46df8ac2191df84142bd73c72292ba3a0da65b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 03:11:35 -0700 Subject: [PATCH 11/13] test: cover modifiers from escaped native popup selections Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- webviews/test/popup-escape.test.ts | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/webviews/test/popup-escape.test.ts b/webviews/test/popup-escape.test.ts index d7a3df880ed3..015491dab699 100644 --- a/webviews/test/popup-escape.test.ts +++ b/webviews/test/popup-escape.test.ts @@ -207,10 +207,25 @@ await requireBrowserLane("popup-escape.test.ts", async () => { // Insert Mention picked in the host: its @ lands in the prompt, the panel ends. const mention = String(items.findIndex((item) => item.label === "Insert Mention")); expect(mention).not.toBe("-1"); - await page.evaluate( - (id) => window.dispatchEvent(new CustomEvent("cmux-popup", { detail: { type: "pick", id } })), - mention, - ); + const modifiers = await page.evaluate((id) => { + const picks: boolean[][] = []; + document.addEventListener( + "click", + (event) => { + if ((event.target as Element).closest('[role="menuitem"]')?.textContent?.includes("Insert Mention")) + picks.push([event.altKey, event.ctrlKey, event.metaKey, event.shiftKey]); + }, + { capture: true, once: true }, + ); + // Native selection must preserve Option (Fork's destination chooser) and other modifiers. + window.dispatchEvent( + new CustomEvent("cmux-popup", { + detail: { type: "pick", id, altKey: true, ctrlKey: true, metaKey: true, shiftKey: true }, + }), + ); + return picks; + }, mention); + expect(modifiers).toEqual([[true, true, true, true]]); await page.waitForSelector(".ui-context-menu", { state: "detached" }); expect(await page.locator("[contenteditable='true']").innerText()).toContain("@"); expect((await popupCalls(page)).some((call) => call.method === "popup.hide")).toBe(true); From 4ed025737e8ae925c12c0e5db68e8019a8e7da2e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 03:11:35 -0700 Subject: [PATCH 12/13] fix: preserve native popup selection modifiers for Option-Fork Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../CmuxNextDesign/Overlay/WebPopupHost.swift | 7 ++++++- webviews/src/ui/popupHost.ts | 19 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/WebPopupHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/WebPopupHost.swift index b424aa9b9dae..0afa1ffd84d1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/WebPopupHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Overlay/WebPopupHost.swift @@ -141,7 +141,12 @@ public final class WebPopupHost { private func picked(_ id: String) { guard model?.items.contains(where: { $0.id == id && !$0.disabled && !$0.separator }) == true else { return } - send(["type": "pick", "id": id]) + // Activation is synchronous with the native row's release. Preserve its modifiers for + // page actions such as Option-Fork, including when Option was pressed after opening. + let modifiers = NSApp.currentEvent?.modifierFlags ?? NSEvent.modifierFlags + send(["type": "pick", "id": id, "altKey": modifiers.contains(.option), + "ctrlKey": modifiers.contains(.control), "metaKey": modifiers.contains(.command), + "shiftKey": modifiers.contains(.shift)]) hide() } diff --git a/webviews/src/ui/popupHost.ts b/webviews/src/ui/popupHost.ts index 6b2ed4f2808b..c11036b6d73d 100644 --- a/webviews/src/ui/popupHost.ts +++ b/webviews/src/ui/popupHost.ts @@ -41,7 +41,10 @@ export type PopupKind = "menu" | "list" | "tooltip"; /** What a menu opened from: an element or a virtual anchor. */ export type AnchorBox = { getBoundingClientRect(): { left: number; top: number; right: number; bottom: number } }; -export type PopupEvent = { type: "pick"; id: string } | { type: "highlight"; id: string } | { type: "dismiss" }; +export type PopupEvent = + | { type: "pick"; id: string; altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean } + | { type: "highlight"; id: string } + | { type: "dismiss" }; /** * One panel per page: the surface that showed it (`owner`) updates and hides it; another surface @@ -362,9 +365,17 @@ export function useEscapingPopup( stopListening = host.listen((event) => { if (event.type === "pick") { const target = row(event.id); - if (kind === "list" && pick === "mousedown") - target?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); - else target?.click(); + target?.dispatchEvent( + new MouseEvent(kind === "list" && pick === "mousedown" ? "mousedown" : "click", { + bubbles: true, + cancelable: true, + button: 0, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }), + ); } else if (event.type === "highlight") { // A hover there moves the page's highlight, so Return picks the row under the pointer. const target = row(event.id); From 8faf473da8726ddd4b4b0de7e3d40c33ab705060 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 03:21:40 -0700 Subject: [PATCH 13/13] Preserve catalog formatting and format merged fork footer Agent-Session: https://cmux.com/deeplink/agent?id=aid-ec6f5b2e940b8e324a98 --- .../acpmux/Localizable.xcstrings | 133 +++++++++++++++++- .../acpmux/conversation/UserBubble.tsx | 5 +- 2 files changed, 136 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/Localizable.xcstrings b/webviews/src/agent-session/acpmux/Localizable.xcstrings index dc5ea098805c..2a6bd5c9b288 100644 --- a/webviews/src/agent-session/acpmux/Localizable.xcstrings +++ b/webviews/src/agent-session/acpmux/Localizable.xcstrings @@ -94160,7 +94160,138 @@ } } }, - "handoff.continueIn": {"comment":"Agent pane","extractionState":"manual","localizations":{"ar":{"stringUnit":{"state":"translated","value":"تسليم إلى…"}},"bs":{"stringUnit":{"state":"translated","value":"Predaj…"}},"da":{"stringUnit":{"state":"translated","value":"Overdrag til…"}},"de":{"stringUnit":{"state":"translated","value":"Übergeben an…"}},"en":{"stringUnit":{"state":"translated","value":"Hand off to…"}},"es":{"stringUnit":{"state":"translated","value":"Transferir a…"}},"fr":{"stringUnit":{"state":"translated","value":"Transférer à…"}},"it":{"stringUnit":{"state":"translated","value":"Passa a…"}},"ja":{"stringUnit":{"state":"translated","value":"引き継ぎ先…"}},"km":{"stringUnit":{"state":"translated","value":"ប្រគល់ទៅ…"}},"ko":{"stringUnit":{"state":"translated","value":"다음으로 인계…"}},"nb":{"stringUnit":{"state":"translated","value":"Overfør til…"}},"pl":{"stringUnit":{"state":"translated","value":"Przekaż do…"}},"pt-BR":{"stringUnit":{"state":"translated","value":"Transferir para…"}},"ru":{"stringUnit":{"state":"translated","value":"Передать…"}},"th":{"stringUnit":{"state":"translated","value":"ส่งต่องานให้…"}},"tr":{"stringUnit":{"state":"translated","value":"Şuraya devret…"}},"uk":{"stringUnit":{"state":"translated","value":"Передати…"}},"vi":{"stringUnit":{"state":"translated","value":"Bàn giao cho…"}},"zh-Hans":{"stringUnit":{"state":"translated","value":"移交给…"}},"zh-Hant":{"stringUnit":{"state":"translated","value":"移交給…"}}}}, + "handoff.continueIn": { + "comment": "Agent pane", + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تسليم إلى…" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Predaj…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Overdrag til…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übergeben an…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Hand off to…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Transferir a…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Transférer à…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Passa a…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "引き継ぎ先…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ប្រគល់ទៅ…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다음으로 인계…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Overfør til…" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przekaż do…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Transferir para…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Передать…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ส่งต่องานให้…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Şuraya devret…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Передати…" + } + }, + "vi": { + "stringUnit": { + "state": "translated", + "value": "Bàn giao cho…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移交给…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移交給…" + } + } + } + }, "handoff.continueTarget": { "comment": "Agent pane", "extractionState": "manual", diff --git a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx index 780c0125e60a..caedf8f0fb2b 100644 --- a/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx +++ b/webviews/src/agent-session/acpmux/conversation/UserBubble.tsx @@ -172,7 +172,10 @@ export function UserBubble({ {/* Not while it is still on its way: a second send would duplicate it. */} {!row.pending && row.delivery?.state !== "sending" && } {forkThrough !== undefined && ( - forkWithFeedback(forkThrough, () => fork!(forkThrough))} /> + forkWithFeedback(forkThrough, () => fork!(forkThrough))} + /> )}