From 826a8e890b8e6abe3e7c083bf4d818ee460b504d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:10:13 +0000 Subject: [PATCH 01/30] Add native agent tab row tools protocol Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextAgentPane/AgentPageEvent.swift | 27 ++++++ .../CmuxNextAgentPane/AgentPageProvider.swift | 2 +- .../AgentPaneModel+Header.swift | 4 + .../CmuxNextAgentPane/AgentPaneModel.swift | 14 ++- .../CmuxNextAgentPane/AgentPanePageHost.swift | 1 + .../AgentPaneRequest+Decoding.swift | 3 + .../AgentPaneRequest+SafeDescription.swift | 1 + .../CmuxNextAgentPane/AgentPaneRequest.swift | 2 + .../CmuxNextAgentPane/AgentPaneTabRow.swift | 12 ++- .../AgentPaneTabRowTools.swift | 68 ++++++++++++++ .../CmuxNextAgentPane/AgentPaneView.swift | 7 ++ .../Sources/CmuxNextApp/PaneContentView.swift | 5 +- .../Sources/CmuxNextApp/PaneToolbar.swift | 16 ++-- .../CmuxNextDesign/PaneTabRowTool.swift | 43 +++++++++ .../CmuxNextDesign/TitlebarBandButton.swift | 16 ++++ .../Tunables/ChromeTunables.swift | 7 +- .../CmuxNextTabs/PaneToolbarView.swift | 88 ++++++++++++++++--- .../AgentPaneTabRowToolsTests.swift | 30 +++++++ 18 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift index 48524bfe66d4..8670657688f1 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift @@ -71,6 +71,33 @@ public nonisolated struct AgentPageEvent: Equatable, Sendable { /// A dispatcher command (`searchChats`, `continueIn`, `createCheckpoint`, `permissionAllowOnce`, ...). public static func command(_ name: String) -> AgentPageEvent { AgentPageEvent(kind: "command", value: .string(name)) } + /// The current declarative native tab-row controls. + public static func tabRowTools(_ tools: [PaneTabRowTool]) -> AgentPageEvent { + let value = tools.map { tool in + var object: [String: Any] = ["id": tool.id, "symbol": tool.symbol, "label": tool.label, + "enabled": tool.enabled, "toggled": tool.toggled] + if let badge = tool.badge { object["badge"] = badge } + if !tool.menu.isEmpty { object["menu"] = tool.menu.map(menuObject) } + return object + } + return AgentPageEvent(kind: "tabRowTools", value: JSONValue(foundation: value) ?? .array([])) + } + + /// A native tab-row control or one of its menu items was invoked. + public static func tabRowToolInvoked(id: String, menuItemId: String? = nil) -> AgentPageEvent { + var value: [String: Any] = ["id": id] + if let menuItemId { value["menuItemId"] = menuItemId } + return AgentPageEvent(kind: "tabRowToolInvoked", value: JSONValue(foundation: value) ?? .null) + } + + private static func menuObject(_ item: PaneTabRowMenuItem) -> [String: Any] { + var object: [String: Any] = ["id": item.id, "label": item.label, "enabled": item.enabled] + if let checked = item.checked { object["checked"] = checked } + if let role = item.role { object["role"] = role } + if !item.children.isEmpty { object["children"] = item.children.map(menuObject) } + return object + } + /// A batch of the host socket's frames, or its close (``AgentPaneTransport``, bridgeSocket.ts). public static func transport(_ event: AgentPaneTransportEvent) -> AgentPageEvent { AgentPageEvent(kind: "transport", value: JSONValue(foundation: event.object) ?? .null) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index 81cadb943aa0..6ac6ade4a66f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift @@ -35,7 +35,7 @@ public nonisolated struct AgentPageOps { "pane.edit", "pane.setQuickActions", "tab.open", "run.attach", "tab.typeAhead", "tab.jump", "newTab.remember", "newTab.setTemplate", "newTab.editWidgets", "nowPlaying.command", "newTab.inputReady", "newTab.touched", "newTab.context", "newTab.warm", "shortcut.edit", "action.run", "file.open", "browser.open", "project.list", "project.icon", "agent.avatar", "agent.avatar.set", "agent.avatar.remove", "harness.symbol", "project.browse", "workspace.chooseFolder", "workspace.useFolder", "chat.folder.choose", "chat.attach.choose", "chat.attach.dropped", "app.action", "chats.open", "chats.openInTerminal", "chats.watch", "chats.page", - "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending", + "quick.dismiss", "quick.openInWindow", "quick.startInBackground", "quick.setOnTop", "quick.setCollapsed", "quick.floatingState", "quick.context", "pane.action", "pane.tabState", "pane.tabRowTools", "pane.tabRowTools.set", "chat.toolCard.respond", "chat.upload", "chat.sideChat", "chat.subagentPane", "chat.processTerminal", "chat.openTab", "chat.offer.open", "chat.asks.open", "chat.asks.answer", "chat.newHere", "chat.unsentDraft", "chat.scrollAnchor", "chat.scrollAnchor.get", "chat.tail", "chat.tail.get", "chat.draftAttachments", "chat.draftAttachments.get", "chat.asks.pending", "shell.run", "shell.read", "shell.stop", "harness.update", "harness.restart", "git.diff", "git.status", "git.commit", "git.push", "git.worktree.create", "git.worktree.apply", "git.worktree.remove", "git.githubRepository", "file.search", "git.checkpoint.diff", "turn.undo", "git.stage", "git.unstage", "git.stageHunk", "git.unstageHunk", "git.pullRequest", "project.instructions", "project.addFiles", "composer.capture", "dictation.toggle", "dictation.start", "dictation.stop", "dictation.cancel", "dictation.openSettings", "dictation.audio", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index cdf09a557626..88b4a32bc31f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift @@ -166,6 +166,10 @@ extension AgentPaneModel { case .tabRowTools(let width): tabRow.report(width: CGFloat(width)) // used only while chrome.tabs.rowTools is on and the pane offered the row return AgentPaneReply.success() + case .tabRowToolsSet(let tools): + guard ChromeTunables.nativeRowTools.value else { return AgentPaneReply.success() } + setTabRowTools(tools) + return AgentPaneReply.success() case .tabState(let refresh): guard let header else { return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: pane.tabState") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 46b4134198f6..41623b434352 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -99,6 +99,10 @@ public final class AgentPaneModel { @ObservationIgnored public var header: AgentPaneHeaderHooks? /// The header's tools in the pane's tab row (``AgentPaneTabRow``, `ChromeTunables.tabRowTools`). @ObservationIgnored public let tabRow = AgentPaneTabRow() + /// Declarative native controls published by the page for the shared tab row. + public private(set) var tabRowTools: [PaneTabRowTool] = [] + @ObservationIgnored public var onTabRowToolsChange: (() -> Void)? + @ObservationIgnored public var onTabRowToolInvoked: ((String, String?) -> Void)? /// Gets the composer's dictation requests (the pane's mic). @ObservationIgnored public var onDictation: ((AgentPaneDictationCommand) -> Void)? /// Opens a changed file the page names; false when it could not. @@ -283,7 +287,7 @@ public final class AgentPaneModel { // Boot traffic, and a request the host refused (it changed nothing), leave it untouched. case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported, .transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext, - .tabRowTools, .projectIcon, .agentAvatar, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break + .tabRowTools, .tabRowToolsSet, .projectIcon, .agentAvatar, .harnessSymbol, .environment(.list), .cloudMachine(.list), .sandboxState: break case .reply(let reply) where reply.isPassive: break default: if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) } @@ -437,7 +441,7 @@ public final class AgentPaneModel { // Preserve the original click's one-use, session/prompt-scoped grant and expiry. released = true return AgentPaneReply.success(["transferred": true, "promptId": request.retryPromptID, "ticket": ticket]) - case .paneAction, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .asksAnswer, .newChatHere: return await respondToHeader(request) + case .paneAction, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .tabRowToolsSet, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .asksAnswer, .newChatHere: return await respondToHeader(request) case .appAction(let id): guard newTab?.omnibar.actions.contains(where: { $0.id == id }) == true, let onAppAction else { return Self.unsupported("app.action") } onAppAction(id) @@ -485,6 +489,12 @@ public final class AgentPaneModel { onSessionChange?(id) } + func setTabRowTools(_ tools: [PaneTabRowTool]) { + guard tabRowTools != tools else { return } + tabRowTools = tools + onTabRowToolsChange?() + } + func setCheckpointAvailable(_ available: Bool) { guard checkpointAvailable != available else { return } checkpointAvailable = available diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift index b8f9f6c0dfc2..a4a5d7b7b1f0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift @@ -40,6 +40,7 @@ enum AgentPanePageHost { events.append(.display(view.model.display)) events.append(.toolCards(view.model.toolCards)) events.append(view.model.tabRow.event) + events.append(.tabRowTools(view.model.tabRowTools)) if !view.customization.isEmpty { events += AgentPageEvent.customization(view.customization) } return events } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 5e989e5f6623..5ef7d6238075 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -345,6 +345,9 @@ extension AgentPaneRequest { } else { self = .unsupported(method) } + case "pane.tabRowTools.set": + if let tools = AgentPaneTabRowTools.list(params?["tools"]) { self = .tabRowToolsSet(tools) } + else { self = .unsupported(method) } case "pane.setQuickActions": if let list = AgentPaneQuickAction.list(params?["quickActions"]) { self = .setQuickActions(list) } else { self = .unsupported(method) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift index 9d249b8b2d76..ba866a594946 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift @@ -67,6 +67,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl case .setQuickActions: "setQuickActions" case .tabState: "tabState" case .tabRowTools: "tabRowTools" + case .tabRowToolsSet: "tabRowToolsSet" case .sideChat: "sideChat" case .chatTab: "chatTab" case .offerOpen: "offerOpen" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 6e8714c6dedd..40fbc99bb293 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -166,6 +166,8 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { /// `pane.tabRowTools {width}`: the chat header's tools take `width` CSS pixels at the trailing /// end of the pane's tab row (``AgentPaneTabRow``), 0 when the page draws none there. case tabRowTools(width: Double) + /// `pane.tabRowTools.set {tools}`: declarative native controls for the shared tab row. + case tabRowToolsSet([PaneTabRowTool]) /// New side chat: open the fork `sessionId` the page just made beside this chat. case sideChat(String) /// A chat this one made (a fork, a Continue In…), opened at a destination of its own: a tab, diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift index 03a8aeac6d87..1930df499cdd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift @@ -16,6 +16,7 @@ import CmuxNextSettings public var onChange: (() -> Void)? /// The row last pushed to the page, nil before the first. private(set) var offered: (height: CGFloat, trailingInset: CGFloat)? + private var offeredNativeRowTools: Bool? /// The page reported its tools' width. func report(width: CGFloat) { @@ -30,12 +31,13 @@ import CmuxNextSettings func pageLoaded() { guard offered != nil || toolsWidth != 0 else { return } offered = nil + offeredNativeRowTools = nil toolsWidth = 0 onChange?() } /// The `tabRow` event for the row last offered (a page that subscribes again gets it first). - var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0) } + var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: ChromeTunables.nativeRowTools.value) } /// The tools' width in points at `zoom` (points per CSS pixel); nil while the setting is off or /// the page draws no tools in the row. @@ -55,6 +57,7 @@ import CmuxNextSettings public static func offer(points height: CGFloat, trailingInset: CGFloat, to view: AgentPaneView) { let zoom = max(0.25, view.webView.pageZoom) let on = ChromeTunables.tabRowTools.value && height > 0 + let nativeRowTools = ChromeTunables.nativeRowTools.value let row = view.model.tabRow let next = (height: on ? height / zoom : 0, trailingInset: on ? max(0, trailingInset) / zoom : 0) let shares = on && row.toolsWidth > 0 @@ -68,8 +71,9 @@ import CmuxNextSettings tools: row.sharedWidth(zoom: zoom) ?? 0) : [] page.nativeRects = holes.map { page.convert($0.offsetBy(dx: view.bounds.minX, dy: top), from: view) } } - guard row.offered.map({ $0 != next }) ?? (next.height > 0) else { return } + guard row.offered.map({ $0 != next || row.offeredNativeRowTools != nativeRowTools }) ?? (next.height > 0 || nativeRowTools) else { return } row.offered = next + row.offeredNativeRowTools = nativeRowTools let event = row.event view.deliver([event], scripts: ["window.cmuxAcpmuxBridge?.applyTabRow?.(\(event.value.compactText));"]) } @@ -88,8 +92,8 @@ private func nativeHoles(width: CGFloat, height: CGFloat, trailingInset: CGFloat extension AgentPageEvent { /// The tab row the chat header's tools may sit in (`tabRow`): `{height, trailingInset}`. - static func tabRow(height: CGFloat, trailingInset: CGFloat) -> AgentPageEvent { - AgentPageEvent(kind: "tabRow", value: ["height": .number(Double(height)), "trailingInset": .number(Double(trailingInset))]) + static func tabRow(height: CGFloat, trailingInset: CGFloat, nativeRowTools: Bool) -> AgentPageEvent { + AgentPageEvent(kind: "tabRow", value: ["height": .number(Double(height)), "trailingInset": .number(Double(trailingInset)), "nativeRowTools": .bool(nativeRowTools)]) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift new file mode 100644 index 000000000000..de380ffa9a19 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -0,0 +1,68 @@ +import CmuxNextDesign +import Foundation + +/// Strict decoding for the page's declarative native tab-row controls. +enum AgentPaneTabRowTools { + static let maximumTools = 16 + static let maximumMenuItems = 32 + static let maximumDepth = 2 + static let maximumText = 64 + + static func list(_ raw: Any?) -> [PaneTabRowTool]? { + guard let raw = raw as? [Any], raw.count <= maximumTools else { return nil } + var tools: [PaneTabRowTool] = [] + tools.reserveCapacity(raw.count) + for value in raw { + guard let parsed = tool(value) else { return nil } + tools.append(parsed) + } + return tools + } + + private static func tool(_ raw: Any) -> PaneTabRowTool? { + guard let object = raw as? [String: Any], + let id = text(object["id"]), let symbol = text(object["symbol"]), let label = text(object["label"]), + object["enabled"] is Bool, object["toggled"] is Bool, + let enabled = object["enabled"] as? Bool, let toggled = object["toggled"] as? Bool, + let badge = optionalText(object["badge"]), + let menu = menu(object["menu"], depth: 0) else { return nil } + return PaneTabRowTool(id: id, symbol: symbol, label: label, enabled: enabled, toggled: toggled, + badge: badge, menu: menu) + } + + private static func menu(_ raw: Any?, depth: Int) -> [PaneTabRowMenuItem]? { + guard let raw else { return [] } + guard depth <= maximumDepth, let list = raw as? [Any], list.count <= maximumMenuItems else { return nil } + let rows = list.compactMap { item($0, depth: depth) } + return rows.count == list.count ? rows : nil + } + + private static func item(_ raw: Any, depth: Int) -> PaneTabRowMenuItem? { + guard let object = raw as? [String: Any], let id = text(object["id"]), let label = text(object["label"]), + object["enabled"] is Bool, let enabled = object["enabled"] as? Bool, + let checked = optionalBool(object["checked"]), let role = optionalRole(object["role"]), + let children = menu(object["children"], depth: depth + 1) else { return nil } + return PaneTabRowMenuItem(id: id, label: label, enabled: enabled, checked: checked, role: role, children: children) + } + + private static func text(_ value: Any?) -> String? { + guard let value = value as? String, !value.isEmpty, value.count <= maximumText else { return nil } + return value + } + + private static func optionalText(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { return nil } + return text(value) + } + + private static func optionalBool(_ value: Any?) -> Bool? { + guard let value, !(value is NSNull) else { return nil } + return value as? Bool + } + + private static func optionalRole(_ value: Any?) -> String? { + guard let value, !(value is NSNull) else { return nil } + guard let value = value as? String, value == "destructive" else { return nil } + return value + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index a135bfd2474f..222f5c1ae127 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -205,6 +205,13 @@ public final class AgentPaneView: NSView { self.webView = webView super.init(frame: .zero) pageEvents?.onUiPointerReport = { [weak self] value in self?.uiPointerReport?(value) } + model.onTabRowToolsChange = { [weak self] in + guard let self else { return } + self.deliver([.tabRowTools(self.model.tabRowTools)], scripts: []) + } + model.onTabRowToolInvoked = { [weak self] id, menuItemID in + self?.pageEvents?.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) + } openURL = { [weak self] url, modifiers in if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 6242d94fb895..22f8f4114129 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -115,7 +115,7 @@ final class PaneContentView: NSView, PaneContentChrome { agentTagObservation = observeAgentTag(stripModel) themeDidChange() tokenObservation = Task { [weak self] in - for await _ in ObservationStream({ (PaneChromeMetrics.current, ChromeTunables.tabRowTools.value) }) { + for await _ in ObservationStream({ (PaneChromeMetrics.current, ChromeTunables.tabRowTools.value, ChromeTunables.nativeRowTools.value) }) { self?.needsLayout = true self?.refreshBandHeight() } @@ -208,6 +208,9 @@ final class PaneContentView: NSView, PaneContentChrome { paintHold.kept.flatMap { $0.superview === contentHost ? $0 : nil } ?? (hostsContent ? content : nil) } + /// The selected agent page's model, for native controls owned by the page. + var agentPaneModel: AgentPaneModel? { (visibleContent as? AgentPaneView)?.model } + var paneFramesContent: Bool { !(visibleContent is AgentPaneView) } /// The hosted content's own header, if it reports one (a browser's diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index 36eca4ad381c..9f72a4087def 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -35,6 +35,7 @@ final class PaneToolbar { /// it shows. Hiding "…" for that frame gave its width to the strip, which laid out every /// tab again at once (10 tabs, cx-codh2). var behind = false + var nativeItems: [PaneTabRowTool] = [] } let view = PaneToolbarView() @@ -62,6 +63,9 @@ final class PaneToolbar { self.contentMenu = contentMenu view.moreMenu = { [weak self] in self?.moreMenu() } view.onPerform = { [weak self] id in self?.perform(ActionID(rawValue: id)) } + view.onNativeTool = { [weak pane] id, menuItemID in + pane?.view.agentPaneModel?.onTabRowToolInvoked?(id, menuItemID) + } } // cx-t7hu: not an isolated deinit; its runtime hop traps when released inside an action's task-local scope. @@ -94,7 +98,7 @@ final class PaneToolbar { for await state in ObservationStream({ [weak pane] in pane.map(Self.state) }) { guard let self, let state else { return } if state.behind { continue } - update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds) + update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds, nativeItems: state.nativeItems) } } } @@ -106,13 +110,13 @@ final class PaneToolbar { // The pane's own workspace, not a store scan: this runs for every pane. Whichever tab is // selected, so the zoom button's room does not follow the selection. let zoomed = pane.workspace?.workspace.screens.contains { $0.zoomedPane == pane.pane.handle } ?? false - guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds) } + guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? []) } guard let tab = pane.pane.tabs.first(where: { $0.id == key }) else { // A window's own page or browser tab is never in the store: no "…", not a stale bar. let local = key.hasPrefix(LocalPageTab.prefix) || key.hasPrefix(LocalBrowserTab.prefix) return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local) } - return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds) + return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? []) } static func kind(_ tab: TabModel, isNewTabPage: Bool) -> Kind? { @@ -141,7 +145,7 @@ final class PaneToolbar { /// Shows the `pinned` actions tab `target` of `kind` can run and "…" (nil kind: none); lit, with the /// zoom button, while the pane is zoomed. /// `paneKinds`: the kinds of every tab in the pane that offers "…", whichever is selected. - func update(target: String?, kind: Kind?, zoomed: Bool = false, pinned: [String] = [], paneKinds: Set = []) { + func update(target: String?, kind: Kind?, zoomed: Bool = false, pinned: [String] = [], paneKinds: Set = [], nativeItems: [PaneTabRowTool] = []) { self.target = target self.kind = kind self.zoomed = zoomed @@ -156,8 +160,10 @@ final class PaneToolbar { let zoomRoom = zoomed && !pinned.contains(Self.zoom.rawValue) ? 1 : 0 let buttons = shownButtons() // The new-terminal and new-browser buttons are not shown: the + and the "…" menu offer them (Lawrence 2026-10-27: too many buttons). + let nativeItems = ChromeTunables.nativeRowTools.value ? nativeItems : [] view.show(buttons.compactMap(item), more: shown, lit: zoomed && shown, - reserving: kinds.isEmpty ? 0 : max(reservedPins + zoomRoom, buttons.count) + 1) + reserving: kinds.isEmpty ? 0 : max(reservedPins + zoomRoom, buttons.count) + 1, + nativeItems: nativeItems) } /// The pinned actions the tab can run, in the user's order, as buttons before "…". diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift new file mode 100644 index 000000000000..6657360aca18 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift @@ -0,0 +1,43 @@ +import Foundation + +/// A menu row supplied by an agent page for the native tab-row toolbar. +public struct PaneTabRowMenuItem: Equatable, Sendable { + public let id: String + public let label: String + public let enabled: Bool + public let checked: Bool? + public let role: String? + public let children: [PaneTabRowMenuItem] + + public init(id: String, label: String, enabled: Bool = true, checked: Bool? = nil, + role: String? = nil, children: [PaneTabRowMenuItem] = []) { + self.id = id + self.label = label + self.enabled = enabled + self.checked = checked + self.role = role + self.children = children + } +} + +/// One content-supplied native control in the shared pane tab row. +public struct PaneTabRowTool: Equatable, Sendable { + public let id: String + public let symbol: String + public let label: String + public let enabled: Bool + public let toggled: Bool + public let badge: String? + public let menu: [PaneTabRowMenuItem] + + public init(id: String, symbol: String, label: String, enabled: Bool = true, toggled: Bool = false, + badge: String? = nil, menu: [PaneTabRowMenuItem] = []) { + self.id = id + self.symbol = symbol + self.label = label + self.enabled = enabled + self.toggled = toggled + self.badge = badge + self.menu = menu + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/TitlebarBandButton.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/TitlebarBandButton.swift index 3196f489fd13..c11d4e619211 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/TitlebarBandButton.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/TitlebarBandButton.swift @@ -13,6 +13,11 @@ public final class TitlebarBandButton: NSButton { public var customImage: ((String, CGFloat) -> NSImage?)? { didSet { renderedIconSize = 0; renderSymbol() } } + /// Optional compact count/status text supplied by a content-owned tool. + public var badge: String? { + didSet { badgeLabel.stringValue = badge ?? ""; badgeLabel.isHidden = badge == nil; needsLayout = true } + } + private let badgeLabel = NSTextField(labelWithString: "") private var renderedIconSize: CGFloat = 0 public private(set) lazy var hover = ChromeHover(self, behindContent: true) /// Set once `hover` follows the pointer (not during NSButton's init). @@ -28,6 +33,15 @@ public final class TitlebarBandButton: NSButton { imageScaling = .scaleProportionallyDown renderSymbol() contentTintColor = performWithTheme { Palette.textSecondary } + badgeLabel.font = Typography.caption + badgeLabel.alignment = .center + badgeLabel.textColor = performWithTheme { Palette.textSecondary } + badgeLabel.isBezeled = false + badgeLabel.drawsBackground = false + badgeLabel.isEditable = false + badgeLabel.isSelectable = false + badgeLabel.isHidden = true + addSubview(badgeLabel) // Hover follows the pointer and the button's frame (cx-3wu5): a band // that collapses or hides under a still pointer clears it. hover.followPointer(isHoverable: { [weak self] in self?.isEnabled ?? false }) @@ -48,6 +62,8 @@ public final class TitlebarBandButton: NSButton { override public func layout() { super.layout() renderSymbol() + let size = min(Metrics.space3, bounds.height) + badgeLabel.frame = NSRect(x: bounds.maxX - size, y: bounds.maxY - size, width: size, height: size) } private func renderSymbol() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift index 68b9e8a4427c..a0d15b538716 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Tunables/ChromeTunables.swift @@ -42,6 +42,11 @@ public nonisolated enum ChromeTunables { "chrome.tabs.rowTools", .tabs, "Chat tools in the tab row", help: "A chat's header tools sit at the right end of its pane's tab row instead of a row of their own; the tabs make room. Off: the chat header is its own row under the tabs.", default: true, code: "ChromeTunables.tabRowTools") + /// Native content-supplied controls in the shared tab row; off while the migration series lands. + public static let nativeRowTools = Tunable.toggle( + "chrome.tabs.nativeRowTools", .tabs, "Native chat tools in the tab row", + help: "Render content-supplied chat tools as native tab-row buttons. Off: the page owns its controls.", + default: false, code: "ChromeTunables.nativeRowTools") // @intent opt-in looks live in Debug Settings, off by default, until promoted (Lawrence via Leo, 2026-10-15). public static let interfaceSize = Tunable.toggle( "chrome.interfaceSize", .shape, "Interface size", @@ -74,7 +79,7 @@ public nonisolated enum ChromeTunables { static var fixed: [TunableDescriptor] { [space1, space2, space3, space4, space5, space6, trafficLightInset, sidebarMinWidth, sidebarMaxWidth, tabBackgroundInset, tabContentLeadingInset].map(\.descriptor) - + [compactTabs.descriptor, compactTabContentLeadingInset.descriptor, tabRowTools.descriptor, interfaceSize.descriptor] + + [compactTabs.descriptor, compactTabContentLeadingInset.descriptor, tabRowTools.descriptor, nativeRowTools.descriptor, interfaceSize.descriptor] + [panePadding.descriptor, opaqueOverlayLift.descriptor, glassOverlayTintStrength.descriptor, cardOpacity.descriptor, scrollFadeMaxFraction.descriptor] } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index f8caafad26e7..484bb3a28def 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -37,6 +37,7 @@ public final class PaneToolbarView: NSView { public var moreMenu: (() -> NSMenu?)? /// Runs a pinned button's action (its catalog id). public var onPerform: ((String) -> Void)? + public var onNativeTool: ((String, String?) -> Void)? /// The tab bar's frame in the superview: hovering it reveals the controls too. public var stripFrame: (() -> NSRect?)? @@ -46,6 +47,8 @@ public final class PaneToolbarView: NSView { /// Buttons' room the bar keeps even while fewer show (``show(_:more:lit:reserving:)``). public private(set) var reservedCount = 0 public private(set) var buttons: [TitlebarBandButton] = [] + public private(set) var nativeItems: [PaneTabRowTool] = [] + public private(set) var nativeButtons: [TitlebarBandButton] = [] /// The app's one more button (⋮, OverflowButton); its menu opens on press. public let moreButton = OverflowButton(label: Strings.paneMore) /// The tab bar's tracking area (``refreshCorner()``) reports the pointer. @@ -92,10 +95,13 @@ public final class PaneToolbarView: NSView { /// /// - Parameter reserving: buttons' room to keep even while fewer show: the most any tab of the /// pane shows, so the strip's width (and every tab's) does not follow the selected tab. - public func show(_ items: [PaneToolbarItem] = [], more: Bool, lit: Bool, reserving: Int = 0) { - guard items != self.items || more != hasMore || lit != self.lit || reserving != reservedCount else { return } + public func show(_ items: [PaneToolbarItem] = [], more: Bool, lit: Bool, reserving: Int = 0, + nativeItems: [PaneTabRowTool] = []) { + guard items != self.items || more != hasMore || lit != self.lit || reserving != reservedCount || nativeItems != self.nativeItems else { return } reservedCount = reserving if items != self.items { setButtons(items) } + if nativeItems != self.nativeItems { setNativeButtons(nativeItems) } + self.nativeItems = nativeItems hasMore = more self.lit = lit moreButton.isHidden = !more @@ -128,6 +134,56 @@ public final class PaneToolbarView: NSView { } } + private func setNativeButtons(_ items: [PaneTabRowTool]) { + nativeButtons.forEach { reveal.remove($0); $0.removeFromSuperview() } + nativeButtons = items.enumerated().map { index, item in + let button = TitlebarBandButton(symbol: item.symbol) + button.tag = index + button.setAccessibilityLabel(item.label) + button.setAccessibilityIdentifier("cmux.pane.native-toolbar.\(item.id)") + button.themedToolTip = item.label + button.state = item.toggled ? .on : .off + button.isEnabled = item.enabled + button.badge = item.badge + if item.menu.isEmpty { + button.target = self + button.action = #selector(pressNative(_:)) + } else { + button.pressMenu = { [weak self] in self?.menu(for: item) } + let present = button.presentMenu + button.presentMenu = { [weak self] menu, source in + let hold = self?.reveal.hold() + present(menu, source) + hold?.release() + } + } + addSubview(button) + reveal.add(button) + return button + } + } + + private func menu(for tool: PaneTabRowTool) -> NSMenu? { + let menu = NSMenu(title: tool.label) + for item in tool.menu { menu.addItem(menuItem(item, toolID: tool.id)) } + return menu + } + + private func menuItem(_ item: PaneTabRowMenuItem, toolID: String) -> NSMenuItem { + let row = NSMenuItem(title: item.label, action: #selector(NativeMenuInvoker.run), keyEquivalent: "") + row.isEnabled = item.enabled + if let checked = item.checked { row.state = checked ? .on : .off } + let invoker = NativeMenuInvoker { [weak self] in self?.onNativeTool?(toolID, item.id) } + row.target = invoker + row.representedObject = invoker + if !item.children.isEmpty { + let submenu = NSMenu(title: item.label) + item.children.forEach { submenu.addItem(self.menuItem($0, toolID: toolID)) } + row.submenu = submenu + } + return row + } + /// Nothing to offer: no width, so the strip keeps the whole bar. override public var intrinsicContentSize: NSSize { let count = max(shownCount, reservedCount) @@ -137,7 +193,7 @@ public final class PaneToolbarView: NSView { return NSSize(width: CGFloat(count) * side + CGFloat(count - 1) * Metrics.space1 / 2 + inset, height: NSView.noIntrinsicMetric) } - private var shownCount: Int { buttons.count + (hasMore ? 1 : 0) } + private var shownCount: Int { buttons.count + nativeButtons.count + (hasMore ? 1 : 0) } override public func layout() { super.layout() @@ -148,16 +204,17 @@ public final class PaneToolbarView: NSView { let y = Self.edgeInset(height: bounds.height) func slotX(_ fromTrailing: Int) -> CGFloat { bounds.width - y - side - CGFloat(fromTrailing) * (side + gap) } moreButton.frame = moreFrame() - let trailingSlot = hasMore || reservedCount > buttons.count ? 1 : 0 + let allButtons = buttons + nativeButtons + let trailingSlot = hasMore || reservedCount > allButtons.count ? 1 : 0 // Creation actions remain in the + menu. Fold their buttons away together when the // pane caps this toolbar's width, keeping pins in their trailing slots. Pins that also // cannot fit return to the more menu instead of overlapping the remaining controls. let creationFits = items.indices.filter { items[$0].alwaysVisible }.allSatisfy { - slotX(buttons.count - $0 - 1 + trailingSlot) >= 0 + slotX(allButtons.count - $0 - 1 + trailingSlot) >= 0 } - for (index, button) in buttons.enumerated() { - let x = slotX(buttons.count - index - 1 + trailingSlot) - button.isHidden = x < 0 || (items[index].alwaysVisible && !creationFits) + for (index, button) in allButtons.enumerated() { + let x = slotX(allButtons.count - index - 1 + trailingSlot) + button.isHidden = x < 0 || (index < items.count && items[index].alwaysVisible && !creationFits) button.frame = NSRect(x: max(0, x), y: y, width: side, height: side) } applyTint() @@ -225,7 +282,7 @@ public final class PaneToolbarView: NSView { public func refreshCorner() { let rect = stripFrame?().map { $0.union(frame) } ?? frame // Any control that shows (Search Tabs with a chat selected has no "…") brightens on hover. - let live = superview != nil && window != nil && (hasMore || !buttons.isEmpty) && !isHiddenOrHasHiddenAncestor + let live = superview != nil && window != nil && (hasMore || !buttons.isEmpty || !nativeButtons.isEmpty) && !isHiddenOrHasHiddenAncestor if live, let corner, corner.rect == rect, cornerHost === superview { return } if let corner { cornerHost?.removeTrackingArea(corner) } corner = nil @@ -248,6 +305,17 @@ public final class PaneToolbarView: NSView { guard items.indices.contains(sender.tag) else { return } onPerform?(items[sender.tag].id) } + + @objc private func pressNative(_ sender: TitlebarBandButton) { + guard nativeItems.indices.contains(sender.tag) else { return } + onNativeTool?(nativeItems[sender.tag].id, nil) + } +} + +private final class NativeMenuInvoker: NSObject { + let action: () -> Void + init(action: @escaping () -> Void) { self.action = action } + @objc func run() { action() } } // Like the strip's empty space (TabStripView+TitlebarDrag): in the window's top row, a press @@ -255,7 +323,7 @@ public final class PaneToolbarView: NSView { extension PaneToolbarView: TitlebarPressDeciding { public func titlebarPress(atWindowPoint windowPoint: CGPoint) -> TitlebarPress { let point = convert(windowPoint, from: nil) - let onButton = (buttons as [NSView] + [moreButton]).contains { !$0.isHidden && $0.frame.contains(point) } + let onButton = ((buttons + nativeButtons).map { $0 as NSView } + [moreButton]).contains { !$0.isHidden && $0.frame.contains(point) } return !onButton && WindowTitlebar.isInTopRow(self) ? .movesWindow : .staysPut } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift new file mode 100644 index 000000000000..7f6ab3a1c4ab --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CmuxNextAgentPane + +struct AgentPaneTabRowToolsTests { + @Test func decodesToolsAndNestedMenus() { + let raw: [String: Any] = [ + "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, + "badge": "2", "menu": [["id": "open", "label": "Open", "enabled": true, "checked": true]], + ] + let tools = AgentPaneTabRowTools.list([raw]) + #expect(tools?.first?.id == "review") + #expect(tools?.first?.menu.first?.checked == true) + } + + @Test func rejectsOutOfBoundsListsAndText() { + let tooMany = (0...AgentPaneTabRowTools.maximumTools).map { _ in + ["id": "x", "symbol": "x", "label": "x", "enabled": true, "toggled": false] as [String: Any] + } + #expect(AgentPaneTabRowTools.list(tooMany) == nil) + let long = ["id": "x", "symbol": "x", "label": String(repeating: "x", count: 65), "enabled": true, "toggled": false] as [String: Any] + #expect(AgentPaneTabRowTools.list([long]) == nil) + } + + @Test func advertisesSetOpAndNativeRowToolsEvent() { + #expect(AgentPageOps.all.contains("cmux.agent.pane.tabRowTools.set")) + let event = AgentPageEvent.tabRow(height: 10, trailingInset: 4, nativeRowTools: true) + #expect(event.value["nativeRowTools"]?.boolValue == true) + } +} From cc4d616bc2a6abcb82c6491bb217c5d2be6c0d18 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:14:46 +0000 Subject: [PATCH 02/30] Fix native toolbar callback ownership Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index 9f72a4087def..8085aa368690 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -63,9 +63,6 @@ final class PaneToolbar { self.contentMenu = contentMenu view.moreMenu = { [weak self] in self?.moreMenu() } view.onPerform = { [weak self] id in self?.perform(ActionID(rawValue: id)) } - view.onNativeTool = { [weak pane] id, menuItemID in - pane?.view.agentPaneModel?.onTabRowToolInvoked?(id, menuItemID) - } } // cx-t7hu: not an isolated deinit; its runtime hop traps when released inside an action's task-local scope. @@ -93,6 +90,9 @@ final class PaneToolbar { } } } + view.onNativeTool = { [weak pane] id, menuItemID in + pane?.view.agentPaneModel?.onTabRowToolInvoked?(id, menuItemID) + } // task-owner: lives as long as the pane's view; event-driven (Observation) observation = Task { [weak self, weak pane] in for await state in ObservationStream({ [weak pane] in pane.map(Self.state) }) { From a4e09e885b9b221a3619cf32b9d0a68003a88cc5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:16:41 +0000 Subject: [PATCH 03/30] Keep native tool state and menus synchronized Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift | 13 +++++++------ .../Sources/CmuxNextTabs/PaneToolbarView.swift | 2 ++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index 8085aa368690..aa5cbe27fbe9 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -36,6 +36,7 @@ final class PaneToolbar { /// tab again at once (10 tabs, cx-codh2). var behind = false var nativeItems: [PaneTabRowTool] = [] + var nativeEnabled = false } let view = PaneToolbarView() @@ -98,7 +99,7 @@ final class PaneToolbar { for await state in ObservationStream({ [weak pane] in pane.map(Self.state) }) { guard let self, let state else { return } if state.behind { continue } - update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds, nativeItems: state.nativeItems) + update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds, nativeItems: state.nativeItems, nativeEnabled: state.nativeEnabled) } } } @@ -110,13 +111,13 @@ final class PaneToolbar { // The pane's own workspace, not a store scan: this runs for every pane. Whichever tab is // selected, so the zoom button's room does not follow the selection. let zoomed = pane.workspace?.workspace.screens.contains { $0.zoomedPane == pane.pane.handle } ?? false - guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? []) } + guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value) } guard let tab = pane.pane.tabs.first(where: { $0.id == key }) else { // A window's own page or browser tab is never in the store: no "…", not a stale bar. let local = key.hasPrefix(LocalPageTab.prefix) || key.hasPrefix(LocalBrowserTab.prefix) - return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local) + return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local, nativeEnabled: ChromeTunables.nativeRowTools.value) } - return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? []) + return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value) } static func kind(_ tab: TabModel, isNewTabPage: Bool) -> Kind? { @@ -145,7 +146,7 @@ final class PaneToolbar { /// Shows the `pinned` actions tab `target` of `kind` can run and "…" (nil kind: none); lit, with the /// zoom button, while the pane is zoomed. /// `paneKinds`: the kinds of every tab in the pane that offers "…", whichever is selected. - func update(target: String?, kind: Kind?, zoomed: Bool = false, pinned: [String] = [], paneKinds: Set = [], nativeItems: [PaneTabRowTool] = []) { + func update(target: String?, kind: Kind?, zoomed: Bool = false, pinned: [String] = [], paneKinds: Set = [], nativeItems: [PaneTabRowTool] = [], nativeEnabled: Bool = ChromeTunables.nativeRowTools.value) { self.target = target self.kind = kind self.zoomed = zoomed @@ -160,7 +161,7 @@ final class PaneToolbar { let zoomRoom = zoomed && !pinned.contains(Self.zoom.rawValue) ? 1 : 0 let buttons = shownButtons() // The new-terminal and new-browser buttons are not shown: the + and the "…" menu offer them (Lawrence 2026-10-27: too many buttons). - let nativeItems = ChromeTunables.nativeRowTools.value ? nativeItems : [] + let nativeItems = nativeEnabled ? nativeItems : [] view.show(buttons.compactMap(item), more: shown, lit: zoomed && shown, reserving: kinds.isEmpty ? 0 : max(reservedPins + zoomRoom, buttons.count) + 1, nativeItems: nativeItems) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index 484bb3a28def..cb2b2fdd1e8e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -165,6 +165,7 @@ public final class PaneToolbarView: NSView { private func menu(for tool: PaneTabRowTool) -> NSMenu? { let menu = NSMenu(title: tool.label) + menu.autoenablesItems = false for item in tool.menu { menu.addItem(menuItem(item, toolID: tool.id)) } return menu } @@ -178,6 +179,7 @@ public final class PaneToolbarView: NSView { row.representedObject = invoker if !item.children.isEmpty { let submenu = NSMenu(title: item.label) + submenu.autoenablesItems = false item.children.forEach { submenu.addItem(self.menuItem($0, toolID: toolID)) } row.submenu = submenu } From 04d77c19047ab14248af3be488556715788ed97e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 00:31:23 +0000 Subject: [PATCH 04/30] Fix native tab row fallback and isolation Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextAgentPane/AgentPageEvent.swift | 20 -------- .../CmuxNextAgentPane/AgentPaneModel.swift | 2 - .../CmuxNextAgentPane/AgentPanePageHost.swift | 1 - .../AgentPaneTabRowTools.swift | 26 +++++----- .../CmuxNextAgentPane/AgentPaneView.swift | 4 -- .../Sources/CmuxNextApp/PaneToolbar.swift | 51 +++++++++++-------- .../CmuxNextDesign/PaneTabRowTool.swift | 4 +- .../CmuxNextTabs/PaneToolbarView.swift | 22 ++++++++ 8 files changed, 67 insertions(+), 63 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift index 8670657688f1..7d673071ec2e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageEvent.swift @@ -71,18 +71,6 @@ public nonisolated struct AgentPageEvent: Equatable, Sendable { /// A dispatcher command (`searchChats`, `continueIn`, `createCheckpoint`, `permissionAllowOnce`, ...). public static func command(_ name: String) -> AgentPageEvent { AgentPageEvent(kind: "command", value: .string(name)) } - /// The current declarative native tab-row controls. - public static func tabRowTools(_ tools: [PaneTabRowTool]) -> AgentPageEvent { - let value = tools.map { tool in - var object: [String: Any] = ["id": tool.id, "symbol": tool.symbol, "label": tool.label, - "enabled": tool.enabled, "toggled": tool.toggled] - if let badge = tool.badge { object["badge"] = badge } - if !tool.menu.isEmpty { object["menu"] = tool.menu.map(menuObject) } - return object - } - return AgentPageEvent(kind: "tabRowTools", value: JSONValue(foundation: value) ?? .array([])) - } - /// A native tab-row control or one of its menu items was invoked. public static func tabRowToolInvoked(id: String, menuItemId: String? = nil) -> AgentPageEvent { var value: [String: Any] = ["id": id] @@ -90,14 +78,6 @@ public nonisolated struct AgentPageEvent: Equatable, Sendable { return AgentPageEvent(kind: "tabRowToolInvoked", value: JSONValue(foundation: value) ?? .null) } - private static func menuObject(_ item: PaneTabRowMenuItem) -> [String: Any] { - var object: [String: Any] = ["id": item.id, "label": item.label, "enabled": item.enabled] - if let checked = item.checked { object["checked"] = checked } - if let role = item.role { object["role"] = role } - if !item.children.isEmpty { object["children"] = item.children.map(menuObject) } - return object - } - /// A batch of the host socket's frames, or its close (``AgentPaneTransport``, bridgeSocket.ts). public static func transport(_ event: AgentPaneTransportEvent) -> AgentPageEvent { AgentPageEvent(kind: "transport", value: JSONValue(foundation: event.object) ?? .null) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 41623b434352..c397b144f04e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -101,7 +101,6 @@ public final class AgentPaneModel { @ObservationIgnored public let tabRow = AgentPaneTabRow() /// Declarative native controls published by the page for the shared tab row. public private(set) var tabRowTools: [PaneTabRowTool] = [] - @ObservationIgnored public var onTabRowToolsChange: (() -> Void)? @ObservationIgnored public var onTabRowToolInvoked: ((String, String?) -> Void)? /// Gets the composer's dictation requests (the pane's mic). @ObservationIgnored public var onDictation: ((AgentPaneDictationCommand) -> Void)? @@ -492,7 +491,6 @@ public final class AgentPaneModel { func setTabRowTools(_ tools: [PaneTabRowTool]) { guard tabRowTools != tools else { return } tabRowTools = tools - onTabRowToolsChange?() } func setCheckpointAvailable(_ available: Bool) { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift index a4a5d7b7b1f0..b8f9f6c0dfc2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePageHost.swift @@ -40,7 +40,6 @@ enum AgentPanePageHost { events.append(.display(view.model.display)) events.append(.toolCards(view.model.toolCards)) events.append(view.model.tabRow.event) - events.append(.tabRowTools(view.model.tabRowTools)) if !view.customization.isEmpty { events += AgentPageEvent.customization(view.customization) } return events } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift index de380ffa9a19..af67ac951d15 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -2,13 +2,13 @@ import CmuxNextDesign import Foundation /// Strict decoding for the page's declarative native tab-row controls. -enum AgentPaneTabRowTools { - static let maximumTools = 16 - static let maximumMenuItems = 32 - static let maximumDepth = 2 - static let maximumText = 64 +nonisolated enum AgentPaneTabRowTools { + nonisolated static let maximumTools = 16 + nonisolated static let maximumMenuItems = 32 + nonisolated static let maximumDepth = 2 + nonisolated static let maximumText = 64 - static func list(_ raw: Any?) -> [PaneTabRowTool]? { + nonisolated static func list(_ raw: Any?) -> [PaneTabRowTool]? { guard let raw = raw as? [Any], raw.count <= maximumTools else { return nil } var tools: [PaneTabRowTool] = [] tools.reserveCapacity(raw.count) @@ -19,7 +19,7 @@ enum AgentPaneTabRowTools { return tools } - private static func tool(_ raw: Any) -> PaneTabRowTool? { + private nonisolated static func tool(_ raw: Any) -> PaneTabRowTool? { guard let object = raw as? [String: Any], let id = text(object["id"]), let symbol = text(object["symbol"]), let label = text(object["label"]), object["enabled"] is Bool, object["toggled"] is Bool, @@ -30,14 +30,14 @@ enum AgentPaneTabRowTools { badge: badge, menu: menu) } - private static func menu(_ raw: Any?, depth: Int) -> [PaneTabRowMenuItem]? { + private nonisolated static func menu(_ raw: Any?, depth: Int) -> [PaneTabRowMenuItem]? { guard let raw else { return [] } guard depth <= maximumDepth, let list = raw as? [Any], list.count <= maximumMenuItems else { return nil } let rows = list.compactMap { item($0, depth: depth) } return rows.count == list.count ? rows : nil } - private static func item(_ raw: Any, depth: Int) -> PaneTabRowMenuItem? { + private nonisolated static func item(_ raw: Any, depth: Int) -> PaneTabRowMenuItem? { guard let object = raw as? [String: Any], let id = text(object["id"]), let label = text(object["label"]), object["enabled"] is Bool, let enabled = object["enabled"] as? Bool, let checked = optionalBool(object["checked"]), let role = optionalRole(object["role"]), @@ -45,22 +45,22 @@ enum AgentPaneTabRowTools { return PaneTabRowMenuItem(id: id, label: label, enabled: enabled, checked: checked, role: role, children: children) } - private static func text(_ value: Any?) -> String? { + private nonisolated static func text(_ value: Any?) -> String? { guard let value = value as? String, !value.isEmpty, value.count <= maximumText else { return nil } return value } - private static func optionalText(_ value: Any?) -> String? { + private nonisolated static func optionalText(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil } return text(value) } - private static func optionalBool(_ value: Any?) -> Bool? { + private nonisolated static func optionalBool(_ value: Any?) -> Bool? { guard let value, !(value is NSNull) else { return nil } return value as? Bool } - private static func optionalRole(_ value: Any?) -> String? { + private nonisolated static func optionalRole(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil } guard let value = value as? String, value == "destructive" else { return nil } return value diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index 222f5c1ae127..24dbb9c99d83 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -205,10 +205,6 @@ public final class AgentPaneView: NSView { self.webView = webView super.init(frame: .zero) pageEvents?.onUiPointerReport = { [weak self] value in self?.uiPointerReport?(value) } - model.onTabRowToolsChange = { [weak self] in - guard let self else { return } - self.deliver([.tabRowTools(self.model.tabRowTools)], scripts: []) - } model.onTabRowToolInvoked = { [weak self] id, menuItemID in self?.pageEvents?.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index aa5cbe27fbe9..9f3c5b09c2a3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -161,10 +161,12 @@ final class PaneToolbar { let zoomRoom = zoomed && !pinned.contains(Self.zoom.rawValue) ? 1 : 0 let buttons = shownButtons() // The new-terminal and new-browser buttons are not shown: the + and the "…" menu offer them (Lawrence 2026-10-27: too many buttons). - let nativeItems = nativeEnabled ? nativeItems : [] - view.show(buttons.compactMap(item), more: shown, lit: zoomed && shown, + // A native page list needs an overflow route when a narrow pane cannot fit every real + // button. It also keeps chat panes discoverable, which otherwise have no pane "…". + let nativeTools = nativeEnabled ? nativeItems : [] + view.show(buttons.compactMap(item), more: shown || !nativeTools.isEmpty, lit: zoomed && shown, reserving: kinds.isEmpty ? 0 : max(reservedPins + zoomRoom, buttons.count) + 1, - nativeItems: nativeItems) + nativeItems: nativeTools) } /// The pinned actions the tab can run, in the user's order, as buttons before "…". @@ -208,31 +210,38 @@ final class PaneToolbar { /// "…": the content's own rows (the Chief's), what opens in the pane, the actions without a button /// with their keys, Search Tabs, then Show in Tab Bar. private func moreMenu() -> NSMenu? { - guard let registry = registry(), let target, kind != nil else { return nil } + guard let registry = registry(), let target else { return nil } let menu = newTabMenu() ?? NSMenu() let own = contentMenu() if !own.isEmpty, !menu.items.isEmpty { menu.insertItem(.separator(), at: 0) } for (index, item) in own.enumerated() { menu.insertItem(item, at: index) } view.layoutSubtreeIfNeeded() - let buttons = zip(view.items, view.buttons).compactMap { item, button in - button.isHidden ? nil : ActionID(rawValue: item.id) - } - let tabOwn = registry.makeContextMenu(for: .tab, target: ActionTargetRef(kind: .tab, id: target), - entries: tabActions().filter { !buttons.contains($0) }.map { .action($0) }) - if !menu.items.isEmpty, !tabOwn.items.isEmpty { menu.addItem(.separator()) } - for item in tabOwn.items { - tabOwn.removeItem(item) - menu.addItem(item) + if kind != nil { + let buttons = zip(view.items, view.buttons).compactMap { item, button in + button.isHidden ? nil : ActionID(rawValue: item.id) + } + let tabOwn = registry.makeContextMenu(for: .tab, target: ActionTargetRef(kind: .tab, id: target), + entries: tabActions().filter { !buttons.contains($0) }.map { .action($0) }) + if !menu.items.isEmpty, !tabOwn.items.isEmpty { menu.addItem(.separator()) } + for item in tabOwn.items { + tabOwn.removeItem(item) + menu.addItem(item) + } + // Search Tabs (was the bar's chevron) runs with no tab target: a tab target only reveals that tab. + let search = registry.makeContextMenu(for: .tab, entries: [.action(Self.search)]) + if !search.items.isEmpty { menu.addItem(.separator()) } + for item in search.items { + search.removeItem(item) + menu.addItem(item) + } + menu.addItem(.separator()) + menu.addItem(showInTabBarItem(registry: registry)) } - // Search Tabs (was the bar's chevron) runs with no tab target: a tab target only reveals that tab. - let search = registry.makeContextMenu(for: .tab, entries: [.action(Self.search)]) - if !search.items.isEmpty { menu.addItem(.separator()) } - for item in search.items { - search.removeItem(item) - menu.addItem(item) + let native = view.nativeOverflowItems() + if !native.isEmpty { + if !menu.items.isEmpty { menu.addItem(.separator()) } + native.forEach(menu.addItem) } - menu.addItem(.separator()) - menu.addItem(showInTabBarItem(registry: registry)) // Every row in the theme's highlight, like the sidebar's ⋮ menus and the strip's plus. return menu.themeRows(in: view.themeScope.fullStrength) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift index 6657360aca18..fe710f37bf21 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/PaneTabRowTool.swift @@ -1,7 +1,7 @@ import Foundation /// A menu row supplied by an agent page for the native tab-row toolbar. -public struct PaneTabRowMenuItem: Equatable, Sendable { +public nonisolated struct PaneTabRowMenuItem: Equatable, Sendable { public let id: String public let label: String public let enabled: Bool @@ -21,7 +21,7 @@ public struct PaneTabRowMenuItem: Equatable, Sendable { } /// One content-supplied native control in the shared pane tab row. -public struct PaneTabRowTool: Equatable, Sendable { +public nonisolated struct PaneTabRowTool: Equatable, Sendable { public let id: String public let symbol: String public let label: String diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index cb2b2fdd1e8e..a4717705be61 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -170,6 +170,28 @@ public final class PaneToolbarView: NSView { return menu } + /// Native tools remain reachable from the overflow menu when a narrow pane cannot fit every + /// supplied button. The same invoker path keeps the page event identical to a direct press. + public func nativeOverflowItems() -> [NSMenuItem] { + nativeItems.map { tool in + let row = NSMenuItem(title: tool.label, action: nil, keyEquivalent: "") + row.isEnabled = tool.enabled + row.state = tool.toggled ? .on : .off + if tool.menu.isEmpty { + let invoker = NativeMenuInvoker { [weak self] in self?.onNativeTool?(tool.id, nil) } + row.action = #selector(NativeMenuInvoker.run) + row.target = invoker + row.representedObject = invoker + } else { + let submenu = NSMenu(title: tool.label) + submenu.autoenablesItems = false + tool.menu.forEach { submenu.addItem(menuItem($0, toolID: tool.id)) } + row.submenu = submenu + } + return row + } + } + private func menuItem(_ item: PaneTabRowMenuItem, toolID: String) -> NSMenuItem { let row = NSMenuItem(title: item.label, action: #selector(NativeMenuInvoker.run), keyEquivalent: "") row.isEnabled = item.enabled From 20b34cf3f2e2a3be142b4471c91d36746b1401f4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:48:16 +0000 Subject: [PATCH 05/30] Import design types in agent pane Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift | 1 + .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift | 1 + .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift | 1 + 3 files changed, 3 insertions(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index 88b4a32bc31f..c4be0fd7d6b4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextActions +import CmuxNextDesign import Foundation /// The app side of the chat header (`pane.action`, `pane.tabState`): runs a diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index c397b144f04e..ec5c965d13aa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -1,4 +1,5 @@ public import AppKit +import CmuxNextDesign public import CmuxNextSettings public import Foundation public import Observation diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 40fbc99bb293..f4b96f0ee3d5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -1,4 +1,5 @@ import CmuxNextDictation +import CmuxNextDesign public import CmuxNextSettings public import Foundation From 6922521ad531c2971389f425260097a1b745769c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 02:56:33 +0000 Subject: [PATCH 06/30] Re-export design types from agent pane Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift | 2 +- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index ec5c965d13aa..d376c7c0df12 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -1,5 +1,5 @@ public import AppKit -import CmuxNextDesign +public import CmuxNextDesign public import CmuxNextSettings public import Foundation public import Observation diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index f4b96f0ee3d5..cfbe0f381c40 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -1,5 +1,5 @@ import CmuxNextDictation -import CmuxNextDesign +public import CmuxNextDesign public import CmuxNextSettings public import Foundation From 1ade7ac0e9d4c1e831707966328491b4e15ad1d5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 04:29:55 +0000 Subject: [PATCH 07/30] Accept absent native tool optionals Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../AgentPaneTabRowTools.swift | 23 +++++++++++++++++-- .../AgentPaneTabRowToolsTests.swift | 18 +++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift index af67ac951d15..536dda83f413 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -24,8 +24,14 @@ nonisolated enum AgentPaneTabRowTools { let id = text(object["id"]), let symbol = text(object["symbol"]), let label = text(object["label"]), object["enabled"] is Bool, object["toggled"] is Bool, let enabled = object["enabled"] as? Bool, let toggled = object["toggled"] as? Bool, - let badge = optionalText(object["badge"]), let menu = menu(object["menu"], depth: 0) else { return nil } + let badge: String? + if let rawBadge = object["badge"], !(rawBadge is NSNull) { + guard let parsed = optionalText(rawBadge) else { return nil } + badge = parsed + } else { + badge = nil + } return PaneTabRowTool(id: id, symbol: symbol, label: label, enabled: enabled, toggled: toggled, badge: badge, menu: menu) } @@ -40,8 +46,21 @@ nonisolated enum AgentPaneTabRowTools { private nonisolated static func item(_ raw: Any, depth: Int) -> PaneTabRowMenuItem? { guard let object = raw as? [String: Any], let id = text(object["id"]), let label = text(object["label"]), object["enabled"] is Bool, let enabled = object["enabled"] as? Bool, - let checked = optionalBool(object["checked"]), let role = optionalRole(object["role"]), let children = menu(object["children"], depth: depth + 1) else { return nil } + let checked: Bool? + if let rawChecked = object["checked"], !(rawChecked is NSNull) { + guard let parsed = optionalBool(rawChecked) else { return nil } + checked = parsed + } else { + checked = nil + } + let role: String? + if let rawRole = object["role"], !(rawRole is NSNull) { + guard let parsed = optionalRole(rawRole) else { return nil } + role = parsed + } else { + role = nil + } return PaneTabRowMenuItem(id: id, label: label, enabled: enabled, checked: checked, role: role, children: children) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index 7f6ab3a1c4ab..aee088878394 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -13,6 +13,24 @@ struct AgentPaneTabRowToolsTests { #expect(tools?.first?.menu.first?.checked == true) } + @Test func decodesAbsentOptionalBadgeAndRole() { + let raw: [String: Any] = [ + "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, + "menu": [["id": "open", "label": "Open", "enabled": true]], + ] + let tools = AgentPaneTabRowTools.list([raw]) + #expect(tools?.first?.badge == nil) + #expect(tools?.first?.menu.first?.role == nil) + } + + @Test func rejectsPresentInvalidRole() { + let raw: [String: Any] = [ + "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, + "menu": [["id": "open", "label": "Open", "enabled": true, "role": "invalid"]], + ] + #expect(AgentPaneTabRowTools.list([raw]) == nil) + } + @Test func rejectsOutOfBoundsListsAndText() { let tooMany = (0...AgentPaneTabRowTools.maximumTools).map { _ in ["id": "x", "symbol": "x", "label": "x", "enabled": true, "toggled": false] as [String: Any] From 1b6a09985feb581ad21a55390de41dae42309d6f Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 19:59:40 +0000 Subject: [PATCH 08/30] fix: cap total native tab menu items Bound the aggregate native menu rows across all tools and nesting while preserving valid payloads. Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../AgentPaneTabRowTools.swift | 25 +++++++++++++------ .../AgentPaneTabRowToolsTests.swift | 19 ++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift index 536dda83f413..6e0dbcbd2bf6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -5,6 +5,7 @@ import Foundation nonisolated enum AgentPaneTabRowTools { nonisolated static let maximumTools = 16 nonisolated static let maximumMenuItems = 32 + nonisolated static let maximumTotalMenuItems = 256 nonisolated static let maximumDepth = 2 nonisolated static let maximumText = 64 @@ -12,19 +13,20 @@ nonisolated enum AgentPaneTabRowTools { guard let raw = raw as? [Any], raw.count <= maximumTools else { return nil } var tools: [PaneTabRowTool] = [] tools.reserveCapacity(raw.count) + var totalMenuItems = 0 for value in raw { - guard let parsed = tool(value) else { return nil } + guard let parsed = tool(value, totalMenuItems: &totalMenuItems) else { return nil } tools.append(parsed) } return tools } - private nonisolated static func tool(_ raw: Any) -> PaneTabRowTool? { + private nonisolated static func tool(_ raw: Any, totalMenuItems: inout Int) -> PaneTabRowTool? { guard let object = raw as? [String: Any], let id = text(object["id"]), let symbol = text(object["symbol"]), let label = text(object["label"]), object["enabled"] is Bool, object["toggled"] is Bool, let enabled = object["enabled"] as? Bool, let toggled = object["toggled"] as? Bool, - let menu = menu(object["menu"], depth: 0) else { return nil } + let menu = menu(object["menu"], depth: 0, totalMenuItems: &totalMenuItems) else { return nil } let badge: String? if let rawBadge = object["badge"], !(rawBadge is NSNull) { guard let parsed = optionalText(rawBadge) else { return nil } @@ -36,17 +38,24 @@ nonisolated enum AgentPaneTabRowTools { badge: badge, menu: menu) } - private nonisolated static func menu(_ raw: Any?, depth: Int) -> [PaneTabRowMenuItem]? { + private nonisolated static func menu(_ raw: Any?, depth: Int, totalMenuItems: inout Int) -> [PaneTabRowMenuItem]? { guard let raw else { return [] } guard depth <= maximumDepth, let list = raw as? [Any], list.count <= maximumMenuItems else { return nil } - let rows = list.compactMap { item($0, depth: depth) } - return rows.count == list.count ? rows : nil + var rows: [PaneTabRowMenuItem] = [] + rows.reserveCapacity(list.count) + for value in list { + totalMenuItems += 1 + guard totalMenuItems <= maximumTotalMenuItems, + let parsed = item(value, depth: depth, totalMenuItems: &totalMenuItems) else { return nil } + rows.append(parsed) + } + return rows } - private nonisolated static func item(_ raw: Any, depth: Int) -> PaneTabRowMenuItem? { + private nonisolated static func item(_ raw: Any, depth: Int, totalMenuItems: inout Int) -> PaneTabRowMenuItem? { guard let object = raw as? [String: Any], let id = text(object["id"]), let label = text(object["label"]), object["enabled"] is Bool, let enabled = object["enabled"] as? Bool, - let children = menu(object["children"], depth: depth + 1) else { return nil } + let children = menu(object["children"], depth: depth + 1, totalMenuItems: &totalMenuItems) else { return nil } let checked: Bool? if let rawChecked = object["checked"], !(rawChecked is NSNull) { guard let parsed = optionalBool(rawChecked) else { return nil } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index aee088878394..a2b9fc998815 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -31,6 +31,25 @@ struct AgentPaneTabRowToolsTests { #expect(AgentPaneTabRowTools.list([raw]) == nil) } + @Test func boundsTotalMenuItemsWhilePreservingNormalPayloads() { + let menu: [[String: Any]] = (0..<17).map { index in + ["id": "item-\(index)", "label": "Item \(index)", "enabled": true] + } + let normal: [Any] = (0..<2).map { index in + ["id": "tool-\(index)", "symbol": "checkmark", "label": "Tool \(index)", + "enabled": true, "toggled": false, "menu": menu] as [String: Any] + } + let normalTools = AgentPaneTabRowTools.list(normal) + #expect(normalTools?.count == 2) + #expect(normalTools?.allSatisfy { $0.menu.count == menu.count } == true) + + let oversized: [Any] = (0..<16).map { index in + ["id": "tool-\(index)", "symbol": "checkmark", "label": "Tool \(index)", + "enabled": true, "toggled": false, "menu": menu] as [String: Any] + } + #expect(AgentPaneTabRowTools.list(oversized) == nil) + } + @Test func rejectsOutOfBoundsListsAndText() { let tooMany = (0...AgentPaneTabRowTools.maximumTools).map { _ in ["id": "x", "symbol": "x", "label": "x", "enabled": true, "toggled": false] as [String: Any] From 909b6a9b2c3a46847d6feed5e79ac1c697565d62 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 22:37:11 +0000 Subject: [PATCH 09/30] fix: keep native menu tools on their menu activation path Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextTabs/PaneToolbarView.swift | 6 +++++- .../PaneMoreOverflowButtonTests.swift | 20 +++++++++++++++++++ .../cmux-next/check-menu-actions-baseline.txt | 2 ++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index 6e06423810aa..57526bb68449 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -336,7 +336,11 @@ public final class PaneToolbarView: NSView { } @objc private func pressNative(_ sender: TitlebarBandButton) { - guard nativeItems.indices.contains(sender.tag) else { return } + guard sender.isEnabled, nativeItems.indices.contains(sender.tag) else { return } + if !nativeItems[sender.tag].menu.isEmpty { + sender.performPressMenu() + return + } onNativeTool?(nativeItems[sender.tag].id, nil) } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift index a43c169c865a..f6bc49201786 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift @@ -22,6 +22,26 @@ import Testing #expect(view.moreButton.accessibilityLabel() == view.moreButton.label) } + @Test func nativeToolKeyboardActivationOpensItsMenu() throws { + let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + let tool = PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review", + menu: [PaneTabRowMenuItem(id: "open", label: "Open")]) + view.show(more: false, lit: false, nativeItems: [tool]) + var invoked: [String] = [] + view.onNativeTool = { id, _ in invoked.append(id) } + let button = try #require(view.nativeButtons.first) + var shown: NSMenu? + button.presentMenu = { menu, _ in shown = menu } + // Space sends the button's target/action; it must open the same menu as a pointer press. + #expect(button.sendAction(button.action, to: button.target)) + #expect(shown?.items.map(\.title) == ["Open"]) + #expect(invoked.isEmpty) + let row = try #require(shown?.items.first) + let action = try #require(row.action) + #expect(NSApplication.shared.sendAction(action, to: row.target, from: row)) + #expect(invoked == ["review"]) + } + @Test func aLitMenuDrawsInTheAccent() { let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) view.show(more: true, lit: true) diff --git a/scripts/cmux-next/check-menu-actions-baseline.txt b/scripts/cmux-next/check-menu-actions-baseline.txt index ee2285922a68..7bd0e88ea0fd 100644 --- a/scripts/cmux-next/check-menu-actions-baseline.txt +++ b/scripts/cmux-next/check-menu-actions-baseline.txt @@ -37,4 +37,6 @@ 1 Packages/macOS/CmuxNext/Sources/CmuxNextRemoteView/Pane/RemoteHoverToolbar.swift 2 Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarNoticeCardView.swift 1 Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarUpdateCardView.swift +# Page-declared tab-row menu rows return the selected id to the page, which owns the action (as WebContextMenu does). +1 Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift 3 Packages/macOS/CmuxNext/Sources/CmuxNextTerminal/TerminalSurfaceView+Pasteboard.swift From b9ddc923570c25d7bb09e0797a08d0cc0ffb7c5b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:48:04 +0000 Subject: [PATCH 10/30] fix: authenticate native tab tool gestures Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index c19003c37b91..9203b75c5485 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -206,7 +206,9 @@ public final class AgentPaneView: NSView { super.init(frame: .zero) pageEvents?.onUiPointerReport = { [weak self] value in self?.uiPointerReport?(value) } model.onTabRowToolInvoked = { [weak self] id, menuItemID in - self?.pageEvents?.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) + guard let self else { return } + AgentPanePersonInput.record(model.transport.gestures) + self.pageEvents?.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) } openURL = { [weak self] url, modifiers in if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) } From 9d942e235ec995573c351c2f67dbb3097aaddbf3 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:53:35 +0000 Subject: [PATCH 11/30] fix: gate native tab tools on displayed page host Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../Sources/CmuxNextAgentPane/AgentPaneTabRow.swift | 4 ++-- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift | 2 ++ .../CmuxNext/Sources/CmuxNextApp/PaneContentView.swift | 3 ++- .../macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift | 6 +++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift index 1930df499cdd..eeb812adb473 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift @@ -37,7 +37,7 @@ import CmuxNextSettings } /// The `tabRow` event for the row last offered (a page that subscribes again gets it first). - var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: ChromeTunables.nativeRowTools.value) } + var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: ChromeTunables.nativeRowTools.value && nativeRowToolsAvailable) } /// The tools' width in points at `zoom` (points per CSS pixel); nil while the setting is off or /// the page draws no tools in the row. @@ -57,7 +57,7 @@ import CmuxNextSettings public static func offer(points height: CGFloat, trailingInset: CGFloat, to view: AgentPaneView) { let zoom = max(0.25, view.webView.pageZoom) let on = ChromeTunables.tabRowTools.value && height > 0 - let nativeRowTools = ChromeTunables.nativeRowTools.value + let nativeRowTools = ChromeTunables.nativeRowTools.value && view.nativeRowToolsAvailable let row = view.model.tabRow let next = (height: on ? height / zoom : 0, trailingInset: on ? max(0, trailingInset) / zoom : 0) let shares = on && row.toolsWidth > 0 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index 9203b75c5485..bec7b3eca643 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -68,6 +68,8 @@ public final class AgentPaneView: NSView { /// host (`cmux-agent://pane`, deleted with P5 of the agent pane move). let page: PageWebView? let pageEvents: AgentPageProvider? + /// Native tab-row tools are supported only by the shared page host, which has a return event stream. + var nativeRowToolsAvailable: Bool { pageEvents != nil } /// Whether the page can take typing yet (the key dispatcher queues keys until then). public let inputReadiness: PageInputReadiness /// Re-pushes the theme when ui.animationSpeed or Reduce Motion changes, so the diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 22f8f4114129..68b1ac89ed4c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -209,7 +209,8 @@ final class PaneContentView: NSView, PaneContentChrome { } /// The selected agent page's model, for native controls owned by the page. - var agentPaneModel: AgentPaneModel? { (visibleContent as? AgentPaneView)?.model } + var displayedAgentPaneView: AgentPaneView? { visibleContent as? AgentPaneView } + var agentPaneModel: AgentPaneModel? { displayedAgentPaneView?.model } var paneFramesContent: Bool { !(visibleContent is AgentPaneView) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index 9f3c5b09c2a3..f9455feac7c8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -92,7 +92,7 @@ final class PaneToolbar { } } view.onNativeTool = { [weak pane] id, menuItemID in - pane?.view.agentPaneModel?.onTabRowToolInvoked?(id, menuItemID) + pane?.view.displayedAgentPaneView?.model.onTabRowToolInvoked?(id, menuItemID) } // task-owner: lives as long as the pane's view; event-driven (Observation) observation = Task { [weak self, weak pane] in @@ -111,13 +111,13 @@ final class PaneToolbar { // The pane's own workspace, not a store scan: this runs for every pane. Whichever tab is // selected, so the zoom button's room does not follow the selection. let zoomed = pane.workspace?.workspace.screens.contains { $0.zoomedPane == pane.pane.handle } ?? false - guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value) } + guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && pane.view.displayedAgentPaneView?.nativeRowToolsAvailable == true) } guard let tab = pane.pane.tabs.first(where: { $0.id == key }) else { // A window's own page or browser tab is never in the store: no "…", not a stale bar. let local = key.hasPrefix(LocalPageTab.prefix) || key.hasPrefix(LocalBrowserTab.prefix) return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local, nativeEnabled: ChromeTunables.nativeRowTools.value) } - return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value) + return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && pane.view.displayedAgentPaneView?.nativeRowToolsAvailable == true) } static func kind(_ tab: TabModel, isNewTabPage: Bool) -> Kind? { From 0b9c3d94a7fa4f66741a038a2720477f0b9817fe Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 23:57:27 +0000 Subject: [PATCH 12/30] fix: observe displayed agent model for native tools Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index f9455feac7c8..bf3cfd8fad31 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -105,19 +105,21 @@ final class PaneToolbar { } private static func state(_ pane: PaneController) -> State { + let displayed = pane.view.displayedAgentPaneView + let displayedModel = displayed?.model let pinned = pane.services.settings.map { PaneToolbarButtonsSetting.current($0).pinned } ?? [] let resolver = TabTitleResolver(services: pane.services) let paneKinds = Set(pane.pane.tabs.compactMap { kind($0, isNewTabPage: resolver.isNewTabPage($0)) }) // The pane's own workspace, not a store scan: this runs for every pane. Whichever tab is // selected, so the zoom button's room does not follow the selection. let zoomed = pane.workspace?.workspace.screens.contains { $0.zoomedPane == pane.pane.handle } ?? false - guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && pane.view.displayedAgentPaneView?.nativeRowToolsAvailable == true) } + guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && displayed?.nativeRowToolsAvailable == true) } guard let tab = pane.pane.tabs.first(where: { $0.id == key }) else { // A window's own page or browser tab is never in the store: no "…", not a stale bar. let local = key.hasPrefix(LocalPageTab.prefix) || key.hasPrefix(LocalBrowserTab.prefix) return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local, nativeEnabled: ChromeTunables.nativeRowTools.value) } - return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: pane.view.agentPaneModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && pane.view.displayedAgentPaneView?.nativeRowToolsAvailable == true) + return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && displayed?.nativeRowToolsAvailable == true) } static func kind(_ tab: TabModel, isNewTabPage: Bool) -> Kind? { From 10f356b5ff12e941a380002ddf41fda2174bba3c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:10:29 +0000 Subject: [PATCH 13/30] fix: preserve disabled native tools in overflow Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextTabs/PaneToolbarView.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index 57526bb68449..50888c57d08b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -180,7 +180,10 @@ public final class PaneToolbarView: NSView { row.isEnabled = tool.enabled row.state = tool.toggled ? .on : .off if tool.menu.isEmpty { - let invoker = NativeMenuInvoker { [weak self] in self?.onNativeTool?(tool.id, nil) } + let invoker = NativeMenuInvoker(enabled: { tool.enabled }) { [weak self] in + guard tool.enabled else { return } + self?.onNativeTool?(tool.id, nil) + } row.action = #selector(NativeMenuInvoker.run) row.target = invoker row.representedObject = invoker @@ -198,7 +201,10 @@ public final class PaneToolbarView: NSView { let row = NSMenuItem(title: item.label, action: #selector(NativeMenuInvoker.run), keyEquivalent: "") row.isEnabled = item.enabled if let checked = item.checked { row.state = checked ? .on : .off } - let invoker = NativeMenuInvoker { [weak self] in self?.onNativeTool?(toolID, item.id) } + let invoker = NativeMenuInvoker(enabled: { item.enabled }) { [weak self] in + guard item.enabled else { return } + self?.onNativeTool?(toolID, item.id) + } row.target = invoker row.representedObject = invoker if !item.children.isEmpty { @@ -345,10 +351,19 @@ public final class PaneToolbarView: NSView { } } -private final class NativeMenuInvoker: NSObject { +private final class NativeMenuInvoker: NSObject, NSMenuItemValidation { + let enabled: () -> Bool let action: () -> Void - init(action: @escaping () -> Void) { self.action = action } - @objc func run() { action() } + init(enabled: @escaping () -> Bool, action: @escaping () -> Void) { + self.enabled = enabled + self.action = action + } + @objc func run() { guard enabled() else { return }; action() } + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + let enabled = enabled() + menuItem.isEnabled = enabled + return enabled + } } // Like the strip's empty space (TabStripView+TitlebarDrag): in the window's top row, a press From f090747db984a5f9d40d27424930b384b8cd5393 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:17:20 +0000 Subject: [PATCH 14/30] fix: bound native tool text bytes Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextAgentPane/AgentPaneTabRowTools.swift | 4 +++- .../AgentPaneTabRowToolsTests.swift | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift index 6e0dbcbd2bf6..88913871b82e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -8,6 +8,7 @@ nonisolated enum AgentPaneTabRowTools { nonisolated static let maximumTotalMenuItems = 256 nonisolated static let maximumDepth = 2 nonisolated static let maximumText = 64 + nonisolated static let maximumTextBytes = 256 nonisolated static func list(_ raw: Any?) -> [PaneTabRowTool]? { guard let raw = raw as? [Any], raw.count <= maximumTools else { return nil } @@ -74,7 +75,8 @@ nonisolated enum AgentPaneTabRowTools { } private nonisolated static func text(_ value: Any?) -> String? { - guard let value = value as? String, !value.isEmpty, value.count <= maximumText else { return nil } + guard let value = value as? String, !value.isEmpty, + value.utf8.count <= maximumTextBytes, value.count <= maximumText else { return nil } return value } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index a2b9fc998815..7267cb94a477 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -18,9 +18,16 @@ struct AgentPaneTabRowToolsTests { "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, "menu": [["id": "open", "label": "Open", "enabled": true]], ] - let tools = AgentPaneTabRowTools.list([raw]) - #expect(tools?.first?.badge == nil) - #expect(tools?.first?.menu.first?.role == nil) + let tools = try #require(AgentPaneTabRowTools.list([raw])) + let item = try #require(tools.first?.menu.first) + #expect(tools.first?.badge == nil) + #expect(item.role == nil) + } + + @Test func rejectsOversizedUtf8Text() { + let combining = "a" + String(repeating: "\u{301}", count: 256) + let raw: [String: Any] = ["id": "x", "symbol": "x", "label": combining, "enabled": true, "toggled": false] + #expect(AgentPaneTabRowTools.list([raw]) == nil) } @Test func rejectsPresentInvalidRole() { From 52434fdbda1d260c081d1e8870de9f24f5df6a41 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 00:30:29 +0000 Subject: [PATCH 15/30] fix: refresh native tools after tab ownership changes Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../Sources/CmuxNextApp/PaneContentView.swift | 1 + .../CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift | 11 +++++++++++ .../AgentPaneTabRowToolsTests.swift | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 68b1ac89ed4c..7b2684c8ea17 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -323,6 +323,7 @@ final class PaneContentView: NSView, PaneContentChrome { // Another pane may own `previous` now and have taken its callback. if let previous, previous !== view, previous.superview == nil { (previous as? PaneContentChrome)?.onPaneHeaderHeightChange = nil } content = view + toolbar?.refresh() // After the incoming view is in: a browser that leaves stays parked // here, and the keyboard it held goes to what shows now. if let leaving { retire(leaving) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index bf3cfd8fad31..3908ee080780 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -55,6 +55,7 @@ final class PaneToolbar { /// Writes the pinned list (`tabs.toolbarButtons`); every pane follows the file. var writePinned: (PaneToolbarButtonsSetting) -> Void = { _ in } private var observation: Task? + private var refreshCurrent: (() -> Void)? init(registry: @escaping () -> ActionRegistry?, newTabMenu: @escaping () -> NSMenu?, paneTarget: ActionTargetRef? = nil, contentMenu: @escaping () -> [NSMenuItem] = { [] }) { @@ -94,6 +95,13 @@ final class PaneToolbar { view.onNativeTool = { [weak pane] id, menuItemID in pane?.view.displayedAgentPaneView?.model.onTabRowToolInvoked?(id, menuItemID) } + refreshCurrent = { [weak self, weak pane] in + guard let self, let pane else { return } + let state = Self.state(pane) + guard !state.behind else { return } + self.update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, + paneKinds: state.paneKinds, nativeItems: state.nativeItems, nativeEnabled: state.nativeEnabled) + } // task-owner: lives as long as the pane's view; event-driven (Observation) observation = Task { [weak self, weak pane] in for await state in ObservationStream({ [weak pane] in pane.map(Self.state) }) { @@ -104,6 +112,9 @@ final class PaneToolbar { } } + /// Rebinds controls immediately after the pane changes content ownership. + func refresh() { refreshCurrent?() } + private static func state(_ pane: PaneController) -> State { let displayed = pane.view.displayedAgentPaneView let displayedModel = displayed?.model diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index 7267cb94a477..74863a7721e6 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -13,7 +13,7 @@ struct AgentPaneTabRowToolsTests { #expect(tools?.first?.menu.first?.checked == true) } - @Test func decodesAbsentOptionalBadgeAndRole() { + @Test func decodesAbsentOptionalBadgeAndRole() throws { let raw: [String: Any] = [ "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, "menu": [["id": "open", "label": "Open", "enabled": true]], From 7ec0e53d99d76d72592a548a36f59c117bdd5fc4 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:05:36 +0000 Subject: [PATCH 16/30] Gate native tab-row tools on shared-host event support Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../AgentPaneModel+Header.swift | 2 +- .../CmuxNextAgentPane/AgentPaneTabRow.swift | 7 +- .../CmuxNextAgentPane/AgentPaneView.swift | 9 ++- .../AgentPaneNativeToolsHostTests.swift | 68 +++++++++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index c4be0fd7d6b4..ac70904214a8 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift @@ -168,7 +168,7 @@ extension AgentPaneModel { tabRow.report(width: CGFloat(width)) // used only while chrome.tabs.rowTools is on and the pane offered the row return AgentPaneReply.success() case .tabRowToolsSet(let tools): - guard ChromeTunables.nativeRowTools.value else { return AgentPaneReply.success() } + guard tabRow.nativeToolsEnabled else { return AgentPaneReply.success() } setTabRowTools(tools) return AgentPaneReply.success() case .tabState(let refresh): diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift index eeb812adb473..e846f5115e25 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift @@ -17,6 +17,9 @@ import CmuxNextSettings /// The row last pushed to the page, nil before the first. private(set) var offered: (height: CGFloat, trailingInset: CGFloat)? private var offeredNativeRowTools: Bool? + /// Only a shared page host has the return event stream needed by native controls. + var supportsNativeTools = false + public var nativeToolsEnabled: Bool { supportsNativeTools && ChromeTunables.nativeRowTools.value } /// The page reported its tools' width. func report(width: CGFloat) { @@ -37,7 +40,7 @@ import CmuxNextSettings } /// The `tabRow` event for the row last offered (a page that subscribes again gets it first). - var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: ChromeTunables.nativeRowTools.value && nativeRowToolsAvailable) } + var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: nativeToolsEnabled) } /// The tools' width in points at `zoom` (points per CSS pixel); nil while the setting is off or /// the page draws no tools in the row. @@ -57,7 +60,7 @@ import CmuxNextSettings public static func offer(points height: CGFloat, trailingInset: CGFloat, to view: AgentPaneView) { let zoom = max(0.25, view.webView.pageZoom) let on = ChromeTunables.tabRowTools.value && height > 0 - let nativeRowTools = ChromeTunables.nativeRowTools.value && view.nativeRowToolsAvailable + let nativeRowTools = view.model.tabRow.nativeToolsEnabled let row = view.model.tabRow let next = (height: on ? height / zoom : 0, trailingInset: on ? max(0, trailingInset) / zoom : 0) let shares = on && row.toolsWidth > 0 diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index bec7b3eca643..c9c2bba0f0b3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -68,8 +68,6 @@ public final class AgentPaneView: NSView { /// host (`cmux-agent://pane`, deleted with P5 of the agent pane move). let page: PageWebView? let pageEvents: AgentPageProvider? - /// Native tab-row tools are supported only by the shared page host, which has a return event stream. - var nativeRowToolsAvailable: Bool { pageEvents != nil } /// Whether the page can take typing yet (the key dispatcher queues keys until then). public let inputReadiness: PageInputReadiness /// Re-pushes the theme when ui.animationSpeed or Reduce Motion changes, so the @@ -207,10 +205,11 @@ public final class AgentPaneView: NSView { self.webView = webView super.init(frame: .zero) pageEvents?.onUiPointerReport = { [weak self] value in self?.uiPointerReport?(value) } + model.tabRow.supportsNativeTools = pageEvents != nil model.onTabRowToolInvoked = { [weak self] id, menuItemID in - guard let self else { return } - AgentPanePersonInput.record(model.transport.gestures) - self.pageEvents?.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) + guard let self, self.model.tabRow.nativeToolsEnabled, let pageEvents = self.pageEvents else { return } + AgentPanePersonInput.record(self.model.transport.gestures) + pageEvents.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) } openURL = { [weak self] url, modifiers in if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift new file mode 100644 index 000000000000..ff4e8327c2fc --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift @@ -0,0 +1,68 @@ +import AppKit +import CmuxNextDesign +import CmuxNextPages +import CmuxNextSettings +import Foundation +import Testing +@testable import CmuxNextAgentPane + +@Suite(.serialized) +@MainActor +struct AgentPaneNativeToolsHostTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } + + private func enableTools() -> () -> Void { + let store = TunableStore.shared + store.activate(file: nil) + store.register(ChromeTunables.all) + let key = ChromeTunables.nativeRowTools.key + let previous = store.override(key) + store.set(key, .bool(true)) + return { store.set(key, previous) } + } + + private var tool: PaneTabRowTool { + PaneTabRowTool(id: "project", symbol: "folder", label: "Project", enabled: true, toggled: false) + } + + @Test func unsupportedHostsKeepWebControlsAndRejectNativePublication() async throws { + let restore = enableTools() + defer { restore() } + let file = try #require(AgentPageTestRoot.page) + let url = try #require(URL(string: "http://127.0.0.1:4173")) + for source in [AgentPaneSource.bundled(file), .devServer(url)] { + let view = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: source, + pageHost: source != .bundled(file), chromium: nil)) + defer { view.close() } + #expect(view.pageEvents == nil) + #expect(!view.model.tabRow.nativeToolsEnabled) + AgentPaneTabRow.offer(points: 36, trailingInset: 40, to: view) + #expect(view.model.tabRow.event.value["nativeRowTools"]?.boolValue == false) + _ = await view.model.respond(to: .tabRowToolsSet([tool])) + #expect(view.model.tabRowTools.isEmpty) + } + } + + @Test func sharedHostPublishesCapabilityAndAcceptsToolsOnlyWhileEnabled() async throws { + let restore = enableTools() + defer { restore() } + let file = try #require(AgentPageTestRoot.page) + let view = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file), + pageHost: true, chromium: nil)) + defer { view.close() } + #expect(view.pageEvents != nil) + #expect(view.model.tabRow.nativeToolsEnabled) + _ = await view.model.respond(to: .tabRowToolsSet([tool])) + #expect(view.model.tabRowTools == [tool]) + let replay = try #require(AgentPanePageHost.currentEvents(view).first { $0.kind == "tabRow" }) + #expect(replay.value["nativeRowTools"]?.boolValue == true) + TunableStore.shared.set(ChromeTunables.nativeRowTools.key, .bool(false)) + AgentPaneTabRow.offer(points: 36, trailingInset: 40, to: view) + #expect(view.model.tabRow.event.value["nativeRowTools"]?.boolValue == false) + _ = await view.model.respond(to: .tabRowToolsSet([])) + #expect(view.model.tabRowTools == [tool], "a disabled capability must ignore page publications") + } + +} From 90fcb5ab7fbdf64426f57e25c38c3c9d2a4f4477 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:06:14 +0000 Subject: [PATCH 17/30] Authenticate native tool selections before returning page events Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../AgentPanePersonInput.swift | 5 +- .../AgentPaneView+TabRowTools.swift | 12 ++++ .../CmuxNextAgentPane/AgentPaneView.swift | 4 +- .../AgentPaneNativeToolsGestureTests.swift | 56 +++++++++++++++++++ .../AgentPaneNativeToolsHostTests.swift | 4 +- 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+TabRowTools.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsGestureTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePersonInput.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePersonInput.swift index cf565e486dc1..fab370193a3e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePersonInput.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePersonInput.swift @@ -36,8 +36,9 @@ enum AgentPanePersonInput { /// Records the gesture of a native choice (a menu item, a shortcut, a drop) only when the event /// in dispatch is the person's: a posted click or key, or a scripted run, grants nothing. /// `answer`: the native permission shortcut, which may answer a prompt as a click does. - @MainActor static func record(_ gestures: AgentPaneUserGestures, if byPerson: Bool = true, answer: Bool = false) { - if byPerson, currentIsPerson { gestures.record(click: answer) } + @MainActor static func record(_ gestures: AgentPaneUserGestures, if byPerson: Bool = true, answer: Bool = false, + event: NSEvent? = NSApp.currentEvent) { + if byPerson, let event, isPerson(event) { gestures.record(click: answer) } } private static func executablePath(_ pid: pid_t) -> String? { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+TabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+TabRowTools.swift new file mode 100644 index 000000000000..064e5a36e188 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView+TabRowTools.swift @@ -0,0 +1,12 @@ +import AppKit + +extension AgentPaneView { + /// A native button or menu selection is outside the webview's input monitor. Authenticate + /// its AppKit event before publishing so the ensuing page request can spend one gesture. + /// Keep this synchronous: a later task no longer has the selection's current event. + func invokeTabRowTool(_ id: String, menuItemID: String?, event: NSEvent? = NSApp.currentEvent) { + guard model.tabRow.nativeToolsEnabled, let pageEvents else { return } + AgentPanePersonInput.record(model.transport.gestures, event: event) + pageEvents.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index c9c2bba0f0b3..17ac0256fb16 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -207,9 +207,7 @@ public final class AgentPaneView: NSView { pageEvents?.onUiPointerReport = { [weak self] value in self?.uiPointerReport?(value) } model.tabRow.supportsNativeTools = pageEvents != nil model.onTabRowToolInvoked = { [weak self] id, menuItemID in - guard let self, self.model.tabRow.nativeToolsEnabled, let pageEvents = self.pageEvents else { return } - AgentPanePersonInput.record(self.model.transport.gestures) - pageEvents.publish(.tabRowToolInvoked(id: id, menuItemId: menuItemID)) + self?.invokeTabRowTool(id, menuItemID: menuItemID) } openURL = { [weak self] url, modifiers in if let self { LinkRouter.shared.open(url, modifiers: modifiers, from: self) } else { Self.routedOpenURL(url, modifiers) } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsGestureTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsGestureTests.swift new file mode 100644 index 000000000000..2b3a8878bcff --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsGestureTests.swift @@ -0,0 +1,56 @@ +import AppKit +import CmuxNextDesign +import CmuxNextPages +import CmuxNextSettings +import Foundation +import Testing +@testable import CmuxNextAgentPane + +extension AgentPaneNativeToolsHostTests { + @Test(.timeLimit(.minutes(1))) func authenticatedNativeSelectionRenewsCreditAndReachesPage() async throws { + let restore = enableTools() + defer { restore() } + final class Clock { var now: TimeInterval = 100 } + let clock = Clock() + let gestures = AgentPaneUserGestures(now: { clock.now }) + let model = AgentPaneModel(host: NoHost(), transport: AgentPaneTransport(gestures: gestures)) + let file = try #require(AgentPageTestRoot.page) + let view = try #require(AgentPaneView(model: model, source: .bundled(file), pageHost: true, chromium: nil)) + defer { view.close() } + let provider = try #require(view.pageEvents) + let (events, sink) = AsyncStream.makeStream(of: JSONValue.self) + let subscription = try await provider.subscribe(AgentPageProvider.hostEvents, filter: .null, + context: PageCallContext(page: PageDescriptor.agent.id)) { sink.yield($0) } + defer { subscription.cancel(); sink.finish() } + let click = try #require(CGEvent(mouseEventSource: nil, mouseType: .leftMouseUp, + mouseCursorPosition: .zero, mouseButton: .left)) + click.setIntegerValueField(.eventSourceUnixProcessID, value: 0) + let personEvent = try #require(NSEvent(cgEvent: click)) + gestures.record() + clock.now += AgentPaneUserGestures.lifetime + 1 + #expect(!gestures.isAvailable) + view.invokeTabRowTool("project", menuItemID: "instructions", event: personEvent) + #expect(gestures.consume(), "a native selection must replace expired credit") + #expect(!gestures.consume(), "the renewed credit is single-use") + view.invokeTabRowTool("project", menuItemID: "addFiles", event: personEvent) + #expect(gestures.consume(), "a later selection must replace consumed credit") + #expect(!gestures.hasFreshAnswerGesture, "project tools must not grant permission-answer credit") + click.setIntegerValueField(.eventSourceUnixProcessID, value: Int64(getpid()) + 1) + view.invokeTabRowTool("project", menuItemID: "instructions", event: try #require(NSEvent(cgEvent: click))) + #expect(!gestures.isAvailable, "another process's posted event must not grant credit") + view.invokeTabRowTool("project", menuItemID: nil, event: nil) + #expect(!gestures.isAvailable, "a call outside event dispatch must not grant credit") + + var received: [JSONValue] = [] + for await event in events where event["kind"] == .string("tabRowToolInvoked") { + received.append(event) + if received.count == 4 { break } + } + #expect(received == [ + AgentPageEvent.tabRowToolInvoked(id: "project", menuItemId: "instructions").data, + AgentPageEvent.tabRowToolInvoked(id: "project", menuItemId: "addFiles").data, + AgentPageEvent.tabRowToolInvoked(id: "project", menuItemId: "instructions").data, + AgentPageEvent.tabRowToolInvoked(id: "project", menuItemId: nil).data, + ]) + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift index ff4e8327c2fc..c0f585a38ec0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift @@ -9,11 +9,11 @@ import Testing @Suite(.serialized) @MainActor struct AgentPaneNativeToolsHostTests { - private actor NoHost: AgentPaneHostProviding { + actor NoHost: AgentPaneHostProviding { func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } } - private func enableTools() -> () -> Void { + func enableTools() -> () -> Void { let store = TunableStore.shared store.activate(file: nil) store.register(ChromeTunables.all) From 34192e4b0ed384ff520a1265b181d5c1a4225b62 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:09:12 +0000 Subject: [PATCH 18/30] Cover encoded text bounds and require decoded optional rows Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../AgentPaneTabRowTools.swift | 2 +- .../AgentPaneTabRowToolsTests.swift | 30 +++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift index 88913871b82e..9b04e5cc4b10 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRowTools.swift @@ -92,7 +92,7 @@ nonisolated enum AgentPaneTabRowTools { private nonisolated static func optionalRole(_ value: Any?) -> String? { guard let value, !(value is NSNull) else { return nil } - guard let value = value as? String, value == "destructive" else { return nil } + guard let value = text(value), value == "destructive" else { return nil } return value } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index 74863a7721e6..af1c920c9225 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -19,14 +19,34 @@ struct AgentPaneTabRowToolsTests { "menu": [["id": "open", "label": "Open", "enabled": true]], ] let tools = try #require(AgentPaneTabRowTools.list([raw])) - let item = try #require(tools.first?.menu.first) - #expect(tools.first?.badge == nil) + let tool = try #require(tools.first) + let item = try #require(tool.menu.first) + #expect(tool.badge == nil) #expect(item.role == nil) } - @Test func rejectsOversizedUtf8Text() { - let combining = "a" + String(repeating: "\u{301}", count: 256) - let raw: [String: Any] = ["id": "x", "symbol": "x", "label": combining, "enabled": true, "toggled": false] + @Test(arguments: ["id", "symbol", "label", "badge"]) + func boundsUtf8ForEveryToolTextField(field: String) throws { + var raw: [String: Any] = ["id": "x", "symbol": "x", "label": "x", "enabled": true, "toggled": false] + // Two graphemes, exactly 256 bytes: the encoded-size boundary is independent of String.count. + let boundary = "a" + String(repeating: "\u{301}", count: 127) + "b" + raw[field] = boundary + #expect(try #require(AgentPaneTabRowTools.list([raw])).count == 1) + raw[field] = boundary + "\u{301}" + #expect(AgentPaneTabRowTools.list([raw]) == nil) + } + + @Test(arguments: ["id", "label"]) + func boundsUtf8ForNestedMenuTextFields(field: String) throws { + let boundary = "a" + String(repeating: "\u{301}", count: 127) + "b" + var item: [String: Any] = ["id": "child", "label": "Child", "enabled": true] + var raw: [String: Any] = ["id": "x", "symbol": "x", "label": "x", "enabled": true, "toggled": false] + item[field] = boundary + raw["menu"] = [["id": "parent", "label": "Parent", "enabled": true, "children": [item]]] + let tools = try #require(AgentPaneTabRowTools.list([raw])) + #expect(tools.first?.menu.first?.children.count == 1) + item[field] = boundary + "\u{301}" + raw["menu"] = [["id": "parent", "label": "Parent", "enabled": true, "children": [item]]] #expect(AgentPaneTabRowTools.list([raw]) == nil) } From 48c9a3744846b0ec3c0959072b593e419f1a1bc9 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:11:12 +0000 Subject: [PATCH 19/30] Observe the displayed page owner and bind its toolbar callbacks Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../PaneContentView+PaintHold.swift | 1 + .../Sources/CmuxNextApp/PaneContentView.swift | 19 ++++- .../Sources/CmuxNextApp/PaneToolbar.swift | 35 +++++--- .../PaneNativeToolOwnershipTests.swift | 85 +++++++++++++++++++ 4 files changed, 122 insertions(+), 18 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+PaintHold.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+PaintHold.swift index 3cf9208f6b54..975124c95b1f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+PaintHold.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+PaintHold.swift @@ -54,6 +54,7 @@ extension PaneContentView { let keptStrip = holdKeepsStrip holdKeepsStrip = false refreshContentFrame() + refreshNativeToolOwner() guard keptStrip else { return } needsLayout = true layoutSubtreeIfNeeded() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 7b2684c8ea17..d925fc23546c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -26,6 +26,8 @@ final class PaneContentView: NSView, PaneContentChrome { private var paneBackgroundsObservation: Task? let contentHost = NSView() private(set) weak var content: NSView? + /// Explicitly observed ownership: first-paint holds can change this without a tab-store update. + let nativeToolOwner = PaneNativeToolOwner() /// A view in the window that this pane, in no window yet, shows: it moves here when the pane enters one. private var windowEntry = WindowEntryHandoff() private var tokenObservation: Task? @@ -208,9 +210,11 @@ final class PaneContentView: NSView, PaneContentChrome { paintHold.kept.flatMap { $0.superview === contentHost ? $0 : nil } ?? (hostsContent ? content : nil) } - /// The selected agent page's model, for native controls owned by the page. - var displayedAgentPaneView: AgentPaneView? { visibleContent as? AgentPaneView } - var agentPaneModel: AgentPaneModel? { displayedAgentPaneView?.model } + /// The controls and their callback follow the same page, including an outgoing first-paint hold. + func refreshNativeToolOwner() { + nativeToolOwner.model = (visibleContent as? AgentPaneView)?.model + toolbar?.refresh() + } var paneFramesContent: Bool { !(visibleContent is AgentPaneView) } @@ -323,7 +327,7 @@ final class PaneContentView: NSView, PaneContentChrome { // Another pane may own `previous` now and have taken its callback. if let previous, previous !== view, previous.superview == nil { (previous as? PaneContentChrome)?.onPaneHeaderHeightChange = nil } content = view - toolbar?.refresh() + refreshNativeToolOwner() // After the incoming view is in: a browser that leaves stays parked // here, and the keyboard it held goes to what shows now. if let leaving { retire(leaving) } @@ -364,6 +368,7 @@ final class PaneContentView: NSView, PaneContentChrome { let shown = hostsContent ? content : nil (shown as? PaneContentChrome)?.onPaneHeaderHeightChange = nil content = nil + refreshNativeToolOwner() if let shown { if parks { retire(shown) } else { shown.removeFromSuperview() } } applyCornerRadius() updateBand() @@ -429,3 +434,9 @@ private let tabSwitchLog = Logger(subsystem: "com.cmuxterm.app.next", category: private func tabSwitchMark(_ name: String) { tabSwitchLog.debug("tab-switch \(name, privacy: .public) \(Date().timeIntervalSince1970 * 1_000, format: .fixed(precision: 3), privacy: .public)") } + +/// Separates observable page ownership from AppKit's non-observable view hierarchy. +@Observable +final class PaneNativeToolOwner { + weak var model: AgentPaneModel? +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift index 3908ee080780..f19b58595393 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneToolbar.swift @@ -1,5 +1,6 @@ import AppKit import CmuxNextActions +import CmuxNextAgentPane import CmuxNextBridge import CmuxNextCompat import CmuxNextDaemon @@ -35,6 +36,7 @@ final class PaneToolbar { /// it shows. Hiding "…" for that frame gave its width to the strip, which laid out every /// tab again at once (10 tabs, cx-codh2). var behind = false + var nativeOwnerID: ObjectIdentifier? var nativeItems: [PaneTabRowTool] = [] var nativeEnabled = false } @@ -92,22 +94,16 @@ final class PaneToolbar { } } } - view.onNativeTool = { [weak pane] id, menuItemID in - pane?.view.displayedAgentPaneView?.model.onTabRowToolInvoked?(id, menuItemID) - } refreshCurrent = { [weak self, weak pane] in guard let self, let pane else { return } - let state = Self.state(pane) - guard !state.behind else { return } - self.update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, - paneKinds: state.paneKinds, nativeItems: state.nativeItems, nativeEnabled: state.nativeEnabled) + self.apply(Self.state(pane), owner: pane.view.nativeToolOwner) } // task-owner: lives as long as the pane's view; event-driven (Observation) observation = Task { [weak self, weak pane] in for await state in ObservationStream({ [weak pane] in pane.map(Self.state) }) { guard let self, let state else { return } - if state.behind { continue } - update(target: state.target, kind: state.kind, zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds, nativeItems: state.nativeItems, nativeEnabled: state.nativeEnabled) + guard let pane else { return } + apply(state, owner: pane.view.nativeToolOwner) } } } @@ -115,22 +111,33 @@ final class PaneToolbar { /// Rebinds controls immediately after the pane changes content ownership. func refresh() { refreshCurrent?() } + /// Apply one ownership snapshot; a retained menu can never retarget its IDs to another page. + func apply(_ state: State, owner: PaneNativeToolOwner) { + let model = owner.model + guard state.nativeOwnerID == model.map(ObjectIdentifier.init) else { return } + view.onNativeTool = { [weak model, weak owner] id, menuItemID in + guard let model, owner?.model === model, model.tabRow.nativeToolsEnabled else { return } + model.onTabRowToolInvoked?(id, menuItemID) + } + update(target: state.behind ? target : state.target, kind: state.behind ? kind : state.kind, + zoomed: state.zoomed, pinned: state.pinned, paneKinds: state.paneKinds, nativeItems: state.nativeItems, nativeEnabled: state.nativeEnabled) + } + private static func state(_ pane: PaneController) -> State { - let displayed = pane.view.displayedAgentPaneView - let displayedModel = displayed?.model + let displayedModel = pane.view.nativeToolOwner.model let pinned = pane.services.settings.map { PaneToolbarButtonsSetting.current($0).pinned } ?? [] let resolver = TabTitleResolver(services: pane.services) let paneKinds = Set(pane.pane.tabs.compactMap { kind($0, isNewTabPage: resolver.isNewTabPage($0)) }) // The pane's own workspace, not a store scan: this runs for every pane. Whichever tab is // selected, so the zoom button's room does not follow the selection. let zoomed = pane.workspace?.workspace.screens.contains { $0.zoomedPane == pane.pane.handle } ?? false - guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && displayed?.nativeRowToolsAvailable == true) } + guard let key = pane.stripModel.selectedID?.rawValue else { return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeOwnerID: displayedModel.map(ObjectIdentifier.init), nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: displayedModel?.tabRow.nativeToolsEnabled == true) } guard let tab = pane.pane.tabs.first(where: { $0.id == key }) else { // A window's own page or browser tab is never in the store: no "…", not a stale bar. let local = key.hasPrefix(LocalPageTab.prefix) || key.hasPrefix(LocalBrowserTab.prefix) - return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local, nativeEnabled: ChromeTunables.nativeRowTools.value) + return State(zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, behind: !local, nativeOwnerID: displayedModel.map(ObjectIdentifier.init), nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: displayedModel?.tabRow.nativeToolsEnabled == true) } - return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: ChromeTunables.nativeRowTools.value && displayed?.nativeRowToolsAvailable == true) + return State(target: key, kind: kind(tab, isNewTabPage: resolver.isNewTabPage(tab)), zoomed: zoomed, pinned: pinned, paneKinds: paneKinds, nativeOwnerID: displayedModel.map(ObjectIdentifier.init), nativeItems: displayedModel?.tabRowTools ?? [], nativeEnabled: displayedModel?.tabRow.nativeToolsEnabled == true) } static func kind(_ tab: TabModel, isNewTabPage: Bool) -> Kind? { diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift new file mode 100644 index 000000000000..f0a4585c9305 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift @@ -0,0 +1,85 @@ +#if DEBUG +import AppKit +import CmuxNextActions +@testable import CmuxNextAgentPane +@testable import CmuxNextApp +import CmuxNextCompat +import CmuxNextDesign +import CmuxNextSettings +@testable import CmuxNextTabs +import Foundation +import Testing + +/// The tools follow the page on screen, including the end of a first-paint hold without a tab-store update. +@MainActor @Suite(.serialized) struct PaneNativeToolOwnershipTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } + + private func snapshot(_ owner: PaneNativeToolOwner) -> PaneToolbar.State { + let model = owner.model + return PaneToolbar.State(nativeOwnerID: model.map(ObjectIdentifier.init), nativeItems: model?.tabRowTools ?? [], + nativeEnabled: model?.tabRow.nativeToolsEnabled == true) + } + + @Test func holdReleaseRebindsObservationAndIdenticalToolCallbacks() async throws { + let store = TunableStore.shared, key = ChromeTunables.nativeRowTools.key + store.activate(file: nil) + store.register(ChromeTunables.all) + let previous = store.override(key) + store.set(key, .bool(true)) + defer { store.set(key, previous) } + let directory = FileManager.default.temporaryDirectory.appending(path: "native-tool-owner-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "index.html") + try Data("Chat".utf8).write(to: file) + let first = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + let second = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + defer { first.close(); second.close() } + first.model.tabRow.supportsNativeTools = true + second.model.tabRow.supportsNativeTools = true + first.model.markPainted() + let tool = PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review") + first.model.setTabRowTools([tool]) + second.model.setTabRowTools([tool]) + var firstCalls = 0, secondCalls = 0 + first.model.onTabRowToolInvoked = { _, _ in firstCalls += 1 } + second.model.onTabRowToolInvoked = { _, _ in secondCalls += 1 } + let pane = PaneContentView(stripModel: TabStripModel(tabs: []), reveal: LaunchReveal(deadline: .zero)) + let registry = ActionRegistry.standard() + let toolbar = PaneToolbar(registry: { registry }, newTabMenu: { nil }) + pane.toolbar = toolbar + pane.show(first) + pane.show(second) + try #require(pane.paintHold.kept === first) + let owner = pane.nativeToolOwner + let current = ObservationStream.current { snapshot(owner) } + var updates = current.later.makeAsyncIterator() + toolbar.apply(current.value, owner: owner) + let oldInvocation = try #require(toolbar.view.onNativeTool) + oldInvocation("review", nil) + #expect(firstCalls == 1 && secondCalls == 0) + + second.model.markPainted() + #expect(owner.model === second) + let next = try #require(await updates.next()) + #expect(next != current.value, "identical tools still change their owner") + toolbar.apply(next, owner: owner) + oldInvocation("review", nil) + toolbar.view.onNativeTool?("review", nil) + #expect(firstCalls == 1 && secondCalls == 1, "a retained outgoing callback must not retarget to the incoming page") + + second.model.setTabRowTools([]) + let changed = try #require(await updates.next()) + #expect(changed.nativeItems.isEmpty, "observation must now subscribe to the incoming model") + toolbar.apply(changed, owner: owner) + #expect(toolbar.view.nativeButtons.isEmpty) + pane.detachContent() + let detached = try #require(await updates.next()) + #expect(detached.nativeOwnerID == nil && detached.nativeItems.isEmpty) + toolbar.apply(detached, owner: owner) + #expect(toolbar.view.nativeButtons.isEmpty) + } +} +#endif From f9e4339cfd2da97e56cbf78fac73e64e16e2ee06 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:24:44 +0000 Subject: [PATCH 20/30] Preserve disabled native tools in overflow menus Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextTabs/PaneToolbarView.swift | 53 +++++++++-------- .../PaneMoreOverflowButtonTests.swift | 59 +++++++++++++++++++ 2 files changed, 89 insertions(+), 23 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index 50888c57d08b..a7e9b3e08f20 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -167,50 +167,57 @@ public final class PaneToolbarView: NSView { private func menu(for tool: PaneTabRowTool) -> NSMenu? { let menu = NSMenu(title: tool.label) + let invoke = onNativeTool menu.autoenablesItems = false - for item in tool.menu { menu.addItem(menuItem(item, toolID: tool.id)) } + for item in tool.menu { menu.addItem(menuItem(item, tool: tool, ancestorsEnabled: tool.enabled, invoke: invoke)) } return menu } /// Native tools remain reachable from the overflow menu when a narrow pane cannot fit every /// supplied button. The same invoker path keeps the page event identical to a direct press. public func nativeOverflowItems() -> [NSMenuItem] { - nativeItems.map { tool in + let invoke = onNativeTool + return nativeItems.map { tool in let row = NSMenuItem(title: tool.label, action: nil, keyEquivalent: "") row.isEnabled = tool.enabled row.state = tool.toggled ? .on : .off - if tool.menu.isEmpty { - let invoker = NativeMenuInvoker(enabled: { tool.enabled }) { [weak self] in - guard tool.enabled else { return } - self?.onNativeTool?(tool.id, nil) - } - row.action = #selector(NativeMenuInvoker.run) - row.target = invoker - row.representedObject = invoker - } else { + let invoker = NativeMenuInvoker(enabled: { [weak self] in + tool.enabled && self?.nativeItems.contains(tool) == true + }) { + guard tool.menu.isEmpty else { return } + invoke?(tool.id, nil) + } + row.action = #selector(NativeMenuInvoker.run) + row.target = invoker + row.representedObject = invoker + if !tool.menu.isEmpty { let submenu = NSMenu(title: tool.label) submenu.autoenablesItems = false - tool.menu.forEach { submenu.addItem(menuItem($0, toolID: tool.id)) } + tool.menu.forEach { submenu.addItem(menuItem($0, tool: tool, ancestorsEnabled: tool.enabled, invoke: invoke)) } row.submenu = submenu } return row } } - private func menuItem(_ item: PaneTabRowMenuItem, toolID: String) -> NSMenuItem { + private func menuItem(_ item: PaneTabRowMenuItem, tool: PaneTabRowTool, ancestorsEnabled: Bool, + invoke: ((String, String?) -> Void)?) -> NSMenuItem { let row = NSMenuItem(title: item.label, action: #selector(NativeMenuInvoker.run), keyEquivalent: "") - row.isEnabled = item.enabled + let enabled = ancestorsEnabled && item.enabled + row.isEnabled = enabled if let checked = item.checked { row.state = checked ? .on : .off } - let invoker = NativeMenuInvoker(enabled: { item.enabled }) { [weak self] in - guard item.enabled else { return } - self?.onNativeTool?(toolID, item.id) + let invoker = NativeMenuInvoker(enabled: { [weak self] in + enabled && self?.nativeItems.contains(tool) == true + }) { + guard item.children.isEmpty else { return } + invoke?(tool.id, item.id) } row.target = invoker row.representedObject = invoker if !item.children.isEmpty { let submenu = NSMenu(title: item.label) submenu.autoenablesItems = false - item.children.forEach { submenu.addItem(self.menuItem($0, toolID: toolID)) } + item.children.forEach { submenu.addItem(self.menuItem($0, tool: tool, ancestorsEnabled: enabled, invoke: invoke)) } row.submenu = submenu } return row @@ -358,12 +365,12 @@ private final class NativeMenuInvoker: NSObject, NSMenuItemValidation { self.enabled = enabled self.action = action } - @objc func run() { guard enabled() else { return }; action() } - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - let enabled = enabled() - menuItem.isEnabled = enabled - return enabled + @objc func run() { + guard enabled() else { return } + action() } + + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { enabled() } } // Like the strip's empty space (TabStripView+TitlebarDrag): in the window's top row, a press diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift index f6bc49201786..d6c1350f51ff 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift @@ -42,6 +42,65 @@ import Testing #expect(invoked == ["review"]) } + @Test func overflowValidationPreservesDisabledToolsAndAncestors() throws { + let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + let child = PaneTabRowMenuItem(id: "child", label: "Child") + view.show(more: true, lit: false, nativeItems: [ + PaneTabRowTool(id: "disabled", symbol: "checkmark", label: "Disabled", enabled: false), + PaneTabRowTool(id: "disabled-menu", symbol: "checkmark", label: "Disabled menu", enabled: false, menu: [child]), + PaneTabRowTool(id: "menu", symbol: "checkmark", label: "Menu", menu: [ + PaneTabRowMenuItem(id: "parent", label: "Parent", enabled: false, children: [child]), + ]), + PaneTabRowTool(id: "enabled", symbol: "checkmark", label: "Enabled"), + ]) + var invoked: [String] = [] + view.onNativeTool = { id, _ in invoked.append(id) } + let menu = NSMenu() + // Registry-built overflow menus enable this; AppKit must still honor page declarations. + menu.autoenablesItems = true + view.nativeOverflowItems().forEach { menu.addItem($0) } + menu.update() + let disabled = try #require(menu.item(at: 0)) + #expect(!disabled.isEnabled) + let disabledMenu = try #require(menu.item(at: 1)) + let disabledChild = try #require(disabledMenu.submenu?.items.first) + #expect(!disabledMenu.isEnabled) + #expect(!disabledChild.isEnabled) + let nestedParent = try #require(menu.item(at: 2)?.submenu?.items.first) + let nestedChild = try #require(nestedParent.submenu?.items.first) + #expect(!nestedParent.isEnabled) + #expect(!nestedChild.isEnabled) + for row in [disabled, disabledMenu, disabledChild, nestedParent, nestedChild] { + let action = try #require(row.action) + // A direct selector dispatch must not bypass menu validation or disabled ancestors. + #expect(NSApplication.shared.sendAction(action, to: row.target, from: row)) + } + #expect(invoked.isEmpty) + let enabled = try #require(menu.item(at: 3)) + #expect(enabled.isEnabled) + #expect(NSApplication.shared.sendAction(try #require(enabled.action), to: enabled.target, from: enabled)) + #expect(invoked == ["enabled"]) + } + + @Test func retainedOverflowRowsCannotInvokeReplacedTools() throws { + let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + view.show(more: true, lit: false, nativeItems: [ + PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review"), + ]) + let row = try #require(view.nativeOverflowItems().first) + var invoked = false + view.onNativeTool = { _, _ in invoked = true } + view.show(more: true, lit: false, nativeItems: [ + PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review", enabled: false), + ]) + let menu = NSMenu() + menu.addItem(row) + menu.update() + #expect(!row.isEnabled) + #expect(NSApplication.shared.sendAction(try #require(row.action), to: row.target, from: row)) + #expect(!invoked) + } + @Test func aLitMenuDrawsInTheAccent() { let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) view.show(more: true, lit: true) From bc4050654d0f6c0c1f020d8def5ec32ca2256228 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 03:44:47 +0000 Subject: [PATCH 21/30] Place native tool ownership state with agent pane models Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift | 9 +++++++++ .../CmuxNext/Sources/CmuxNextApp/PaneContentView.swift | 5 ----- 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift new file mode 100644 index 000000000000..e7267e4347f9 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift @@ -0,0 +1,9 @@ +import CmuxNextCompat +import Observation + +/// Separates observable page ownership from AppKit's non-observable view hierarchy. +@MainActor @Observable +public final class PaneNativeToolOwner { + public weak var model: AgentPaneModel? + public init(model: AgentPaneModel? = nil) { self.model = model } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index d925fc23546c..5d03ac4cd18b 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -435,8 +435,3 @@ private func tabSwitchMark(_ name: String) { tabSwitchLog.debug("tab-switch \(name, privacy: .public) \(Date().timeIntervalSince1970 * 1_000, format: .fixed(precision: 3), privacy: .public)") } -/// Separates observable page ownership from AppKit's non-observable view hierarchy. -@Observable -final class PaneNativeToolOwner { - weak var model: AgentPaneModel? -} From 8f0b7bb5edb21f9b1c89878405d03c424d94a5af Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 21:16:43 -0700 Subject: [PATCH 22/30] cmux next: the tabRow event reports the native-tools flag last offered (build fix) AgentPaneTabRow has no view, so it can't read nativeRowToolsAvailable; offer() records the flag in offeredNativeRowTools right before it reads the event. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-5b0be570f0b96d2d4c7b --- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift index eeb812adb473..413ccf937a96 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTabRow.swift @@ -37,7 +37,7 @@ import CmuxNextSettings } /// The `tabRow` event for the row last offered (a page that subscribes again gets it first). - var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: ChromeTunables.nativeRowTools.value && nativeRowToolsAvailable) } + var event: AgentPageEvent { .tabRow(height: offered?.height ?? 0, trailingInset: offered?.trailingInset ?? 0, nativeRowTools: offeredNativeRowTools ?? false) } /// The tools' width in points at `zoom` (points per CSS pixel); nil while the setting is off or /// the page draws no tools in the row. From dba1d6853aab3199cc2675d3ffed0924828da956 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Thu, 29 Oct 2026 21:45:37 -0700 Subject: [PATCH 23/30] =?UTF-8?q?cmux=20next:=20native=20row=20tools=20dro?= =?UTF-8?q?p=20on=20page=20reload,=20list=20only=20hidden=20tools=20under?= =?UTF-8?q?=20=E2=80=A6,=20follow=20the=20selected=20chat=20during=20a=20p?= =?UTF-8?q?aint=20hold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for #27133: a page that loads again clears the old page's native tools; the overflow menu lists only native tools whose buttons are hidden; the toolbar derives its native tools from the selected (incoming) chat rather than the outgoing view a paint hold keeps. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-5b0be570f0b96d2d4c7b --- .../AgentPaneHandshakeReply.swift | 6 ++++- .../Sources/CmuxNextApp/PaneContentView.swift | 5 ++-- .../CmuxNextTabs/PaneToolbarView.swift | 6 +++-- .../AgentPaneTabRowToolsTests.swift | 16 +++++++++++- .../AgentPaneFramelessChromeTests.swift | 25 +++++++++++++++++++ .../PaneMoreOverflowButtonTests.swift | 17 +++++++++++++ 6 files changed, 69 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift index 96faefbe14eb..34ced2139ce5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneHandshakeReply.swift @@ -7,7 +7,11 @@ import Foundation enum AgentPaneHandshakeReply { static func reply(to request: AgentPaneRequest, in model: AgentPaneModel) async -> [String: Any] { // A page that loaded again starts without the tab row: it is offered again. - if request == .ready { model.tabRow.pageLoaded() } + // Its native tools were the old page's: they go too, so no stale button sends an old id to the new page. + if request == .ready { + model.tabRow.pageLoaded() + model.setTabRowTools([]) + } model.setCheckpointAvailable(false) model.pageCommands = [] do { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 7b2684c8ea17..114f61b2e110 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -208,8 +208,9 @@ final class PaneContentView: NSView, PaneContentChrome { paintHold.kept.flatMap { $0.superview === contentHost ? $0 : nil } ?? (hostsContent ? content : nil) } - /// The selected agent page's model, for native controls owned by the page. - var displayedAgentPaneView: AgentPaneView? { visibleContent as? AgentPaneView } + /// The selected agent page, for native controls owned by the page: the incoming one while a + /// paint hold still shows the outgoing view, so the toolbar never shows the outgoing tab's tools. + var displayedAgentPaneView: AgentPaneView? { hostsContent ? content as? AgentPaneView : nil } var agentPaneModel: AgentPaneModel? { displayedAgentPaneView?.model } var paneFramesContent: Bool { !(visibleContent is AgentPaneView) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index 50888c57d08b..f48bd08c4675 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -173,9 +173,11 @@ public final class PaneToolbarView: NSView { } /// Native tools remain reachable from the overflow menu when a narrow pane cannot fit every - /// supplied button. The same invoker path keeps the page event identical to a direct press. + /// supplied button: only those whose button is hidden, so a tool that shows is not listed twice. + /// The same invoker path keeps the page event identical to a direct press. public func nativeOverflowItems() -> [NSMenuItem] { - nativeItems.map { tool in + layoutSubtreeIfNeeded() + return zip(nativeItems, nativeButtons).compactMap { tool, button in button.isHidden ? tool : nil }.map { tool in let row = NSMenuItem(title: tool.label, action: nil, keyEquivalent: "") row.isEnabled = tool.enabled row.state = tool.toggled ? .on : .off diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift index 74863a7721e6..9a6cf9752910 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneTabRowToolsTests.swift @@ -1,8 +1,22 @@ +import CmuxNextDesign import Foundation import Testing @testable import CmuxNextAgentPane -struct AgentPaneTabRowToolsTests { +@MainActor struct AgentPaneTabRowToolsTests { + private actor NoHost: AgentPaneHostProviding { + func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } + } + + /// A page that loads again starts without the old page's native tools: no stale button stays, + /// and none sends an old id to the new page. + @Test func aPageThatLoadsAgainDropsItsNativeTools() async { + let model = AgentPaneModel(host: NoHost()) + model.setTabRowTools([PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review")]) + #expect(model.tabRowTools.map(\.id) == ["review"]) + _ = await model.respond(to: .ready) + #expect(model.tabRowTools.isEmpty, "the old page's native tools survived its reload") + } @Test func decodesToolsAndNestedMenus() { let raw: [String: Any] = [ "id": "review", "symbol": "checkmark", "label": "Review", "enabled": true, "toggled": false, diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift index b7014b32022e..04c59f08db17 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift @@ -51,6 +51,31 @@ import Testing #expect(host.chrome.showsRing, "a regular pane lost its configured focus frame after a chat") } + /// While a paint hold keeps the outgoing chat shown, the pane's native tools follow the selected + /// (incoming) chat: they never show or invoke the outgoing tab's tools. + @Test func nativeToolsFollowTheSelectedChatDuringAPaintHold() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "chat-tools-hold-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "index.html") + try Data("Chat".utf8).write(to: file) + let outgoing = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + defer { outgoing.close() } + let incoming = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + defer { incoming.close() } + let pane = PaneContentView(stripModel: TabStripModel(tabs: [TabItem(id: TabID("chat"), title: "Chat")]), + reveal: LaunchReveal(deadline: .zero)) + pane.show(outgoing) + outgoing.model.markPainted() + #expect(pane.displayedAgentPaneView === outgoing) + pane.show(incoming) + try #require(pane.paintHold.kept === outgoing) + #expect(pane.displayedAgentPaneView === incoming, "the toolbar follows the outgoing chat kept for the paint hold") + incoming.model.markPainted() + #expect(pane.paintHold.kept == nil) + #expect(pane.displayedAgentPaneView === incoming) + } + private func updateChrome(_ host: PaneHostView) { var ring = FocusRingSettings() ring.enabled = true diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift index f6bc49201786..685b8684fd3f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift @@ -42,6 +42,23 @@ import Testing #expect(invoked == ["review"]) } + /// "…" lists only the native tools whose buttons do not fit: a tool that shows is not listed twice. + @Test func overflowListsOnlyNativeToolsThatDoNotFit() { + let tools = ["review", "share"].map { PaneTabRowTool(id: $0, symbol: "checkmark", label: $0) } + let wide = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 400, height: 30)) + wide.show(more: true, lit: false, nativeItems: tools) + wide.layoutSubtreeIfNeeded() + #expect(wide.nativeButtons.allSatisfy { !$0.isHidden }) + #expect(wide.nativeOverflowItems().isEmpty, "visible native tools are duplicated under …") + + let narrow = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 1, height: 30)) + narrow.show(more: true, lit: false, nativeItems: tools) + narrow.layoutSubtreeIfNeeded() + let hidden = zip(tools, narrow.nativeButtons).filter { $0.1.isHidden }.map(\.0.label) + #expect(!hidden.isEmpty) + #expect(narrow.nativeOverflowItems().map(\.title) == hidden) + } + @Test func aLitMenuDrawsInTheAccent() { let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) view.show(more: true, lit: true) From 5aba361e5f20d6a29fa8afbc8dd17293c7318042 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 04:54:49 +0000 Subject: [PATCH 24/30] Refresh native menu enabled state during validation Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift index a7e9b3e08f20..f3ca9de202cd 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/PaneToolbarView.swift @@ -370,7 +370,11 @@ private final class NativeMenuInvoker: NSObject, NSMenuItemValidation { action() } - func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { enabled() } + func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + let value = enabled() + menuItem.isEnabled = value + return value + } } // Like the strip's empty space (TabStripView+TitlebarDrag): in the window's top row, a press From da09f216e1821f739b80b7e93332691bb3ca6f4b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 07:40:25 +0000 Subject: [PATCH 25/30] Restore design import for tab row tool types Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index f7d0d249d0bb..b1ab56823181 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -1,4 +1,5 @@ public import AppKit +public import CmuxNextDesign public import CmuxNextSettings public import Foundation public import Observation From a5695e4b77f1d379ce41c13aac50b5530841753c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 07:58:44 +0000 Subject: [PATCH 26/30] Export Observation for native tool owner Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift index e7267e4347f9..ad236576569d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/PaneNativeToolOwner.swift @@ -1,5 +1,5 @@ import CmuxNextCompat -import Observation +public import Observation /// Separates observable page ownership from AppKit's non-observable view hierarchy. @MainActor @Observable From f1f053f20c2694fc88956829ba7fcdaf4f6de209 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 08:51:14 +0000 Subject: [PATCH 27/30] Restore incoming native tool owner test surface Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNext/Sources/CmuxNextApp/PaneContentView.swift | 6 +++++- .../AgentPaneNativeToolsHostTests.swift | 2 +- .../CmuxNextAppTests/PaneNativeToolOwnershipTests.swift | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 1d0e98b5fbf5..bf58b60e2bec 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -210,9 +210,13 @@ final class PaneContentView: NSView, PaneContentChrome { paintHold.kept.flatMap { $0.superview === contentHost ? $0 : nil } ?? (hostsContent ? content : nil) } + /// The selected agent page, for native controls owned by the page: the incoming one while a + /// paint hold still shows the outgoing view. + var displayedAgentPaneView: AgentPaneView? { hostsContent ? content as? AgentPaneView : nil } + /// The controls and their callback follow the selected page, including an outgoing first-paint hold. func refreshNativeToolOwner() { - nativeToolOwner.model = hostsContent ? (content as? AgentPaneView)?.model : nil + nativeToolOwner.model = displayedAgentPaneView?.model toolbar?.refresh() } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift index c0f585a38ec0..e3451c6f004f 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift @@ -16,7 +16,7 @@ struct AgentPaneNativeToolsHostTests { func enableTools() -> () -> Void { let store = TunableStore.shared store.activate(file: nil) - store.register(ChromeTunables.all) + store.register(DesignTunables.all) let key = ChromeTunables.nativeRowTools.key let previous = store.override(key) store.set(key, .bool(true)) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift index f0a4585c9305..5cbefd62257e 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift @@ -25,7 +25,7 @@ import Testing @Test func holdReleaseRebindsObservationAndIdenticalToolCallbacks() async throws { let store = TunableStore.shared, key = ChromeTunables.nativeRowTools.key store.activate(file: nil) - store.register(ChromeTunables.all) + store.register(DesignTunables.all) let previous = store.override(key) store.set(key, .bool(true)) defer { store.set(key, previous) } From b0d6113755b66d6705d9c1e33cd712d7292c2e0c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 09:42:53 +0000 Subject: [PATCH 28/30] Keep native tool ownership aligned through reparenting Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextAgentPane/AgentPaneView.swift | 4 ++++ .../CmuxNextApp/PaneContentView+Parking.swift | 3 +++ .../Sources/CmuxNextApp/PaneContentView.swift | 4 ++++ .../AgentPaneFramelessChromeTests.swift | 2 +- .../PaneNativeToolOwnershipTests.swift | 21 ++++++++++++++----- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index 90a7e04f6eef..1e91830a8f33 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -447,9 +447,13 @@ public final class AgentPaneView: NSView { applyTheme() } + /// Called by a pane when AppKit reparents this page, so native toolbar ownership cannot stay stale. + var onNativeToolOwnerChange: (() -> Void)? + public override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() themeFollower.rebind() + onNativeToolOwnerChange?() } public override func viewDidMoveToWindow() { diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift index 183c9c63f270..3a04daf4caa4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift @@ -50,6 +50,7 @@ extension PaneContentView { view.frame = contentHost.bounds view.autoresizingMask = [.width, .height] contentHost.addSubview(view) + refreshNativeToolOwner() // Parked in the pane it came from. if view is PaneParkableContent { view.isHidden = false } return @@ -82,6 +83,7 @@ extension PaneContentView { guard view.superview === contentHost else { return } guard view is PaneParkableContent else { view.removeFromSuperview() + refreshNativeToolOwner() return } // The keyboard never stays in a view that does not show: the window @@ -108,6 +110,7 @@ extension PaneContentView { guard let view = entry.view, view.superview === contentHost, view !== content else { return true } guard live?(view) ?? true else { view.removeFromSuperview() + refreshNativeToolOwner() return true } return false diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index bf58b60e2bec..8b030c58c52a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -330,6 +330,9 @@ final class PaneContentView: NSView, PaneContentChrome { view?.reparentRootedThemeScope() // Another pane may own `previous` now and have taken its callback. if let previous, previous !== view, previous.superview == nil { (previous as? PaneContentChrome)?.onPaneHeaderHeightChange = nil } + if let agent = view as? AgentPaneView { + agent.onNativeToolOwnerChange = { [weak self] in self?.refreshNativeToolOwner() } + } content = view refreshNativeToolOwner() // After the incoming view is in: a browser that leaves stays parked @@ -371,6 +374,7 @@ final class PaneContentView: NSView, PaneContentChrome { endPaintHold() let shown = hostsContent ? content : nil (shown as? PaneContentChrome)?.onPaneHeaderHeightChange = nil + (shown as? AgentPaneView)?.onNativeToolOwnerChange = nil content = nil refreshNativeToolOwner() if let shown { if parks { retire(shown) } else { shown.removeFromSuperview() } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift index 04c59f08db17..7f76847f7ae2 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/AgentPaneFramelessChromeTests.swift @@ -70,7 +70,7 @@ import Testing #expect(pane.displayedAgentPaneView === outgoing) pane.show(incoming) try #require(pane.paintHold.kept === outgoing) - #expect(pane.displayedAgentPaneView === incoming, "the toolbar follows the outgoing chat kept for the paint hold") + #expect(pane.displayedAgentPaneView === incoming, "the toolbar follows the incoming selected chat during the paint hold") incoming.model.markPainted() #expect(pane.paintHold.kept == nil) #expect(pane.displayedAgentPaneView === incoming) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift index 5cbefd62257e..6fc301418d02 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift @@ -51,9 +51,8 @@ import Testing let toolbar = PaneToolbar(registry: { registry }, newTabMenu: { nil }) pane.toolbar = toolbar pane.show(first) - pane.show(second) - try #require(pane.paintHold.kept === first) let owner = pane.nativeToolOwner + #expect(owner.model === first, "the initially shown page owns the tools") let current = ObservationStream.current { snapshot(owner) } var updates = current.later.makeAsyncIterator() toolbar.apply(current.value, owner: owner) @@ -61,14 +60,21 @@ import Testing oldInvocation("review", nil) #expect(firstCalls == 1 && secondCalls == 0) + pane.show(second) + try #require(pane.paintHold.kept === first) + #expect(owner.model === second, "the selected incoming page owns tools during the paint hold") + let incomingInvocation = try #require(toolbar.view.onNativeTool) + incomingInvocation("review", nil) + #expect(firstCalls == 1 && secondCalls == 1, "a fresh callback during the hold reaches the incoming page") + oldInvocation("review", nil) + #expect(firstCalls == 2 && secondCalls == 1, "the retained outgoing callback stays bound to the old page") + second.model.markPainted() - #expect(owner.model === second) let next = try #require(await updates.next()) #expect(next != current.value, "identical tools still change their owner") toolbar.apply(next, owner: owner) - oldInvocation("review", nil) toolbar.view.onNativeTool?("review", nil) - #expect(firstCalls == 1 && secondCalls == 1, "a retained outgoing callback must not retarget to the incoming page") + #expect(firstCalls == 2 && secondCalls == 2, "the current callback remains on the incoming page") second.model.setTabRowTools([]) let changed = try #require(await updates.next()) @@ -80,6 +86,11 @@ import Testing #expect(detached.nativeOwnerID == nil && detached.nativeItems.isEmpty) toolbar.apply(detached, owner: owner) #expect(toolbar.view.nativeButtons.isEmpty) + + pane.show(second) + try #require(second.superview === pane.contentHost) + second.removeFromSuperview() + #expect(owner.model == nil, "reparenting content away clears native tool ownership") } } #endif From e6bc95d241434ebd526a5c9dccd757c95c544f5c Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 10:21:07 +0000 Subject: [PATCH 29/30] Clear native tool ownership callbacks when panes retire Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextAgentPane/AgentPaneView.swift | 4 +++- .../CmuxNextApp/PaneContentView+Parking.swift | 9 ++++++++- .../Sources/CmuxNextApp/PaneContentView.swift | 9 ++++++++- .../PaneNativeToolOwnershipTests.swift | 19 +++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift index 1e91830a8f33..14cda9f3236a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneView.swift @@ -399,6 +399,8 @@ public final class AgentPaneView: NSView { /// Stops the page (and its WebSocket) for good; call when the tab closes. public func close() { + // A closed page must not notify the pane that used to host it after it leaves. + onNativeToolOwnerChange = nil popupHostStorage?.hide() rateReapply?.cancel() motionObservation?.cancel() @@ -448,7 +450,7 @@ public final class AgentPaneView: NSView { } /// Called by a pane when AppKit reparents this page, so native toolbar ownership cannot stay stale. - var onNativeToolOwnerChange: (() -> Void)? + public var onNativeToolOwnerChange: (() -> Void)? public override func viewDidMoveToSuperview() { super.viewDidMoveToSuperview() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift index 3a04daf4caa4..f9079779c5d0 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift @@ -81,6 +81,7 @@ extension PaneContentView { /// content leaves the pane. A view another pane took is not touched. func retire(_ view: NSView) { guard view.superview === contentHost else { return } + (view as? AgentPaneView)?.onNativeToolOwnerChange = nil guard view is PaneParkableContent else { view.removeFromSuperview() refreshNativeToolOwner() @@ -107,8 +108,14 @@ extension PaneContentView { /// `live` (a closed tab, a replaced view). func prunePark(live: ((NSView) -> Bool)? = nil) { parked.removeAll { entry in - guard let view = entry.view, view.superview === contentHost, view !== content else { return true } + guard let view = entry.view else { return true } + guard view.superview === contentHost else { + (view as? AgentPaneView)?.onNativeToolOwnerChange = nil + return true + } + guard view !== content else { return true } guard live?(view) ?? true else { + (view as? AgentPaneView)?.onNativeToolOwnerChange = nil view.removeFromSuperview() refreshNativeToolOwner() return true diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index 8b030c58c52a..d08ca2a580aa 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -216,6 +216,10 @@ final class PaneContentView: NSView, PaneContentChrome { /// The controls and their callback follow the selected page, including an outgoing first-paint hold. func refreshNativeToolOwner() { + if let agent = content as? AgentPaneView, !hostsContent { + // Reparenting invokes this refresh on the old pane before the new pane binds its callback. + agent.onNativeToolOwnerChange = nil + } nativeToolOwner.model = displayedAgentPaneView?.model toolbar?.refresh() } @@ -329,7 +333,10 @@ final class PaneContentView: NSView, PaneContentChrome { // A terminal's theme scope inherits this pane's workspace theme. view?.reparentRootedThemeScope() // Another pane may own `previous` now and have taken its callback. - if let previous, previous !== view, previous.superview == nil { (previous as? PaneContentChrome)?.onPaneHeaderHeightChange = nil } + if let previous, previous !== view, previous.superview == nil { + (previous as? PaneContentChrome)?.onPaneHeaderHeightChange = nil + (previous as? AgentPaneView)?.onNativeToolOwnerChange = nil + } if let agent = view as? AgentPaneView { agent.onNativeToolOwnerChange = { [weak self] in self?.refreshNativeToolOwner() } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift index 6fc301418d02..b7e22ab3a58d 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift @@ -92,5 +92,24 @@ import Testing second.removeFromSuperview() #expect(owner.model == nil, "reparenting content away clears native tool ownership") } + + @Test func closingShownViewClearsOwnerHookWithoutRebindingPane() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "closed-native-tool-owner-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "index.html") + try Data("Chat".utf8).write(to: file) + let page = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + let pane = PaneContentView(stripModel: TabStripModel(tabs: []), reveal: LaunchReveal(deadline: .zero)) + pane.show(page) + let owner = pane.nativeToolOwner + let ownerBeforeClose = try #require(owner.model) + #expect(page.onNativeToolOwnerChange != nil) + + page.close() + + #expect(page.onNativeToolOwnerChange == nil, "a closed page must not retain the pane callback") + #expect(owner.model === ownerBeforeClose, "closing the page must not rebind the pane owner") + } } #endif From f8e3ebd6b078b28dd9046109d3a828159fb69643 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 12:26:06 +0000 Subject: [PATCH 30/30] Bound native tool ownership tests and preserve adopted hooks Agent-Session: https://cmux.com/deeplink/agent?id=aid-031b1d956a6374b7cf7a --- .../CmuxNextApp/PaneContentView+Parking.swift | 2 +- .../Sources/CmuxNextApp/PaneContentView.swift | 2 +- .../AgentPaneNativeToolsHostTests.swift | 1 + .../PaneMoreOverflowButtonTests.swift | 19 +++-- .../PaneNativeToolOwnershipTests.swift | 79 ++++++++++++++++--- 5 files changed, 84 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift index f9079779c5d0..7a9be237a00e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView+Parking.swift @@ -110,7 +110,7 @@ extension PaneContentView { parked.removeAll { entry in guard let view = entry.view else { return true } guard view.superview === contentHost else { - (view as? AgentPaneView)?.onNativeToolOwnerChange = nil + if view.superview == nil { (view as? AgentPaneView)?.onNativeToolOwnerChange = nil } return true } guard view !== content else { return true } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift index d08ca2a580aa..88f23d7d320c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/PaneContentView.swift @@ -216,7 +216,7 @@ final class PaneContentView: NSView, PaneContentChrome { /// The controls and their callback follow the selected page, including an outgoing first-paint hold. func refreshNativeToolOwner() { - if let agent = content as? AgentPaneView, !hostsContent { + if let agent = content as? AgentPaneView, !hostsContent, agent.superview == nil { // Reparenting invokes this refresh on the old pane before the new pane binds its callback. agent.onNativeToolOwnerChange = nil } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift index e3451c6f004f..41699f13f4af 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneNativeToolsHostTests.swift @@ -54,6 +54,7 @@ struct AgentPaneNativeToolsHostTests { defer { view.close() } #expect(view.pageEvents != nil) #expect(view.model.tabRow.nativeToolsEnabled) + AgentPaneTabRow.offer(points: 36, trailingInset: 40, to: view) _ = await view.model.respond(to: .tabRowToolsSet([tool])) #expect(view.model.tabRowTools == [tool]) let replay = try #require(AgentPanePageHost.currentEvents(view).first { $0.kind == "tabRow" }) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift index acbb9f74e6ed..7c733bdf6545 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneMoreOverflowButtonTests.swift @@ -44,7 +44,7 @@ import Testing @Test func overflowValidationPreservesDisabledToolsAndAncestors() throws { - let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 1, height: 30)) let child = PaneTabRowMenuItem(id: "child", label: "Child") view.show(more: true, lit: false, nativeItems: [ PaneTabRowTool(id: "disabled", symbol: "checkmark", label: "Disabled", enabled: false), @@ -59,15 +59,18 @@ import Testing let menu = NSMenu() // Registry-built overflow menus enable this; AppKit must still honor page declarations. menu.autoenablesItems = true + view.layoutSubtreeIfNeeded() view.nativeOverflowItems().forEach { menu.addItem($0) } menu.update() - let disabled = try #require(menu.item(at: 0)) + let rows = menu.items + try #require(rows.count == 4, "all four native tools must overflow before indexing menu rows") + let disabled = rows[0] #expect(!disabled.isEnabled) - let disabledMenu = try #require(menu.item(at: 1)) + let disabledMenu = rows[1] let disabledChild = try #require(disabledMenu.submenu?.items.first) #expect(!disabledMenu.isEnabled) #expect(!disabledChild.isEnabled) - let nestedParent = try #require(menu.item(at: 2)?.submenu?.items.first) + let nestedParent = try #require(rows[2].submenu?.items.first) let nestedChild = try #require(nestedParent.submenu?.items.first) #expect(!nestedParent.isEnabled) #expect(!nestedChild.isEnabled) @@ -77,23 +80,25 @@ import Testing #expect(NSApplication.shared.sendAction(action, to: row.target, from: row)) } #expect(invoked.isEmpty) - let enabled = try #require(menu.item(at: 3)) + let enabled = rows[3] #expect(enabled.isEnabled) #expect(NSApplication.shared.sendAction(try #require(enabled.action), to: enabled.target, from: enabled)) #expect(invoked == ["enabled"]) } @Test func retainedOverflowRowsCannotInvokeReplacedTools() throws { - let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 200, height: 30)) + let view = PaneToolbarView(frame: NSRect(x: 0, y: 0, width: 1, height: 30)) view.show(more: true, lit: false, nativeItems: [ PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review"), ]) - let row = try #require(view.nativeOverflowItems().first) + view.layoutSubtreeIfNeeded() var invoked = false view.onNativeTool = { _, _ in invoked = true } + let row = try #require(view.nativeOverflowItems().first) view.show(more: true, lit: false, nativeItems: [ PaneTabRowTool(id: "review", symbol: "checkmark", label: "Review", enabled: false), ]) + view.layoutSubtreeIfNeeded() let menu = NSMenu() menu.addItem(row) menu.update() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift index b7e22ab3a58d..65339b662ea0 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/PaneNativeToolOwnershipTests.swift @@ -12,6 +12,37 @@ import Testing /// The tools follow the page on screen, including the end of a first-paint hold without a tab-store update. @MainActor @Suite(.serialized) struct PaneNativeToolOwnershipTests { + private nonisolated struct UpdateTimedOut: Error, CustomStringConvertible { + let label: String + var description: String { "Timed out after 2 seconds waiting for \(label)" } + } + + @MainActor private final class UpdateIterator { + var iterator: ObservationStream.Iterator + + init(_ iterator: ObservationStream.Iterator) { self.iterator = iterator } + + func next() async -> PaneToolbar.State? { + var pending = iterator + let value = await pending.next() + iterator = pending + return value + } + } + + private func nextUpdate(_ updates: UpdateIterator, label: String) async throws -> PaneToolbar.State { + try await withThrowingTaskGroup(of: PaneToolbar.State?.self) { group in + group.addTask { await updates.next() } + group.addTask { + try await Task.sleep(for: .seconds(2)) + throw UpdateTimedOut(label: label) + } + defer { group.cancelAll() } + guard let value = try await group.next(), let value else { throw UpdateTimedOut(label: label) } + return value + } + } + private actor NoHost: AgentPaneHostProviding { func handshake(sessionId: String?) async throws -> AgentPaneHandshake { throw AgentPaneHostError.daemonStopped } } @@ -52,9 +83,9 @@ import Testing pane.toolbar = toolbar pane.show(first) let owner = pane.nativeToolOwner - #expect(owner.model === first, "the initially shown page owns the tools") + #expect(owner.model === first.model, "the initially shown page owns the tools") let current = ObservationStream.current { snapshot(owner) } - var updates = current.later.makeAsyncIterator() + let updates = UpdateIterator(current.later.makeAsyncIterator()) toolbar.apply(current.value, owner: owner) let oldInvocation = try #require(toolbar.view.onNativeTool) oldInvocation("review", nil) @@ -62,27 +93,28 @@ import Testing pane.show(second) try #require(pane.paintHold.kept === first) - #expect(owner.model === second, "the selected incoming page owns tools during the paint hold") + let switched = try await nextUpdate(updates, label: "selected page ownership") + #expect(switched.nativeOwnerID == ObjectIdentifier(second.model), "owner observation follows the selected page") + #expect(owner.model === second.model, "the selected incoming page owns tools during the paint hold") + toolbar.apply(switched, owner: owner) let incomingInvocation = try #require(toolbar.view.onNativeTool) incomingInvocation("review", nil) #expect(firstCalls == 1 && secondCalls == 1, "a fresh callback during the hold reaches the incoming page") oldInvocation("review", nil) - #expect(firstCalls == 2 && secondCalls == 1, "the retained outgoing callback stays bound to the old page") + #expect(firstCalls == 1 && secondCalls == 1, "the retained outgoing callback is rejected after ownership changes; it cannot retarget") second.model.markPainted() - let next = try #require(await updates.next()) - #expect(next != current.value, "identical tools still change their owner") - toolbar.apply(next, owner: owner) + #expect(owner.model === second.model, "releasing the hold does not change the selected owner") toolbar.view.onNativeTool?("review", nil) - #expect(firstCalls == 2 && secondCalls == 2, "the current callback remains on the incoming page") + #expect(firstCalls == 1 && secondCalls == 2, "the current callback remains on the incoming page") second.model.setTabRowTools([]) - let changed = try #require(await updates.next()) + let changed = try await nextUpdate(updates, label: "incoming page tools") #expect(changed.nativeItems.isEmpty, "observation must now subscribe to the incoming model") toolbar.apply(changed, owner: owner) #expect(toolbar.view.nativeButtons.isEmpty) pane.detachContent() - let detached = try #require(await updates.next()) + let detached = try await nextUpdate(updates, label: "detached ownership") #expect(detached.nativeOwnerID == nil && detached.nativeItems.isEmpty) toolbar.apply(detached, owner: owner) #expect(toolbar.view.nativeButtons.isEmpty) @@ -93,6 +125,33 @@ import Testing #expect(owner.model == nil, "reparenting content away clears native tool ownership") } + @Test func pruningParkedViewAdoptedByAnotherPanePreservesItsNewHook() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "adopted-native-tool-owner-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "index.html") + try Data("Chat".utf8).write(to: file) + let page = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + defer { page.close() } + page.model.markPainted() + let firstPane = PaneContentView(stripModel: TabStripModel(tabs: []), reveal: LaunchReveal(deadline: .zero)) + let replacement = try #require(AgentPaneView(model: AgentPaneModel(host: NoHost()), source: .bundled(file))) + replacement.model.markPainted() + defer { replacement.close() } + firstPane.show(page) + firstPane.show(replacement) + #expect(firstPane.parked.contains { $0.view === page }) + + let secondPane = PaneContentView(stripModel: TabStripModel(tabs: []), reveal: LaunchReveal(deadline: .zero)) + secondPane.show(page) + try #require(page.onNativeToolOwnerChange != nil) + firstPane.prunePark() + + #expect(page.onNativeToolOwnerChange != nil, "pruning the old pane must not clear the adopted pane's hook") + page.removeFromSuperview() + #expect(secondPane.nativeToolOwner.model == nil, "the preserved hook clears the adopting pane when the page leaves") + } + @Test func closingShownViewClearsOwnerHookWithoutRebindingPane() throws { let directory = FileManager.default.temporaryDirectory.appending(path: "closed-native-tool-owner-\(UUID().uuidString)") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)