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+Header.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneModel+Header.swift index aea6ae7cfb78..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 @@ -222,13 +227,22 @@ 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 .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/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) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift index 3416e38570aa..edfd3afda3cc 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest+Decoding.swift @@ -380,8 +380,14 @@ 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.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 f9b7a67eab44..533c366dc87d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneRequest.swift @@ -201,7 +201,10 @@ 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.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 new file mode 100644 index 000000000000..df30624a9efb --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/SubagentTabTitles.swift @@ -0,0 +1,55 @@ +import Foundation +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:)``), 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() + + /// 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 + } + + /// 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] + } + + 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..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 @@ -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..dba676398a89 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubagentTabTitleTests.swift @@ -0,0 +1,97 @@ +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") + + 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") + } + + @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") + } + + /// 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 } + #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..139bfc57517c 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,6 +5409,15 @@ function AcpmuxPane() { () => [...subagentsIn(snapshot.rows, snapshot.subagentViews).values()], [snapshot.rows, snapshot.subagentViews], ); + /// 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) : []), @@ -6635,14 +6645,36 @@ 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), 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. directClient.current?.handOverCurrentTail(); - void openSubagentPane(callNative, id, focus).then((opened) => { + void openSubagentPane(callNative, id, focus, title).then((opened) => { 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; 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; + if (!shownSubagentTitle) return; + } 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 98000096d245..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,10 +59,20 @@ 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 { +/// 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"). +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..2f77ff7a975e --- /dev/null +++ b/webviews/test/subagent-pane-tab-title.test.ts @@ -0,0 +1,33 @@ +// 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, 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 }> = []; + 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 } }, + ]); +}); + +// 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); +});