Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ extension AgentTimeline {
|| (started != nil && started != oldDetail?.turnStartedAtMs)
guard newTurn else { return [] }
let at = started ?? laneSince ?? nowMs
// acpmux's inbox notice is no prompt: the turn started working, and the History of
// what was asked leaves it out (Leo 2026-11-01: "the details aren't even very useful").
if newDetail?.promptIsNotice == true { return [AgentTimelineEvent(kind: .started, atMs: at, isNotice: true)] }
if let prompt = newDetail?.prompt { return [AgentTimelineEvent(kind: .prompt, atMs: at, text: prompt)] }
return [AgentTimelineEvent(kind: .started, atMs: at)]
case .needsInput?:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,30 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
public var laneSinceMs: UInt64?
/// The session's folder (`cwd`).
public var cwd: String?
/// The running turn's prompt is acpmux's notice of other agents' messages ("[inbox] New
/// messages from other agents…", `turn.notice`), not anyone's words: the hover card's
/// History leaves it out.
public var promptIsNotice = false
/// What the chat runs below its turn now, as acpmux counts it (`subagentsRunning`,
/// `subagentNames`, `commandsRunning`): its running subagents at any depth, the first of
/// their names, and its running background commands. 0 and empty from an older acpmux.
public var subagentsRunning = 0
public var subagentNames: [String] = []
public var commandsRunning = 0

public init(model: String? = nil, turnStartedAtMs: UInt64? = nil, prompt: String? = nil,
laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil) {
laneState: String? = nil, laneSinceMs: UInt64? = nil, cwd: String? = nil, promptIsNotice: Bool = false,
subagentsRunning: Int = 0, subagentNames: [String] = [], commandsRunning: Int = 0) {
self.model = model
self.turnStartedAtMs = turnStartedAtMs
self.prompt = prompt
self.laneState = laneState
self.laneSinceMs = laneSinceMs
self.cwd = cwd
self.promptIsNotice = promptIsNotice
self.subagentsRunning = subagentsRunning
self.subagentNames = subagentNames
self.commandsRunning = commandsRunning
}

/// A summary's detail; nil when it names none of them.
Expand All @@ -69,9 +84,14 @@ public nonisolated struct AgentChatDetail: Hashable, Sendable {
return text
}
let lane = summary["lane"] as? [String: Any]
let count = { (value: Any?) -> Int in max(0, (value as? NSNumber)?.intValue ?? 0) }
let detail = AgentChatDetail(model: text(summary["model"]), turnStartedAtMs: (turn?["startedAt"] as? NSNumber)?.uint64Value,
prompt: text(turn?["prompt"]), laneState: text(lane?["state"]),
laneSinceMs: (lane?["since"] as? NSNumber)?.uint64Value, cwd: text(summary["cwd"]))
laneSinceMs: (lane?["since"] as? NSNumber)?.uint64Value, cwd: text(summary["cwd"]),
promptIsNotice: turn?["notice"] as? Bool == true,
subagentsRunning: count(summary["subagentsRunning"]),
subagentNames: (summary["subagentNames"] as? [Any] ?? []).compactMap(text),
commandsRunning: count(summary["commandsRunning"]))
return detail == AgentChatDetail() ? nil : detail
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,22 @@ extension SidebarMapping {
detail.command = process(front)
detail.commandSince = Self.date(ms: since)
}
// What every agent chat in the workspace runs below its turn, as acpmux counts it.
for chat in tabs.compactMap({ $0.agentSession.flatMap(turns.detail(for:)) }) {
detail.subagentsRunning += chat.subagentsRunning
detail.subagentNames += chat.subagentNames

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The workspace-level subagentNames concatenates each chat's five-name list without a cap, so a workspace with several agent chats shows far more than five subagent lines in the hover card. Cap the combined list at five to match the per-chat limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextBridge/SidebarMapping+Detail.swift, line 34:

<comment>The workspace-level `subagentNames` concatenates each chat's five-name list without a cap, so a workspace with several agent chats shows far more than five subagent lines in the hover card. Cap the combined list at five to match the per-chat limit.</comment>

<file context>
@@ -28,9 +28,22 @@ extension SidebarMapping {
+        // What every agent chat in the workspace runs below its turn, as acpmux counts it.
+        for chat in tabs.compactMap({ $0.agentSession.flatMap(turns.detail(for:)) }) {
+            detail.subagentsRunning += chat.subagentsRunning
+            detail.subagentNames += chat.subagentNames
+            detail.commandsRunning += chat.commandsRunning
+        }
</file context>
Suggested change
detail.subagentNames += chat.subagentNames
detail.subagentNames = Array((detail.subagentNames + chat.subagentNames).prefix(5))

detail.commandsRunning += chat.commandsRunning
}
return detail
}

/// The running subagents and background commands of `tab`'s agent chat (acpmux's counts);
/// zero for any other tab.
static func running(_ tab: TabModel, turns: AgentTurnStateStore = .shared) -> (subagents: Int, commands: Int) {
guard let chat = tab.agentSession.flatMap(turns.detail(for:)) else { return (0, 0) }
return (chat.subagentsRunning, chat.commandsRunning)
}

