diff --git a/CHANGELOG.md b/CHANGELOG.md index 54fa31ce9..14105a7c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index afc03648e..1d72476e8 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -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? { diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift index 635ad2763..ae2980808 100644 --- a/apps/macos/Sources/Clawdis/NodesMenu.swift +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -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) } } }