Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 },
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "(" }) }
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)" }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) })) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading