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
99 changes: 94 additions & 5 deletions Sources/CodexBar/StatusItemController+AgentSessions.swift
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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)
})
Comment on lines +126 to +128

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 Badge Dismiss the menu before focusing a remote session

When a remote session row is clicked with the pointer or activated through VoiceOver, this onClick path invokes the closure directly and therefore keeps the custom-view menu open; unlike the previous native NSMenuItem action, the asynchronous remote focus operation never activates another local application to dismiss it. The CodexBar popup consequently remains open after the remote focus request, so cancel menu tracking before dispatching the focus action.

Useful? React with 👍 / 👎.

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)
Comment on lines +130 to +135

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 Badge Keep the keyboard payload synchronized during cached swaps

In the merged-menu cached-switch path, hosted rows exchange their payloads through swapMenuItemMetadataKeepingViews, but that helper does not exchange NSMenuItem.identifier. When provider/overview sections have different row counts, an agent-session row can reuse a shell from another hosted row (or an adjacent session), leaving this newly stored identifier absent or associated with the wrong session; pointer clicks use the updated closure, but Return-key activation calls focusAgentSession(_:) with the stale identifier and either does nothing or focuses the wrong session. The identifier must be transferred with the rest of the item metadata.

Useful? React with 👍 / 👎.

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)
Expand Down
49 changes: 8 additions & 41 deletions Sources/CodexBar/StatusItemController+Menu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuTextRows.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
4 changes: 4 additions & 0 deletions Sources/CodexBar/StatusItemController+MenuWidthCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
70 changes: 70 additions & 0 deletions Tests/CodexBarTests/StatusMenuHeightCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down