-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Fix agent session menu width #3096
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() { | ||
|
|
@@ -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) | ||
|
Comment on lines
+130
to
+135
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the merged-menu cached-switch path, hosted rows exchange their payloads through 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) | ||
|
|
||
| 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)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a remote session row is clicked with the pointer or activated through VoiceOver, this
onClickpath invokes the closure directly and therefore keeps the custom-view menu open; unlike the previous nativeNSMenuItemaction, 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 👍 / 👎.