diff --git a/Sources/CodexBar/StatusItemController+AgentSessions.swift b/Sources/CodexBar/StatusItemController+AgentSessions.swift index 37109c68d2..1971060b52 100644 --- a/Sources/CodexBar/StatusItemController+AgentSessions.swift +++ b/Sources/CodexBar/StatusItemController+AgentSessions.swift @@ -1,4 +1,49 @@ import AppKit +import CodexBarCore +import Foundation +import SwiftUI + +private struct AgentSessionMenuRowView: View { + let title: String + let width: CGFloat + + var body: some View { + Text(self.title) + .font(.system(size: NSFont.menuFont(ofSize: 0).pointSize)) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 20) + .padding(.trailing, 12) + .padding(.vertical, 4) + .frame(width: self.width, alignment: .leading) + } +} + +private enum AgentSessionMenuItemIdentifier { + private static let prefix = "agentSessionAction:" + private static let separator = "\u{1f}" + + static func make(sessionID: String, remoteHost: String?) -> NSUserInterfaceItemIdentifier { + let values = "\(remoteHost ?? "")\(self.separator)\(sessionID)" + let encoded = Data(values.utf8).base64EncodedString() + return NSUserInterfaceItemIdentifier(self.prefix + encoded) + } + + static func actionValues(from identifier: NSUserInterfaceItemIdentifier?) -> (String, String?)? { + guard let rawValue = identifier?.rawValue, + rawValue.hasPrefix(self.prefix) + else { return nil } + let encoded = rawValue.dropFirst(self.prefix.count) + guard let data = Data(base64Encoded: String(encoded)), + let values = String(data: data, encoding: .utf8) + else { return nil } + let parts = values.split(separator: Character(self.separator), maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, !parts[1].isEmpty else { return nil } + let remoteHost = parts[0].isEmpty ? nil : String(parts[0]) + return (String(parts[1]), remoteHost) + } +} extension StatusItemController { func wireAgentSessionUpdates() { @@ -44,16 +89,60 @@ extension StatusItemController { } @objc func focusAgentSession(_ sender: NSMenuItem) { - guard let values = sender.representedObject as? [String], - let sessionID = values.first + if let values = sender.representedObject as? [String], let sessionID = values.first { + let remoteHost = values.count > 1 && !values[1].isEmpty ? values[1] : nil + self.focusAgentSession(id: sessionID, remoteHost: remoteHost) + return + } + guard let (sessionID, remoteHost) = AgentSessionMenuItemIdentifier.actionValues(from: sender.identifier) else { return } - let remoteHost = values.count > 1 && !values[1].isEmpty ? values[1] : nil + self.focusAgentSession(id: sessionID, remoteHost: remoteHost) + } + + func makeAgentSessionMenuItem( + title: String, + session: AgentSession, + remoteHost: String?, + width: CGFloat) -> NSMenuItem + { + let action = MenuDescriptor.MenuAction.focusAgentSession(session, remoteHost: remoteHost) + let (selector, represented) = self.selector(for: action) + guard self.menuCardRenderingEnabledForController else { + let item = NSMenuItem(title: title, action: selector, keyEquivalent: "") + item.target = self + item.representedObject = represented + return item + } + + // Native menu item titles contribute their full natural width to the popup. Put the text + // in a fixed-width hosted row instead, so it truncates within the width chosen by the rest + // of the menu rather than expanding the popup for an unusually long project or session name. + let item = self.makeMenuCardItem( + AgentSessionMenuRowView(title: title, width: width), + id: "agentSession:\(remoteHost ?? "local"):\(session.id)", + width: width, + heightCacheScope: "agentSession", + heightCacheFingerprint: "singleLine", + onClick: { [weak self] in + self?.focusAgentSession(id: session.id, remoteHost: remoteHost) + }) + item.toolTip = title + // The hosted row handles pointer input. Preserve AppKit's keyboard activation path too. + item.target = self + item.action = selector + // Keep the card identifier in `representedObject` for view recycling and height caching. + // The native action gets its payload from the private identifier instead. + item.identifier = AgentSessionMenuItemIdentifier.make(sessionID: session.id, remoteHost: remoteHost) + return item + } + + private func focusAgentSession(id: String, remoteHost: String?) { let session = if let remoteHost { self.agentSessions.remoteHosts .first(where: { $0.host == remoteHost })? - .sessions.first(where: { $0.id == sessionID }) + .sessions.first(where: { $0.id == id }) } else { - self.agentSessions.localSessions.first(where: { $0.id == sessionID }) + self.agentSessions.localSessions.first(where: { $0.id == id }) } guard let session else { return } self.agentSessions.focus(session, remoteHost: remoteHost) diff --git a/Sources/CodexBar/StatusItemController+Menu.swift b/Sources/CodexBar/StatusItemController+Menu.swift index f23df67253..fc2df6a124 100644 --- a/Sources/CodexBar/StatusItemController+Menu.swift +++ b/Sources/CodexBar/StatusItemController+Menu.swift @@ -871,6 +871,14 @@ extension StatusItemController { continue } let localizedTitle = L(title) + if case let .focusAgentSession(session, remoteHost) = action { + menu.addItem(self.makeAgentSessionMenuItem( + title: localizedTitle, + session: session, + remoteHost: remoteHost, + width: width)) + continue + } let (selector, represented) = self.selector(for: action) let item = NSMenuItem(title: localizedTitle, action: selector, keyEquivalent: "") item.target = self @@ -943,47 +951,6 @@ extension StatusItemController { } } - private func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { - let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") - let view = self.makeWrappedSecondaryTextView(text: text) - let height = self.menuTextItemHeight(for: view, width: width) - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) - item.view = view - item.isEnabled = false - item.toolTip = text - return item - } - - private func makeWrappedSecondaryTextView(text: String) -> NSView { - let container = NSView() - container.translatesAutoresizingMaskIntoConstraints = false - - let textField = NSTextField(wrappingLabelWithString: text) - textField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) - textField.textColor = NSColor.secondaryLabelColor - textField.lineBreakMode = .byWordWrapping - textField.maximumNumberOfLines = 0 - textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - textField.translatesAutoresizingMaskIntoConstraints = false - - container.addSubview(textField) - // macos-smell:disable MACOS005 - NSLayoutConstraint.activate([ - textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), - textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), - textField.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), - textField.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), - ]) - - return container - } - - private func menuTextItemHeight(for view: NSView, width: CGFloat) -> CGFloat { - view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) - view.layoutSubtreeIfNeeded() - return max(1, ceil(view.fittingSize.height)) - } - func makeMenu(for provider: UsageProvider?) -> NSMenu { let menu = self.makeBaseMenu() if let provider { diff --git a/Sources/CodexBar/StatusItemController+MenuTextRows.swift b/Sources/CodexBar/StatusItemController+MenuTextRows.swift new file mode 100644 index 0000000000..789a26f697 --- /dev/null +++ b/Sources/CodexBar/StatusItemController+MenuTextRows.swift @@ -0,0 +1,44 @@ +import AppKit + +extension StatusItemController { + func makeWrappedSecondaryTextItem(text: String, width: CGFloat) -> NSMenuItem { + let item = NSMenuItem(title: "", action: nil, keyEquivalent: "") + let view = self.makeWrappedSecondaryTextView(text: text) + let height = self.menuTextItemHeight(for: view, width: width) + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: height)) + item.view = view + item.isEnabled = false + item.toolTip = text + return item + } + + private func makeWrappedSecondaryTextView(text: String) -> NSView { + let container = NSView() + container.translatesAutoresizingMaskIntoConstraints = false + + let textField = NSTextField(wrappingLabelWithString: text) + textField.font = NSFont.menuFont(ofSize: NSFont.smallSystemFontSize) + textField.textColor = NSColor.secondaryLabelColor + textField.lineBreakMode = .byWordWrapping + textField.maximumNumberOfLines = 0 + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.translatesAutoresizingMaskIntoConstraints = false + + container.addSubview(textField) + // macos-smell:disable MACOS005 + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18), + textField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -10), + textField.topAnchor.constraint(equalTo: container.topAnchor, constant: 2), + textField.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -2), + ]) + + return container + } + + private func menuTextItemHeight(for view: NSView, width: CGFloat) -> CGFloat { + view.frame = NSRect(origin: .zero, size: NSSize(width: width, height: 1)) + view.layoutSubtreeIfNeeded() + return max(1, ceil(view.fittingSize.height)) + } +} diff --git a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift index b9312b9662..e176096883 100644 --- a/Sources/CodexBar/StatusItemController+MenuWidthCache.swift +++ b/Sources/CodexBar/StatusItemController+MenuWidthCache.swift @@ -90,6 +90,10 @@ extension StatusItemController { switch entry { case let .text(text, style): "text:\(style):\(text)" + case let .action(_, .focusAgentSession(session, remoteHost)): + // Session rows are fixed-width hosted views. Their title can change every scan without + // affecting popup width, so avoid both measurement work and cache churn from its text. + "focusAgentSession:\(remoteHost ?? "local"):\(session.id)" case let .action(title, action): "action:\(title):\(self.measuredStandardMenuWidthCacheToken(for: action))" case let .unavailable(title, tooltip): diff --git a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift index 18f27e501d..7a7e3b6ecb 100644 --- a/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift +++ b/Tests/CodexBarTests/StatusMenuHeightCacheTests.swift @@ -89,6 +89,76 @@ extension StatusMenuTests { #expect(controller.measuredStandardMenuWidthCache == firstCache) } + @Test + func `agent session rows use the menu width instead of their natural title width`() { + let previousMenuCardRendering = StatusItemController.menuCardRenderingEnabled + StatusItemController.menuCardRenderingEnabled = true + defer { + StatusItemController.menuCardRenderingEnabled = previousMenuCardRendering + } + + let controller = self.makeHeightCacheController() + defer { controller.releaseStatusItemsForTesting() } + + let now = Date(timeIntervalSince1970: 1000) + let shortSession = AgentSession( + id: "session", + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/tmp/short", + projectName: "short", + startedAt: nil, + lastActivityAt: now, + transcriptPath: nil, + host: "local") + let longSession = AgentSession( + id: "session", + provider: .codex, + source: .cli, + state: .active, + pid: 42, + cwd: "/tmp/long", + projectName: String(repeating: "very-long-project-name-", count: 24), + startedAt: nil, + lastActivityAt: now, + transcriptPath: nil, + host: "local") + let shortSection = MenuDescriptor.agentSessionsSection( + localSessions: [shortSession], + remoteHosts: [], + now: now) + let longSection = MenuDescriptor.agentSessionsSection( + localSessions: [longSession], + remoteHosts: [], + now: now) + let baseWidth = StatusItemController.menuCardBaseWidth + + let shortWidth = controller.measuredStandardMenuWidth(for: [shortSection], baseWidth: baseWidth) + let longWidth = controller.measuredStandardMenuWidth(for: [longSection], baseWidth: baseWidth) + + #expect(longWidth == shortWidth) + #expect(controller.measuredStandardMenuWidthCache.count == 1) + + let menu = NSMenu() + controller.addActionableSections([longSection], to: menu, width: longWidth) + guard menu.items.indices.contains(1), + case let .action(title, .focusAgentSession) = longSection.entries[1], + let view = menu.items[1].view + else { + Issue.record("Expected a hosted Agent Session action row") + return + } + let row = menu.items[1] + + #expect(row.title.isEmpty) + #expect(row.toolTip == title) + #expect(row.representedObject as? String == "agentSession:local:session") + #expect(row.identifier != nil) + #expect(view.frame.width == longWidth) + } + @Test func `fingerprinted menu card height cache survives content version invalidation`() { let controller = self.makeHeightCacheController()