/// When the row's state began: the earliest start among the tabs that report that state.
static func activitySince(tabs: [TabModel], indicator: StatusIndicatorState, turns: AgentTurnStateStore) -> Date? {
guard indicator.isVisible else { return nil }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,14 @@ public struct SidebarMapping {
let isNewTabPage = newTabPages.contains(tab.id) && !newTabTitle.isEmpty
let title = Self.shortenedPath(tabTitle?(tab) ?? (isNewTabPage ? newTabTitle : tab.displayTitle), of: tab, shorten: shorten)
// The hover card's tab rows show each tab's own folder and status; a New Tab page shows neither.
return SidebarTab(id: TabID(tab.id), title: title,
kind: Self.listedKind(tab, newTabPages: newTabPages), isUnread: tab.showsUnread,
brand: tab.agentBrand, favicon: browserFaces[tab.id]?.favicon,
folder: isNewTabPage ? nil : tab.cwd.flatMap { $0.isEmpty ? nil : shorten($0) },
state: isNewTabPage ? .idle : state)
var listed = SidebarTab(id: TabID(tab.id), title: title,
kind: Self.listedKind(tab, newTabPages: newTabPages), isUnread: tab.showsUnread,
brand: tab.agentBrand, favicon: browserFaces[tab.id]?.favicon,
folder: isNewTabPage ? nil : tab.cwd.flatMap { $0.isEmpty ? nil : shorten($0) },
state: isNewTabPage ? .idle : state)
// Its chat's running subagents and commands, which the tab row counts.
(listed.subagentsRunning, listed.commandsRunning) = Self.running(tab)
return listed
},
muted: muted,
// The store refuses every close of its home workspace (`home_not_closable`); a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public struct WorkspaceTimelineReader {
case .failed: .failed
}
return SidebarTimelineEntry(kind: kind, date: Date(timeIntervalSince1970: TimeInterval(event.atMs) / 1000),
text: event.text, agent: agent.map(displayName))
text: event.text, agent: agent.map(displayName), isNotice: event.isNotice)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The reader applies its event limit before the hover card removes isNotice, so inbox notices consume the read budget and can hide older real History entries. Filter notices while collecting or over-fetch until the requested number of non-notice entries is available.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At Packages/macOS/CmuxNext/Sources/CmuxNextBridge/WorkspaceTimelineReader.swift, line 42:

<comment>The reader applies its event limit before the hover card removes `isNotice`, so inbox notices consume the read budget and can hide older real History entries. Filter notices while collecting or over-fetch until the requested number of non-notice entries is available.</comment>

<file context>
@@ -39,7 +39,7 @@ public struct WorkspaceTimelineReader {
         }
         return SidebarTimelineEntry(kind: kind, date: Date(timeIntervalSince1970: TimeInterval(event.atMs) / 1000),
-                                    text: event.text, agent: agent.map(displayName))
+                                    text: event.text, agent: agent.map(displayName), isNotice: event.isNotice)
     }
 
</file context>

}

/// "Claude Code" for "claude"; the id itself for an agent cmux does not list.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,15 @@ public struct AgentTimelineEvent: Sendable, Hashable {
public var text: String?
/// The agent's name when known ("claude", "codex").
public var agent: String?
/// A turn acpmux's own notice started (other agents' messages arrived), not a prompt anyone
/// typed: its work counts, but a History of what was asked leaves it out.
public var isNotice = false

