From cc30a4bbfc59aa84da02fc480224494670349425 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 19:40:20 +0000 Subject: [PATCH 1/7] cmux NEXT: a subagent's pane is named after the subagent, not its parent chat Leo, 2026-10-30: "subagents shouldn't be tab-named the exact same as the parent." A subagent's pane is an agent tab on its chat's own acpmux session, so the tab strip and the sidebar's tab rows, which name an agent tab by its session (TabTitleResolver -> AgentChatTabTitle), named it exactly like the chat. The page now sends the subagent's own name with chat.subagentPane (Subagent.name: its task, the Agent tool's description), and the app keeps it per tab (SubagentTabTitles: recorded by session and subagent when the request arrives, bound to the tab once it is created). The title rule reads it before the chat's title, after any name the person gave the tab. No name (an older page, a workflow agent) leaves the tab as before. The openSubagent hook keeps its signature. A restored subagent tab after a relaunch shows its chat in full, so it is named like the chat again. Tests: SubagentTabTitleTests (the subagent's tab reads its own name while the chat keeps its own; blank, unnamed, other-session, over-long and closed-tab cases; the request decodes with and without `title`) and webviews/test/subagent-pane-tab-title.test.ts (the request carries it). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- .../AgentPaneModel+Header.swift | 4 +- .../AgentPaneRequest+Decoding.swift | 3 +- .../CmuxNextAgentPane/AgentPaneRequest.swift | 2 +- .../CmuxNextAgentPane/SubagentTabTitles.swift | 46 ++++++++++++ .../CmuxNextApp/AgentTabs+Header.swift | 3 +- .../CmuxNextApp/TabTitleResolver.swift | 5 ++ .../SubagentTabTitleTests.swift | 70 +++++++++++++++++++ webviews/src/agent-session/acpmux/App.tsx | 7 +- .../acpmux/subagents/subagentPanes.ts | 8 ++- webviews/test/subagent-pane-tab-title.test.ts | 19 +++++ 10 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift create mode 100644 webviews/test/subagent-pane-tab-title.test.ts diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index aea6ae7cfb78..aad03c447a33 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift @@ -222,12 +222,14 @@ extension AgentPaneModel { return AgentPaneReply.success(state) case .sideChat(let fork): return await AgentPaneSideChat.open(fork, in: self) - case .subagentPane(let agent, let focus): + case .subagentPane(let agent, let focus, let title): // The chat's own subagent, in a pane of its own; not from a New Tab page. A refusal // answers `opened: false`, and the page shows the subagent in this pane instead. guard let sessionId, newTab == nil, let header else { return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.subagentPane") } + // Its tab is named after the subagent, not the chat (SubagentTabTitles). + SubagentTabTitles.shared.name(agent, of: sessionId, title: title) return AgentPaneReply.success(["opened": header.openSubagent(sessionId, agent, focus)]) case .processTerminal(let call): return AgentPaneProcessViewer.open(call, in: self) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 3416e38570aa..2116d9b28177 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -380,7 +380,8 @@ extension AgentPaneRequest { if let id = params?["sessionId"] as? String, !id.isEmpty, id.count <= 256 { self = .sideChat(id) } else { self = .unsupported(method) } case "chat.subagentPane": if let id = params?["subagent"] as? String, !id.isEmpty, id.count <= 256 { - self = .subagentPane(id, focus: params?["focus"] as? Bool == true) + // `title`: the subagent's own name, which its pane's tab shows (SubagentTabTitles). + self = .subagentPane(id, focus: params?["focus"] as? Bool == true, title: params?["title"] as? String) } else { self = .unsupported(method) } case "chat.processTerminal": if let id = params?["toolCallId"] as? String, AgentPaneProcessViewer.isToken(id) { self = .processTerminal(toolCall: id) } else { self = .unsupported(method) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index f9b7a67eab44..849840435a76 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -201,7 +201,7 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { case asksAnswer(AgentPaneAskAnswer) /// `chat.subagentPane {subagent, focus}`: this chat's subagent in an agent pane of its own, /// right of the chat (``AgentSubagentPanes``); `focus` moves keyboard focus to it. - case subagentPane(String, focus: Bool) + case subagentPane(String, focus: Bool, title: String? = nil) /// `chat.processTerminal {toolCallId}`: the background process this chat's tool call started, /// in a read-only terminal tab beside the chat (``AgentPaneProcessViewer``). case processTerminal(toolCall: String) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift new file mode 100644 index 000000000000..e7e648e628c4 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift @@ -0,0 +1,46 @@ +import Foundation +import Observation + +/// The names subagent panes' tabs show. A subagent's pane is a tab on its chat's own acpmux session +/// (``AgentSubagentPanes``), so by session it would be named like the chat. Leo 2026-10-30: +/// "subagents shouldn't be tab-named the exact same as the parent". The page sends the subagent's +/// own name with `chat.subagentPane` (its task, for the Agent tool the call's description); the app +/// gives it to the tab it opens (``name(_:of:title:)``, then ``bind(_:of:toTab:)``), and the tab title +/// rule reads it (``title(ofTab:)``) after any name the person gave the tab. +@MainActor @Observable +public final class SubagentTabTitles { + public static let shared = SubagentTabTitles() + + /// Each subagent's name as its page sent it, by session and subagent id. + @ObservationIgnored private var names: [String: String] = [:] + /// The name of each subagent pane's tab, by tab id. + private var tabs: [String: String] = [:] + + public init() {} + + /// The longest name a tab keeps. + public static let limit = 200 + + /// Records subagent `agent` of session `session` as named `title` (trimmed, at most ``limit`` + /// characters; an empty one records nothing). + public func name(_ agent: String, of session: String, title: String?) { + let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + names[Self.key(session, agent)] = String(trimmed.prefix(Self.limit)) + } + + /// Gives tab `tab`, a new pane on subagent `agent` of `session`, that subagent's name, when one + /// was recorded. `isOpen` drops the names of tabs that closed. + public func bind(_ agent: String, of session: String, toTab tab: String, isOpen: (String) -> Bool = { _ in true }) { + guard let title = names[Self.key(session, agent)] else { return } + tabs = tabs.filter { isOpen($0.key) } + tabs[tab] = title + } + + /// The name tab `tab` shows as a subagent's pane, nil for any other tab. + public func title(ofTab tab: String) -> String? { + tabs[tab] + } + + private static func key(_ session: String, _ agent: String) -> String { "\(session)\u{1F}\(agent)" } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift index 5c3605ca0f5d..e0284d136ff2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift @@ -92,7 +92,8 @@ extension AgentTabStore { place: { [weak self] anchor, placement, created, finished in guard let self else { return false } let open: (PaneID, DaemonService) throws -> AgentTabPending = { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try self.open(in: pane, of: daemon, session: session) } - let made: @MainActor (String) -> Void = { [weak self] in created($0); self?.revealTurn(AgentSubagentPanes.reveal(agent), in: $0); if placement == .tab, !focus, anchor != provisional { self?.selectInPane($0) } } + // The tab takes the subagent's own name, never the chat's (SubagentTabTitles). + let made: @MainActor (String) -> Void = { [weak self] in created($0); SubagentTabTitles.shared.bind(agent, of: session, toTab: $0) { [weak self] in self?.locate($0) != nil }; self?.revealTurn(AgentSubagentPanes.reveal(agent), in: $0); if placement == .tab, !focus, anchor != provisional { self?.selectInPane($0) } } if anchor == provisional, let placed = openInMainPane(resolve(provisional), focus, open, made, finished) { return placed } return openBeside(anchor, split: placement != .tab, direction: placement == .down ? "down" : "right", origin: focus ? .user : .script, open: open, created: made, finished: finished) }, pane: { [weak self] in self?.locate(self?.resolve($0) ?? $0).map { "\(ObjectIdentifier($0.daemon).hashValue):\($0.pane.rawValue)" } }, width: { [weak self] in self?.paneWidth(self?.resolve($0) ?? $0) }, again: { [weak self] request in _ = self?.openSubagent(request.agent, of: session, focus: request.focus, beside: request.chat) })) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabTitleResolver.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabTitleResolver.swift index 6768c7cc1072..790a8ea7672e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabTitleResolver.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/TabTitleResolver.swift @@ -17,6 +17,8 @@ struct TabTitleResolver { var showsNewTabPage: @MainActor (TabModel) -> Bool /// A New Tab page tab's own title (`AgentHistoryPage.title`), if it has one. var newTabPageTitle: @MainActor (TabModel) -> String? = { _ in nil } + /// A subagent pane's own name (``SubagentTabTitles``), nil for any other tab. + var subagentTitle: @MainActor (TabModel) -> String? = { SubagentTabTitles.shared.title(ofTab: $0.id) } /// An agent chat's title before the user names it (`AgentChatTabTitle`). var chatTitle: @MainActor (TabModel) -> String = { AgentChatTabTitle.untitled($0.agentSession) } /// The name of the workspace an asked tab is in, when the caller knows it: an agent chat @@ -70,6 +72,9 @@ struct TabTitleResolver { func untitled(_ tab: TabModel, fallback: String = Strings.untitledTerminal) -> String { if tab.agentSession != nil { if isNewTabPage(tab) { return newTabPageTitle(tab) ?? Strings.untitledBrowser } + // A subagent's pane is a tab on its chat's session: it shows the subagent's own name, never + // the chat's (Leo 2026-10-30: "subagents shouldn't be tab-named the exact same as the parent"). + if let subagent = subagentTitle(tab) { return subagent } let title = chatTitle(tab) if title == AgentPaneModel.tabTitle, let name = workspaceName.flatMap(AgentChatTabTitle.taskName) { return name } return title diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift new file mode 100644 index 000000000000..86d2d196bc5c --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift @@ -0,0 +1,70 @@ +import CmuxNextActions +import CmuxNextAgentPane +import CmuxNextBridge +import CmuxNextDaemon +import Foundation +import Testing +@testable import CmuxNextApp + +/// Leo 2026-10-30: "subagents shouldn't be tab-named the exact same as the parent". A subagent's +/// pane is a tab on its chat's own session, so the tab strip and the sidebar's tab rows named it by +/// that session, like the chat. It now shows the name its page sent (SubagentTabTitles), while the +/// chat keeps its own, and a name the person gives the tab still wins. +@MainActor +struct SubagentTabTitleTests { + private static let host = "install:this-mac" + + @Test func aSubagentsPaneIsNamedAfterTheSubagentNotTheChat() throws { + let daemon = DaemonService() + let ref = AgentSessionRef(host: Self.host, session: "s-\(UUID().uuidString)", harness: "claude") + var chat = TabSnapshot(surface: 1, kind: .conversation, title: "about:blank", url: "about:blank") + chat.conversation = ConversationTabRef(agentSession: ref) + var subagent = TabSnapshot(surface: 2, kind: .conversation, title: "about:blank", url: "about:blank") + subagent.conversation = ConversationTabRef(agentSession: ref) + let screen = ScreenSnapshot(id: 1, layout: .leaf(1), panes: [PaneSnapshot(id: 1, tabs: [chat, subagent])]) + daemon.store.apply(snapshot: DaemonTree(workspaces: [WorkspaceSnapshot(id: 1, key: nil, name: "w", screens: [screen])])) + let chatTab = try #require(daemon.store.tab(surface: 1)), subagentTab = try #require(daemon.store.tab(surface: 2)) + + let titles = SubagentTabTitles() + var strip = TabTitleResolver(newTabPages: []) + strip.chatTitle = { _ in "claude-sr2-coord" } + strip.subagentTitle = { titles.title(ofTab: $0.id) } + #expect(strip.title(subagentTab) == strip.title(chatTab), "fixture: before, the subagent's tab reads like its chat") + + titles.name("agent-1", of: ref.session, title: " Review the tray motion diff \n") + titles.bind("agent-1", of: ref.session, toTab: subagentTab.id) + #expect(strip.title(subagentTab) == "Review the tray motion diff") + #expect(strip.title(chatTab) == "claude-sr2-coord", "the chat lost its own name") + } + + @Test func onlyANamedSubagentsOpenTabIsRenamed() { + let titles = SubagentTabTitles() + // No name sent (an older page, a workflow agent): the tab keeps the chat's name. + titles.bind("agent-x", of: "s", toTab: "tab-x") + #expect(titles.title(ofTab: "tab-x") == nil) + titles.name("agent-y", of: "s", title: " ") + titles.bind("agent-y", of: "s", toTab: "tab-y") + #expect(titles.title(ofTab: "tab-y") == nil, "a blank name renamed the tab") + // Another session's subagent of the same id is another subagent. + titles.name("agent-z", of: "s1", title: "One") + titles.bind("agent-z", of: "s2", toTab: "tab-z") + #expect(titles.title(ofTab: "tab-z") == nil) + // A very long name is cut, and closed tabs' names go. + titles.name("agent-w", of: "s", title: String(repeating: "a", count: 500)) + titles.bind("agent-w", of: "s", toTab: "tab-w") + #expect(titles.title(ofTab: "tab-w")?.count == SubagentTabTitles.limit) + titles.name("agent-v", of: "s", title: "Later") + titles.bind("agent-v", of: "s", toTab: "tab-v") { $0 != "tab-w" } + #expect(titles.title(ofTab: "tab-w") == nil) + #expect(titles.title(ofTab: "tab-v") == "Later") + } + + @Test func thePagesRequestCarriesTheName() throws { + let request = AgentPaneRequest(body: ["method": "chat.subagentPane", "params": ["subagent": "agent-1", "focus": false, "title": "Audit the stack"]]) + guard case .subagentPane(let agent, let focus, let title) = request else { Issue.record("not decoded: \(request)"); return } + #expect(agent == "agent-1" && !focus && title == "Audit the stack") + let old = AgentPaneRequest(body: ["method": "chat.subagentPane", "params": ["subagent": "agent-1"]]) + guard case .subagentPane(_, _, let none) = old else { Issue.record("not decoded: \(old)"); return } + #expect(none == nil, "a page that sends no name still opens the pane") + } +} diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 1d1c7d1448c6..ad6802a79d29 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -5408,6 +5408,9 @@ function AcpmuxPane() { () => [...subagentsIn(snapshot.rows, snapshot.subagentViews).values()], [snapshot.rows, snapshot.subagentViews], ); + // The latest subagents, for callbacks that keep one identity (showSubagent names a pane's tab from it). + const freshAgentsRef = useRef(freshAgents); + freshAgentsRef.current = freshAgents; const workflowAdapter = useMemo(createWorkflowAdapter, []); const workflows = useMemo( () => (workflowLabsEnabled && shownSessionId ? workflowAdapter(freshAgents, shownSessionId) : []), @@ -6635,11 +6638,13 @@ function AcpmuxPane() { /// this pane when this is a subagent's pane or the host opens none. const showSubagent = useCallback((id: string, focus = true) => { if (subagentPane.current) return setSubagentView(id); + // Its tab takes the subagent's own name (its task; the Agent tool's description), not the chat's. + const title = freshAgentsRef.current.find((agent) => agent.id === id)?.name; // The pane the app opens is a new tab on this chat's own session (subagentPanes.ts). Hand the // chat over first, as openOwnTab does before `chat.openTab`, so that tab draws it from the // host's kept tail (tailCache.ts) before its own attach answers, instead of opening blank. directClient.current?.handOverCurrentTail(); - void openSubagentPane(callNative, id, focus).then((opened) => { + void openSubagentPane(callNative, id, focus, title).then((opened) => { if (!opened && focus) setSubagentView(id); }); }, []); diff --git a/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts b/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts index 98000096d245..6babadbbe557 100644 --- a/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts +++ b/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts @@ -58,10 +58,12 @@ export function useAutoOpenSubagents(): [boolean, (on: boolean) => void] { type Call = (method: string, params: Record) => Promise; -/// Asks the app for subagent `id`'s pane; false when it opened none. -export async function openSubagentPane(call: Call, id: string, focus: boolean): Promise { +/// Asks the app for subagent `id`'s pane; false when it opened none. `title` is the subagent's own +/// name, which the pane's tab shows instead of the chat's (Leo 2026-10-30: "subagents shouldn't be +/// tab-named the exact same as the parent"). +export async function openSubagentPane(call: Call, id: string, focus: boolean, title?: string): Promise { try { - const reply = await call("chat.subagentPane", { subagent: id, focus }); + const reply = await call("chat.subagentPane", title ? { subagent: id, focus, title } : { subagent: id, focus }); return (reply as { opened?: unknown } | null)?.opened === true; } catch { return false; diff --git a/webviews/test/subagent-pane-tab-title.test.ts b/webviews/test/subagent-pane-tab-title.test.ts new file mode 100644 index 000000000000..a76a468a9443 --- /dev/null +++ b/webviews/test/subagent-pane-tab-title.test.ts @@ -0,0 +1,19 @@ +// Leo 2026-10-30: "subagents shouldn't be tab-named the exact same as the parent". A subagent's pane +// is a tab on its chat's own session, so the app named it like the chat; the page now sends the +// subagent's own name with `chat.subagentPane`, and the app gives it to the pane's tab. +import { expect, test } from "bun:test"; +import { openSubagentPane } from "../src/agent-session/acpmux/subagents/subagentPanes"; + +test("the pane request carries the subagent's name, and leaves it out when there is none", async () => { + const calls: Array<{ method: string; params: Record }> = []; + const call = async (method: string, params: Record) => { + calls.push({ method, params }); + return { opened: true }; + }; + expect(await openSubagentPane(call, "agent-1", false, "Review the tray motion diff")).toBe(true); + expect(await openSubagentPane(call, "agent-2", true)).toBe(true); + expect(calls).toEqual([ + { method: "chat.subagentPane", params: { subagent: "agent-1", focus: false, title: "Review the tray motion diff" } }, + { method: "chat.subagentPane", params: { subagent: "agent-2", focus: true } }, + ]); +}); From 995299ff1a969559283b109fe186b872b47d1273 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 19:46:59 +0000 Subject: [PATCH 2/7] test(next): format the subagent pane title test Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- webviews/test/subagent-pane-tab-title.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webviews/test/subagent-pane-tab-title.test.ts b/webviews/test/subagent-pane-tab-title.test.ts index a76a468a9443..ec03b136e7d1 100644 --- a/webviews/test/subagent-pane-tab-title.test.ts +++ b/webviews/test/subagent-pane-tab-title.test.ts @@ -13,7 +13,10 @@ test("the pane request carries the subagent's name, and leaves it out when there expect(await openSubagentPane(call, "agent-1", false, "Review the tray motion diff")).toBe(true); expect(await openSubagentPane(call, "agent-2", true)).toBe(true); expect(calls).toEqual([ - { method: "chat.subagentPane", params: { subagent: "agent-1", focus: false, title: "Review the tray motion diff" } }, + { + method: "chat.subagentPane", + params: { subagent: "agent-1", focus: false, title: "Review the tray motion diff" }, + }, { method: "chat.subagentPane", params: { subagent: "agent-2", focus: true } }, ]); }); From 3d2e9b33eab3aa9fd7ac89281c893eac1bd06072 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:00:16 +0000 Subject: [PATCH 3/7] fix(next): a subagent tab's name follows what its pane shows; "Subagent of" the chat when it has no name - SubagentTabTitles.swift: `public import Observation`. A public @Observable class needs it under InternalImportsByDefault (the fleet compile of the stills build failed there). - Review of #28497 at 995299ff (router-review, P2): a subagent pane shows the chat's other subagents in place, and its "Subagent of" pill goes back to the chat, but the tab kept the name it got at open. The page now sends chat.subagentShown {subagent?, title?} whenever the pane it opened on a subagent shows another one, or the chat again (only after its first subagent has shown, so the open-time name never flashes away). The app sets or clears the tab's name (header hook showsSubagent -> SubagentTabTitles.show). - Leo, 2026-10-30: "the sidebar tab shouldn't just be named claude sr2 coord if you can just say it's SUbagent of sr2 coord". A subagent with no name of its own (only its id) is named "Subagent of ", from the page's already-translated subagents.of label (subagentTabTitle). The tab strip and the sidebar's tab rows both read it. Tests: SubagentTabTitleTests (the name follows what the pane shows; the request decodes) and webviews/test/subagent-pane-tab-title.test.ts (own name, else "Subagent of" the chat). Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- .../AgentPaneModel+Header.swift | 12 ++++++++ .../AgentPaneRequest+Decoding.swift | 5 ++++ .../AgentPaneRequest+SafeDescription.swift | 1 + .../CmuxNextAgentPane/AgentPaneRequest.swift | 3 ++ .../CmuxNextAgentPane/SubagentTabTitles.swift | 15 ++++++++-- .../CmuxNextApp/AgentTabs+Header.swift | 2 +- .../SubagentTabTitleTests.swift | 26 +++++++++++++++++ webviews/src/agent-session/acpmux/App.tsx | 28 +++++++++++++++---- .../acpmux/subagents/subagentPanes.ts | 9 ++++++ webviews/test/subagent-pane-tab-title.test.ts | 13 ++++++++- 10 files changed, 104 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index aad03c447a33..492afbf865cf 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift @@ -46,6 +46,9 @@ public struct AgentPaneHeaderHooks { /// Shows subagent `agent` of session `session` in an agent pane right of the chat /// (``AgentSubagentPanes``), focused with `focus`; false when no pane opened. public var openSubagent: @MainActor (_ session: String, _ agent: String, _ focus: Bool) -> Bool + /// This chat's tab, a pane opened on a subagent, now shows a subagent named `title`, or (nil) its + /// chat again: the tab's name follows (``SubagentTabTitles/show(_:inTab:)``). + public var showsSubagent: @MainActor (_ title: String?) -> Void /// Opens `viewer` (a background process's read-only terminal) in a tab beside the chat; false /// when no tab opened. public var openProcess: @MainActor (_ viewer: AgentPaneProcessViewer) -> Bool @@ -90,6 +93,7 @@ public struct AgentPaneHeaderHooks { openRight: @escaping @MainActor (String) -> Bool = { _ in false }, openTab: @escaping @MainActor (String, AgentForkDestination) -> Bool = { _, _ in false }, openSubagent: @escaping @MainActor (String, String, Bool) -> Bool = { _, _, _ in false }, + showsSubagent: @escaping @MainActor (String?) -> Void = { _ in }, openProcess: @escaping @MainActor (AgentPaneProcessViewer) -> Bool = { _ in false }, newChat: @escaping @MainActor (String) -> Bool = { _ in false }, sideTabs: @escaping @MainActor () -> Bool? = { nil }, @@ -109,6 +113,7 @@ public struct AgentPaneHeaderHooks { self.openRight = openRight self.openTab = openTab self.openSubagent = openSubagent + self.showsSubagent = showsSubagent self.openProcess = openProcess self.newChat = newChat self.sideTabs = sideTabs @@ -231,6 +236,13 @@ extension AgentPaneModel { // Its tab is named after the subagent, not the chat (SubagentTabTitles). SubagentTabTitles.shared.name(agent, of: sessionId, title: title) return AgentPaneReply.success(["opened": header.openSubagent(sessionId, agent, focus)]) + case .subagentShown(let agent, let title): + // A subagent pane's own page: its tab's name follows what it shows. Not from a New Tab page. + guard newTab == nil, let header else { + return AgentPaneReply.failure(code: "unsupported", message: "Unsupported agent pane request: chat.subagentShown") + } + header.showsSubagent(agent == nil ? nil : title) + return AgentPaneReply.success() case .processTerminal(let call): return AgentPaneProcessViewer.open(call, in: self) case .chatTab(let chat, let destination): diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 2116d9b28177..edfd3afda3cc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -383,6 +383,11 @@ extension AgentPaneRequest { // `title`: the subagent's own name, which its pane's tab shows (SubagentTabTitles). self = .subagentPane(id, focus: params?["focus"] as? Bool == true, title: params?["title"] as? String) } else { self = .unsupported(method) } + case "chat.subagentShown": + let id = params?["subagent"] as? String + if let id, id.isEmpty || id.count > 256 { self = .unsupported(method) } else { + self = .subagentShown(id, title: id == nil ? nil : params?["title"] as? String) + } case "chat.processTerminal": if let id = params?["toolCallId"] as? String, AgentPaneProcessViewer.isToken(id) { self = .processTerminal(toolCall: id) } else { self = .unsupported(method) } case "chat.newHere": diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift index 7219b7604cca..d4c5caa7a909 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+SafeDescription.swift @@ -78,6 +78,7 @@ extension AgentPaneRequest: CustomStringConvertible, CustomDebugStringConvertibl case .asksOpen: "asksOpen" case .asksAnswer: "asksAnswer" case .subagentPane: "subagentPane" + case .subagentShown: "subagentShown" case .processTerminal: "processTerminal" case .newChatHere: "newChatHere" case .checkpointAvailability: "checkpointAvailability" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift index 849840435a76..533c366dc87d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -202,6 +202,9 @@ public nonisolated enum AgentPaneRequest: Equatable, Sendable { /// `chat.subagentPane {subagent, focus}`: this chat's subagent in an agent pane of its own, /// right of the chat (``AgentSubagentPanes``); `focus` moves keyboard focus to it. case subagentPane(String, focus: Bool, title: String? = nil) + /// `chat.subagentShown {subagent?, title?}`: a pane opened on a subagent now shows that subagent + /// (named `title`), or its chat again (no subagent): its tab's name follows (``SubagentTabTitles``). + case subagentShown(String?, title: String?) /// `chat.processTerminal {toolCallId}`: the background process this chat's tool call started, /// in a read-only terminal tab beside the chat (``AgentPaneProcessViewer``). case processTerminal(toolCall: String) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift index e7e648e628c4..df30624a9efb 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift @@ -1,12 +1,13 @@ import Foundation -import Observation +public import Observation /// The names subagent panes' tabs show. A subagent's pane is a tab on its chat's own acpmux session /// (``AgentSubagentPanes``), so by session it would be named like the chat. Leo 2026-10-30: /// "subagents shouldn't be tab-named the exact same as the parent". The page sends the subagent's /// own name with `chat.subagentPane` (its task, for the Agent tool the call's description); the app -/// gives it to the tab it opens (``name(_:of:title:)``, then ``bind(_:of:toTab:)``), and the tab title -/// rule reads it (``title(ofTab:)``) after any name the person gave the tab. +/// gives it to the tab it opens (``name(_:of:title:)``, then ``bind(_:of:toTab:)``), the page keeps it +/// in step with the subagent the pane shows (`chat.subagentShown`, ``show(_:inTab:)``), and the tab +/// title rule reads it (``title(ofTab:)``) after any name the person gave the tab. @MainActor @Observable public final class SubagentTabTitles { public static let shared = SubagentTabTitles() @@ -37,6 +38,14 @@ public final class SubagentTabTitles { tabs[tab] = title } + /// Tab `tab` now shows a subagent named `title`, or (nil or blank) its chat again: a subagent pane + /// shows the chat's other subagents in place, and its "Subagent of" pill goes back to the chat. + /// Review of #28497 at 995299ff: the name bound at open stayed on the tab through those. + public func show(_ title: String?, inTab tab: String) { + let trimmed = (title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + tabs[tab] = trimmed.isEmpty ? nil : String(trimmed.prefix(Self.limit)) + } + /// The name tab `tab` shows as a subagent's pane, nil for any other tab. public func title(ofTab tab: String) -> String? { tabs[tab] diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift index e0284d136ff2..553e5b762ef4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/AgentTabs+Header.swift @@ -37,7 +37,7 @@ extension AgentTabStore { guard let self else { return }; AgentSidePanes.chat(chat).open(fork, chat: resolve(provisional), pane: { [weak self] in self?.locate(self?.resolve($0) ?? $0).map { "\(ObjectIdentifier($0.daemon).hashValue):\($0.pane.rawValue)" } }) { [weak self] anchor, created, finished in self?.openBeside(anchor ?? provisional, split: anchor == nil, main: provisional, open: { [weak self] pane, daemon in guard let self else { throw CancellationError() }; return try open(in: pane, of: daemon, session: fork) }, created: created, finished: { _ in finished() }) ?? false } }, openRight: { [weak self] session in guard let self else { return false }; return openRight?(resolve(provisional), session) ?? false - }, openTab: { [weak self] session, destination in self?.openFork(session, at: destination, beside: provisional) ?? false }, openSubagent: { [weak self] in self?.openSubagent($1, of: $0, focus: $2, beside: provisional) ?? false }, openProcess: { [weak self] viewer in guard let self else { return false }; return openProcess?(resolve(provisional), viewer) ?? false }, newChat: { [weak self] in self?.openNewTabColumn(beside: provisional, cwd: $0, split: false) ?? false }, sideTabs: { [weak self] in + }, openTab: { [weak self] session, destination in self?.openFork(session, at: destination, beside: provisional) ?? false }, openSubagent: { [weak self] in self?.openSubagent($1, of: $0, focus: $2, beside: provisional) ?? false }, showsSubagent: { [weak self] title in guard let self else { return }; SubagentTabTitles.shared.show(title, inTab: resolve(provisional)) }, openProcess: { [weak self] viewer in guard let self else { return false }; return openProcess?(resolve(provisional), viewer) ?? false }, newChat: { [weak self] in self?.openNewTabColumn(beside: provisional, cwd: $0, split: false) ?? false }, sideTabs: { [weak self] in guard let self else { return nil } let chat = resolve(provisional) return toggles.sideTabs(chat: chat, store: lookup(chat)?.store, actions: actions) { [weak self] in diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift index 86d2d196bc5c..ea7295647da8 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift @@ -59,6 +59,32 @@ struct SubagentTabTitleTests { #expect(titles.title(ofTab: "tab-v") == "Later") } + /// Review of #28497 at 995299ff: a subagent pane shows the chat's other subagents in place, and its + /// "Subagent of" pill goes back to the chat; the name bound at open stayed on the tab through both. + @Test func theTabsNameFollowsWhatThePaneShows() { + let titles = SubagentTabTitles() + titles.name("reviewer", of: "s", title: "Reviewer") + titles.bind("reviewer", of: "s", toTab: "tab") + #expect(titles.title(ofTab: "tab") == "Reviewer") + titles.show("Auditor", inTab: "tab") + #expect(titles.title(ofTab: "tab") == "Auditor", "the pane showed another subagent under the first one's name") + titles.show(nil, inTab: "tab") + #expect(titles.title(ofTab: "tab") == nil, "back on the chat, the tab kept a subagent's name") + titles.show(" ", inTab: "tab") + #expect(titles.title(ofTab: "tab") == nil) + } + + @Test func thePageReportsWhatThePaneShows() { + let shown = AgentPaneRequest(body: ["method": "chat.subagentShown", "params": ["subagent": "a2", "title": "Auditor"]]) + guard case .subagentShown(let agent, let title) = shown else { Issue.record("not decoded: \(shown)"); return } + #expect(agent == "a2" && title == "Auditor") + let chat = AgentPaneRequest(body: ["method": "chat.subagentShown", "params": [String: Any]()]) + guard case .subagentShown(let none, let noTitle) = chat else { Issue.record("not decoded: \(chat)"); return } + #expect(none == nil && noTitle == nil, "back on the chat: no subagent, no name") + let bad = AgentPaneRequest(body: ["method": "chat.subagentShown", "params": ["subagent": ""]]) + if case .subagentShown = bad { Issue.record("an empty subagent id was taken") } + } + @Test func thePagesRequestCarriesTheName() throws { let request = AgentPaneRequest(body: ["method": "chat.subagentPane", "params": ["subagent": "agent-1", "focus": false, "title": "Audit the stack"]]) guard case .subagentPane(let agent, let focus, let title) = request else { Issue.record("not decoded: \(request)"); return } diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index ad6802a79d29..ff94a867db4a 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -242,6 +242,7 @@ import { WorkflowSideTab } from "./summary/WorkflowSideTab"; import { openSubagentPane, revealedSubagent, + subagentTabTitle, useAutoOpenSubagents, useOpenNewSubagents, } from "./subagents/subagentPanes"; @@ -5408,9 +5409,12 @@ function AcpmuxPane() { () => [...subagentsIn(snapshot.rows, snapshot.subagentViews).values()], [snapshot.rows, snapshot.subagentViews], ); - // The latest subagents, for callbacks that keep one identity (showSubagent names a pane's tab from it). - const freshAgentsRef = useRef(freshAgents); - freshAgentsRef.current = freshAgents; + /// The name a subagent's pane tab shows (subagentPanes.ts subagentTabTitle): its own, else "Subagent of" the chat. + const subagentTitle = (id: string) => + subagentTabTitle(freshAgents.find((agent) => agent.id === id), `${t("subagents.of")} ${chatName}`); + // For callbacks that keep one identity (showSubagent names a pane's tab with it). + const subagentTitleRef = useRef(subagentTitle); + subagentTitleRef.current = subagentTitle; const workflowAdapter = useMemo(createWorkflowAdapter, []); const workflows = useMemo( () => (workflowLabsEnabled && shownSessionId ? workflowAdapter(freshAgents, shownSessionId) : []), @@ -6638,8 +6642,8 @@ function AcpmuxPane() { /// this pane when this is a subagent's pane or the host opens none. const showSubagent = useCallback((id: string, focus = true) => { if (subagentPane.current) return setSubagentView(id); - // Its tab takes the subagent's own name (its task; the Agent tool's description), not the chat's. - const title = freshAgentsRef.current.find((agent) => agent.id === id)?.name; + // Its tab takes the subagent's own name (its task; the Agent tool's description), never the chat's bare name. + const title = subagentTitleRef.current(id); // The pane the app opens is a new tab on this chat's own session (subagentPanes.ts). Hand the // chat over first, as openOwnTab does before `chat.openTab`, so that tab draws it from the // host's kept tail (tailCache.ts) before its own attach answers, instead of opening blank. @@ -6648,6 +6652,20 @@ function AcpmuxPane() { if (!opened && focus) setSubagentView(id); }); }, []); + // A pane opened on a subagent shows the chat's other subagents in place, and its "Subagent of" pill goes + // back to the chat: its tab's name follows what it shows (review of #28497 at 995299ff). Not before + // the subagent it was opened on has shown, or the name the app gave the tab would flash away. + const shownSubagentTitle = ownSubagentPane && subagentView ? subagentTitle(subagentView) : undefined; + const subagentShownOnce = useRef(false); + useEffect(() => { + if (!ownSubagentPane) return; + if (subagentView) subagentShownOnce.current = true; + else if (!subagentShownOnce.current) return; + void callNative( + "chat.subagentShown", + subagentView ? { subagent: subagentView, title: shownSubagentTitle } : {}, + ).catch(() => undefined); + }, [ownSubagentPane, subagentView, shownSubagentTitle]); const showWorkflowAgent = useCallback( (agent: WorkflowAgent) => { const workflow = workflows.find((entry) => entry.agents.some((candidate) => candidate.id === agent.id)); diff --git a/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts b/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts index 6babadbbe557..93561d30bd1c 100644 --- a/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts +++ b/webviews/src/agent-session/acpmux/subagents/subagentPanes.ts @@ -11,6 +11,7 @@ // showed it open none: they are history. import { useEffect, useRef, useSyncExternalStore } from "react"; import type { AcpmuxRow } from "../model"; +import type { Subagent } from "./subagentFold"; import { subagentsIn } from "./subagentRows"; /// The `revealTurn` prefix that opens a pane on a subagent's view. @@ -58,6 +59,14 @@ export function useAutoOpenSubagents(): [boolean, (on: boolean) => void] { type Call = (method: string, params: Record) => Promise; +/// The name subagent `agent`'s pane tab shows: its own (its task; the Agent tool's description), else +/// `fallback` ("Subagent of" the chat). Leo 2026-10-30: "the sidebar tab shouldn't just be named claude +/// sr2 coord if you can just say it's SUbagent of sr2 coord". +export function subagentTabTitle(agent: Pick | undefined, fallback: string): string { + const own = agent?.name?.trim(); + return own && own !== agent?.id ? own : fallback; +} + /// Asks the app for subagent `id`'s pane; false when it opened none. `title` is the subagent's own /// name, which the pane's tab shows instead of the chat's (Leo 2026-10-30: "subagents shouldn't be /// tab-named the exact same as the parent"). diff --git a/webviews/test/subagent-pane-tab-title.test.ts b/webviews/test/subagent-pane-tab-title.test.ts index ec03b136e7d1..2f77ff7a975e 100644 --- a/webviews/test/subagent-pane-tab-title.test.ts +++ b/webviews/test/subagent-pane-tab-title.test.ts @@ -2,7 +2,7 @@ // is a tab on its chat's own session, so the app named it like the chat; the page now sends the // subagent's own name with `chat.subagentPane`, and the app gives it to the pane's tab. import { expect, test } from "bun:test"; -import { openSubagentPane } from "../src/agent-session/acpmux/subagents/subagentPanes"; +import { openSubagentPane, subagentTabTitle } from "../src/agent-session/acpmux/subagents/subagentPanes"; test("the pane request carries the subagent's name, and leaves it out when there is none", async () => { const calls: Array<{ method: string; params: Record }> = []; @@ -20,3 +20,14 @@ test("the pane request carries the subagent's name, and leaves it out when there { method: "chat.subagentPane", params: { subagent: "agent-2", focus: true } }, ]); }); + +// Leo 2026-10-30: "the sidebar tab shouldn't just be named claude sr2 coord if you can just say it's +// SUbagent of sr2 coord". A subagent with no name of its own (its name is only its id) is named after +// what it is, relative to the chat. +test("a subagent's tab takes its own name, else says it is the chat's subagent", () => { + const of = "Subagent of claude-sr2-coord"; + expect(subagentTabTitle({ id: "a1", name: "Review the tray motion diff" }, of)).toBe("Review the tray motion diff"); + expect(subagentTabTitle({ id: "a1", name: "a1" }, of)).toBe(of); + expect(subagentTabTitle({ id: "a1", name: " " }, of)).toBe(of); + expect(subagentTabTitle(undefined, of)).toBe(of); +}); From edf65582ff91730b65b7ee917b5fcbc0820c4165 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:02:08 +0000 Subject: [PATCH 4/7] fix(next): a subagent pane reports what it shows only once it has that subagent's rows A pane's page loads its subagent's rows after it opens (and a showcase fixture's rows live only in the chat's page): until then it would report the "Subagent of" fallback over the name the chat's page gave the tab. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- webviews/src/agent-session/acpmux/App.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index ff94a867db4a..9096ea011d0e 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -6654,13 +6654,18 @@ function AcpmuxPane() { }, []); // A pane opened on a subagent shows the chat's other subagents in place, and its "Subagent of" pill goes // back to the chat: its tab's name follows what it shows (review of #28497 at 995299ff). Not before - // the subagent it was opened on has shown, or the name the app gave the tab would flash away. - const shownSubagentTitle = ownSubagentPane && subagentView ? subagentTitle(subagentView) : undefined; + // the subagent it was opened on has shown, or the name the app gave the tab would flash away; and not + // while this page has no rows for the subagent yet (they load after the pane opens), which would name + // it "Subagent of" over the name the chat's page gave it. + const shownSubagentKnown = !!subagentView && freshAgents.some((agent) => agent.id === subagentView); + const shownSubagentTitle = ownSubagentPane && subagentView && shownSubagentKnown ? subagentTitle(subagentView) : undefined; const subagentShownOnce = useRef(false); useEffect(() => { if (!ownSubagentPane) return; - if (subagentView) subagentShownOnce.current = true; - else if (!subagentShownOnce.current) return; + if (subagentView) { + subagentShownOnce.current = true; + if (!shownSubagentTitle) return; + } else if (!subagentShownOnce.current) return; void callNative( "chat.subagentShown", subagentView ? { subagent: subagentView, title: shownSubagentTitle } : {}, From c4a360b7b8ad336465e1750e1b7b7e2a20d06ec1 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:07:59 +0000 Subject: [PATCH 5/7] style(next): format the subagent tab title lines Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- webviews/src/agent-session/acpmux/App.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 9096ea011d0e..139bfc57517c 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -5411,7 +5411,10 @@ function AcpmuxPane() { ); /// The name a subagent's pane tab shows (subagentPanes.ts subagentTabTitle): its own, else "Subagent of" the chat. const subagentTitle = (id: string) => - subagentTabTitle(freshAgents.find((agent) => agent.id === id), `${t("subagents.of")} ${chatName}`); + subagentTabTitle( + freshAgents.find((agent) => agent.id === id), + `${t("subagents.of")} ${chatName}`, + ); // For callbacks that keep one identity (showSubagent names a pane's tab with it). const subagentTitleRef = useRef(subagentTitle); subagentTitleRef.current = subagentTitle; @@ -6658,7 +6661,8 @@ function AcpmuxPane() { // while this page has no rows for the subagent yet (they load after the pane opens), which would name // it "Subagent of" over the name the chat's page gave it. const shownSubagentKnown = !!subagentView && freshAgents.some((agent) => agent.id === subagentView); - const shownSubagentTitle = ownSubagentPane && subagentView && shownSubagentKnown ? subagentTitle(subagentView) : undefined; + const shownSubagentTitle = + ownSubagentPane && subagentView && shownSubagentKnown ? subagentTitle(subagentView) : undefined; const subagentShownOnce = useRef(false); useEffect(() => { if (!ownSubagentPane) return; From 9d12f819146015e96c350b7b15a394aa33bfc125 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:17:43 +0000 Subject: [PATCH 6/7] fix(next): route chat.subagentShown: the model's request switch and the page's method list Fleet build of the #28497 stills (ae1c050c) failed: AgentPaneModel.respond's switch over AgentPaneRequest was not exhaustive without .subagentShown. It joins the header requests (respondToHeader) and the passive ones that do not mark the pane as touched by the person. AgentPageProvider.methods also lists chat.subagentShown, or the page's report would never reach the pane. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- .../Sources/CmuxNextAgentPane/AgentPageProvider.swift | 2 +- .../CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPageProvider.swift index a9c3c12f3d42..fc5e0b7a3787 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", "readAloud.speak", "readAloud.stop", "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", "chief.face", "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.moveToMachine", "pane.tabState", "pane.tabRowTools", "pane.tabRowTools.set", "chat.toolCard.respond", "chat.computerUsePreview", "chat.upload", "chat.btw.inline", "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.moveToMachine", "pane.tabState", "pane.tabRowTools", "pane.tabRowTools.set", "chat.toolCard.respond", "chat.computerUsePreview", "chat.upload", "chat.btw.inline", "chat.sideChat", "chat.subagentPane", "chat.subagentShown", "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", "dictation.recording.upload", "dictation.recording.cancel", diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift index 7afbbc7903a6..1fc418abefa3 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel.swift @@ -306,7 +306,7 @@ public final class AgentPaneModel { case .ready, .reconnect, .uiPointerReport, .framePacing, .renderRate, .checkpointAvailability, .commandAvailability, .painted, .newTabInputReady, .newTabContext, .newTabWarm, .unsupported, .transportOpen, .transportSend, .transportClose, .transportGesture, .transportGestureRelease, .chatsPage, .quickFloatingState, .quickContext, .tabRowTools, .tabRowToolsSet, .projectIcon, .agentAvatar, .chiefFace, .harnessSymbol, .environment(.list), .cloudMachine(.list), .cloudMachine(.recoveryList), - .sandboxState: break + .sandboxState, .subagentShown: break case .reply(let reply) where reply.isPassive: break default: if !userTouched { touchedBy = String(String(describing: request).prefix { $0 != "(" }) } @@ -492,7 +492,7 @@ public final class AgentPaneModel { } catch { return AgentPaneReply.failure(code: "btw_failed", message: "Inline side question failed") } - case .paneAction, .moveToMachine, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .tabRowToolsSet, .sideChat, .subagentPane, .processTerminal, .chatTab, .offerOpen, .asksOpen, .asksAnswer, .newChatHere: return await respondToHeader(request) + case .paneAction, .moveToMachine, .diffTurn, .preview, .setQuickActions, .tabState, .tabRowTools, .tabRowToolsSet, .sideChat, .subagentPane, .subagentShown, .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) From dfa690a5ce55ca9822b5495942be0d0bbb5a441d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Fri, 30 Oct 2026 20:31:21 +0000 Subject: [PATCH 7/7] test(next): unwrap the subagent title test's session (AgentSessionRef.session is optional) Fleet compile-check of #28497 at 9d12f819: CmuxNextAppTests failed on passing ref.session (String?) where String is needed. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-0c8df2029ad2a028c4e7 --- .../Tests/CmuxNextAppTests/SubagentTabTitleTests.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift index ea7295647da8..dba676398a89 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift @@ -31,8 +31,9 @@ struct SubagentTabTitleTests { strip.subagentTitle = { titles.title(ofTab: $0.id) } #expect(strip.title(subagentTab) == strip.title(chatTab), "fixture: before, the subagent's tab reads like its chat") - titles.name("agent-1", of: ref.session, title: " Review the tray motion diff \n") - titles.bind("agent-1", of: ref.session, toTab: subagentTab.id) + let session = try #require(ref.session) + titles.name("agent-1", of: session, title: " Review the tray motion diff \n") + titles.bind("agent-1", of: session, toTab: subagentTab.id) #expect(strip.title(subagentTab) == "Review the tray motion diff") #expect(strip.title(chatTab) == "claude-sr2-coord", "the chat lost its own name") }