fix(mac): refine node submenu copy behavior

This commit is contained in:
Peter Steinberger
2025-12-26 20:05:23 +00:00
parent 4016bc2416
commit ab73c40bfe
3 changed files with 126 additions and 32 deletions

View File

@@ -58,6 +58,7 @@
### macOS app
- Update-ready state surfaced in the menu; menu sections regrouped with session submenus.
- Menu bar now shows a dedicated Nodes section under Context with inline rows, overflow submenu, and iconized actions.
- Nodes now expose consistent inline details with per-node submenus for quick copy of key fields.
- Session list polish: sleeping/disconnected/error states, usage bar restored, padding + bar sizing tuned, syncing menu removed, header hidden when disconnected.
- Chat UI polish: tool call cards + merged tool results, glass background, tighter composer spacing, visual effect host tweaks.
- OAuth storage moved; legacy session syncing metadata removed.

View File

@@ -226,6 +226,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
item.view = HighlightedMenuItemHostView(
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
width: width)
item.submenu = self.buildNodeSubmenu(entry: entry)
menu.insertItem(item, at: cursor)
cursor += 1
}
@@ -417,11 +418,61 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
item.view = HighlightedMenuItemHostView(
rootView: AnyView(NodeMenuRowView(entry: entry, width: width)),
width: width)
item.submenu = self.buildNodeSubmenu(entry: entry)
menu.addItem(item)
}
return menu
}
private func buildNodeSubmenu(entry: InstanceInfo) -> NSMenu {
let menu = NSMenu()
menu.addItem(self.makeNodeCopyItem(label: "ID", value: entry.id))
if let host = entry.host?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Host", value: host))
}
if let ip = entry.ip?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "IP", value: ip))
}
menu.addItem(self.makeNodeCopyItem(label: "Role", value: NodeMenuEntryFormatter.roleText(entry)))
if let platform = NodeMenuEntryFormatter.platformText(entry) {
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
}
if let version = entry.version?.nonEmpty {
menu.addItem(self.makeNodeCopyItem(label: "Version", value: "v\(version)"))
}
menu.addItem(self.makeNodeDetailItem(label: "Last seen", value: entry.ageDescription))
if entry.lastInputSeconds != nil {
menu.addItem(self.makeNodeDetailItem(label: "Last input", value: entry.lastInputDescription))
}
if let reason = entry.reason?.nonEmpty {
menu.addItem(self.makeNodeDetailItem(label: "Reason", value: reason))
}
return menu
}
private func makeNodeDetailItem(label: String, value: String) -> NSMenuItem {
let item = NSMenuItem(title: "\(label): \(value)", action: nil, keyEquivalent: "")
item.isEnabled = false
return item
}
private func makeNodeCopyItem(label: String, value: String) -> NSMenuItem {
let item = NSMenuItem(title: "\(label): \(value)", action: #selector(self.copyNodeValue(_:)), keyEquivalent: "")
item.target = self
item.representedObject = value
return item
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],
@@ -531,6 +582,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
NSPasteboard.general.setString(summary, forType: .string)
}
@objc
private func copyNodeValue(_ sender: NSMenuItem) {
guard let value = sender.representedObject as? String else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
// MARK: - Width + placement
private func findInsertIndex(in menu: NSMenu) -> Int? {

View File

@@ -23,39 +23,67 @@ struct NodeMenuEntryFormatter {
entry.text.nonEmpty ?? self.primaryName(entry)
}
static func roleText(_ entry: InstanceInfo) -> String {
if self.isGateway(entry) { return "gateway" }
if let mode = entry.mode?.nonEmpty { return mode }
return "node"
}
static func detailLeft(_ entry: InstanceInfo) -> String {
var modeLabel: String?
if self.isGateway(entry) {
modeLabel = "gateway"
} else if let mode = entry.mode?.nonEmpty {
modeLabel = mode
}
if let version = entry.version?.nonEmpty {
let base = modeLabel ?? "node"
modeLabel = "\(base) v\(version)"
}
if let modeLabel { return modeLabel }
if let text = entry.text.nonEmpty {
let trimmed = text
.replacingOccurrences(of: "Node: ", with: "")
.replacingOccurrences(of: "Gateway: ", with: "")
let candidates = trimmed
.components(separatedBy: " · ")
.filter { !$0.hasPrefix("mode ") && !$0.hasPrefix("reason ") }
if let first = candidates.first, !first.isEmpty { return first }
}
return entry.ageDescription
let role = self.roleText(entry)
if let ip = entry.ip?.nonEmpty { return "\(ip) · \(role)" }
return role
}
static func detailRight(_ entry: InstanceInfo) -> String? {
if let ip = entry.ip?.nonEmpty { return ip }
if let platform = entry.platform?.nonEmpty { return platform }
var parts: [String] = []
if let platform = self.platformText(entry) { parts.append(platform) }
if let version = entry.version?.nonEmpty { parts.append("v\(version)") }
if parts.isEmpty { return nil }
return parts.joined(separator: " · ")
}
static func platformText(_ entry: InstanceInfo) -> String? {
if let raw = entry.platform?.nonEmpty {
return self.prettyPlatform(raw) ?? raw
}
if let family = entry.deviceFamily?.lowercased() {
if family.contains("mac") { return "macOS" }
if family.contains("iphone") { return "iOS" }
if family.contains("ipad") { return "iPadOS" }
if family.contains("android") { return "Android" }
}
return nil
}
private static func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil }
let name: String = switch prefix {
case "macos": "macOS"
case "ios": "iOS"
case "ipados": "iPadOS"
case "tvos": "tvOS"
case "watchos": "watchOS"
default: prefix.prefix(1).uppercased() + prefix.dropFirst()
}
guard let version, !version.isEmpty else { return name }
let parts = version.split(separator: ".").map(String.init)
if parts.count >= 2 {
return "\(name) \(parts[0]).\(parts[1])"
}
return "\(name) \(version)"
}
private static func parsePlatform(_ raw: String) -> (prefix: String, version: String?) {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return ("", nil) }
let parts = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
let prefix = parts.first?.lowercased() ?? ""
let versionToken = parts.dropFirst().first
return (prefix, versionToken)
}
static func leadingSymbol(_ entry: InstanceInfo) -> String {
if self.isGateway(entry) { return self.safeSystemSymbol("dot.radiowaves.left.and.right", fallback: "network") }
if let family = entry.deviceFamily?.lowercased() {
@@ -109,7 +137,7 @@ struct NodeMenuRowView: View {
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(NodeMenuEntryFormatter.detailLeft(self.entry))
.font(.caption)
.foregroundStyle(self.secondaryColor)
@@ -118,12 +146,19 @@ struct NodeMenuRowView: View {
Spacer(minLength: 0)
if let right = NodeMenuEntryFormatter.detailRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
HStack(alignment: .firstTextBaseline, spacing: 6) {
if let right = NodeMenuEntryFormatter.detailRight(self.entry) {
Text(right)
.font(.caption.monospacedDigit())
.foregroundStyle(self.secondaryColor)
.lineLimit(1)
.truncationMode(.middle)
}
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryColor)
.lineLimit(1)
.truncationMode(.middle)
.padding(.leading, 2)
}
}
}