public init(kind: Kind, atMs: UInt64, text: String? = nil, agent: String? = nil) {
public init(kind: Kind, atMs: UInt64, text: String? = nil, agent: String? = nil, isNotice: Bool = false) {
self.kind = kind
self.atMs = atMs
self.text = text
self.agent = agent
self.isNotice = isNotice
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,11 @@ struct WorkspaceHoverCardContent: Equatable {
card.activitySince = nil
for index in card.facts.indices { card.facts[index].since = nil }
}
// The History lists what was asked and what happened, never acpmux's inbox notices
// ("[inbox] New messages from other agents…"); the stats still count their work.
let shown = timeline.filter { entry in
switch entry.kind {
if entry.isNotice { return false }
return switch entry.kind {
case .prompt, .started: options.showsPrompts
case .waiting, .finished, .failed: options.showsWaiting
}
Expand Down Expand Up @@ -217,6 +220,9 @@ struct WorkspaceHoverCardContent: Equatable {
facts.append(Fact(icon: .agentChat, text: [agent, detail.model].compactMap { $0 }.joined(separator: " · "), kind: "agent"))
}
if let task = detail.task { facts.append(Fact(icon: .textDescription, text: task, lines: 3, kind: "task")) }
// What its agents run below their turns now: how many subagents and commands, and which
// (Leo 2026-11-01: "being able to see the # of associated subagents and whatnot").
facts += WorkspaceHoverCardWork.facts(detail)
for presence in workspace.presence.prefix(2) {
facts.append(Fact(icon: toolIcon(presence.activity), text: Strings.presenceLine(presence), kind: "presence"))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import CmuxNextDesign
import CmuxNextIcons
import Foundation

/// What a workspace's agents run below their turns, as the hover card says it (Leo 2026-11-01:
/// "e.g. this hover preview being able to see the # of associated subagents and whatnot would be
/// a lot more useful / like the details aren't even very useful"). acpmux counts them
/// (`subagentsRunning`, `subagentNames`, `commandsRunning` on each chat's summary); the card only
/// words them. Pure.
enum WorkspaceHoverCardWork {
/// How the expanded card shows the running work. The gallery shows all three
/// (`WorkspaceHoverCardGallery`); `shipped` is the one the app draws.
enum Style: String, CaseIterable, Sendable {
/// A count line, then one line per running subagent, named (the shipped one).
case list
/// The count line alone.
case summary
/// One line: the count, then the names after it, wrapped to two lines.
case inline
}

/// The style the card draws: each subagent named on its own line, so the person sees what
/// runs, not only how much; a count alone says little more than the spinner.
static let shipped = Style.list

/// The fact kind of the count line and of a subagent's line.
static let countKind = "runningWork"
static let subagentKind = "runningSubagent"

/// "3 subagents · 1 command"; nil when nothing runs.
static func counts(subagents: Int, commands: Int) -> String? {
let parts = [subagents > 0 ? Strings.subagentsRunning(subagents) : nil,
commands > 0 ? Strings.commandsRunning(commands) : nil].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: WorkspaceRowContent.separator)
}

/// A tab row's second line: its chat's counts first (they never cut), then its folder.
static func tabCaption(_ tab: SidebarTab) -> String? {
let folder = tab.folder.flatMap { $0.isEmpty ? nil : $0 }
let parts = [counts(subagents: tab.subagentsRunning, commands: tab.commandsRunning), folder].compactMap { $0 }
return parts.isEmpty ? nil : parts.joined(separator: WorkspaceRowContent.separator)
}

/// The expanded card's lines for `detail`'s running work in `style`; none when nothing runs.
static func facts(_ detail: SidebarWorkspaceDetail, style: Style = shipped) -> [WorkspaceHoverCardContent.Fact] {
typealias Fact = WorkspaceHoverCardContent.Fact
guard let count = counts(subagents: detail.subagentsRunning, commands: detail.commandsRunning) else { return [] }
// Each name once, in acpmux's order (oldest first); a nameless subagent only counts.
var seen = Set<String>()
let names = detail.subagentNames.filter { !$0.isEmpty && seen.insert($0).inserted }
let head = Fact(icon: .appSampleRunningagents, text: count, kind: countKind)
switch style {
case .summary:
return [head]
case .inline:
guard !names.isEmpty else { return [head] }
return [Fact(icon: .appSampleRunningagents, text: count + ": " + names.joined(separator: ", "), lines: 2, kind: countKind)]
case .list:
// The list's own header names the kind, so each line is the name alone, by its icon.
return [head] + names.map { Fact(icon: .agentActivity, text: $0, kind: subagentKind) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public nonisolated struct SidebarTab: Identifiable, Hashable, Sendable {
public var state: StatusIndicatorState = .idle
/// The tab's folder, shortened as the row's is ("~", "~/src/cmux").
public var folder: String?
/// What the tab's agent chat runs below its turn now (acpmux's `subagentsRunning` and
/// `commandsRunning`): the hover card's tab row counts them (Leo 2026-11-01: "this hover
/// preview being able to see the # of associated subagents and whatnot").
public var subagentsRunning = 0
public var commandsRunning = 0

public init(id: TabID, title: String, kind: SidebarTabKind = .terminal, isUnread: Bool = false, brand: String? = nil,
favicon: SidebarFavicon? = nil, folder: String? = nil, state: StatusIndicatorState = .idle) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,16 @@ public nonisolated struct SidebarTimelineEntry: Hashable, Sendable {
public var text: String?
/// The agent's display name ("Claude Code", "Codex"); nil when unknown.
public var agent: String?
/// A turn acpmux's notice of other agents' messages started ("[inbox] New messages…"), not
/// the person: the card's stats count its work, its History lists it not.
public var isNotice: Bool

public init(kind: Kind, date: Date, text: String? = nil, agent: String? = nil) {
public init(kind: Kind, date: Date, text: String? = nil, agent: String? = nil, isNotice: Bool = false) {
self.kind = kind
self.date = date
self.text = text
self.agent = agent
self.isNotice = isNotice
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ public nonisolated struct SidebarWorkspaceDetail: Hashable, Sendable {
/// when it started (the daemon's OSC 133 fact).
public var command: String?
public var commandSince: Date?
/// What the workspace's agent chats run below their turns now (acpmux, summed over its
/// tabs): running subagents, the first of their names, and background commands.
public var subagentsRunning = 0
public var subagentNames: [String] = []
public var commandsRunning = 0

public init(activitySince: Date? = nil, model: String? = nil, task: String? = nil, needsInput: String? = nil,
command: String? = nil, commandSince: Date? = nil) {
Expand Down
Loading
Loading