diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index 0f2235489..be14e4d18 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -44,6 +44,7 @@ final class InstancesStore { private var task: Task? private let interval: TimeInterval = 30 private var eventTask: Task? + private var startCount = 0 private var lastPresenceById: [String: InstanceInfo] = [:] private var lastLoginNotifiedAtMs: [String: Double] = [:] @@ -57,6 +58,8 @@ final class InstancesStore { func start() { guard !self.isPreview else { return } + self.startCount += 1 + guard self.startCount == 1 else { return } guard self.task == nil else { return } self.startGatewaySubscription() self.task = Task.detached { [weak self] in @@ -70,6 +73,10 @@ final class InstancesStore { } func stop() { + guard !self.isPreview else { return } + guard self.startCount > 0 else { return } + self.startCount -= 1 + guard self.startCount == 0 else { return } self.task?.cancel() self.task = nil self.eventTask?.cancel() diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 4b2aeb9e2..981431985 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -48,20 +48,8 @@ struct MenuContent: View { if self.showVoiceWakeMicPicker { self.voiceWakeMicMenu } - Divider() - Button("Open Chat") { - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.show(sessionKey: sessionKey) - } - } - Button("Open Dashboard") { - Task { @MainActor in - await self.openDashboard() - } - } Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) { - Text("Allow Canvas") + Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis") } .onChange(of: self.state.canvasEnabled) { _, enabled in if !enabled { @@ -69,16 +57,36 @@ struct MenuContent: View { } } if self.state.canvasEnabled { - Button(self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas") { + Button { if self.state.canvasPanelVisible { CanvasManager.shared.hideAll() } else { // Don't force a navigation on re-open: preserve the current web view state. _ = try? CanvasManager.shared.show(sessionKey: "main", path: nil) } + } label: { + Label( + self.state.canvasPanelVisible ? "Close Canvas" : "Open Canvas", + systemImage: "rectangle.inset.filled.on.rectangle") } } Divider() + Button { + Task { @MainActor in + let sessionKey = await WebChatManager.shared.preferredSessionKey() + WebChatManager.shared.show(sessionKey: sessionKey) + } + } label: { + Label("Open Chat", systemImage: "bubble.left.and.bubble.right") + } + Button { + Task { @MainActor in + await self.openDashboard() + } + } label: { + Label("Open Dashboard", systemImage: "gauge") + } + Divider() Toggle( isOn: Binding( get: { self.browserControlEnabled }, @@ -86,7 +94,7 @@ struct MenuContent: View { self.browserControlEnabled = enabled ClawdisConfigFile.setBrowserControlEnabled(enabled) })) { - Text("Browser Control") + Label("Browser Control", systemImage: "globe") } Divider() Button("Settings…") { self.open(tab: .general) } diff --git a/apps/macos/Sources/Clawdis/MenuHighlightedHostView.swift b/apps/macos/Sources/Clawdis/MenuHighlightedHostView.swift new file mode 100644 index 000000000..772facde4 --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuHighlightedHostView.swift @@ -0,0 +1,99 @@ +import AppKit +import SwiftUI + +final class HighlightedMenuItemHostView: NSView { + private var baseView: AnyView + private let hosting: NSHostingView + private var targetWidth: CGFloat + private var tracking: NSTrackingArea? + private var hovered = false { + didSet { self.updateHighlight() } + } + + init(rootView: AnyView, width: CGFloat) { + self.baseView = rootView + self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false))) + self.targetWidth = max(1, width) + super.init(frame: .zero) + + self.addSubview(self.hosting) + self.hosting.autoresizingMask = [.width, .height] + self.updateSizing() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var intrinsicContentSize: NSSize { + self.hosting.fittingSize + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let tracking { + self.removeTrackingArea(tracking) + } + let options: NSTrackingArea.Options = [ + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect, + ] + let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) + self.addTrackingArea(area) + self.tracking = area + } + + override func mouseEntered(with event: NSEvent) { + _ = event + self.hovered = true + } + + override func mouseExited(with event: NSEvent) { + _ = event + self.hovered = false + } + + override func layout() { + super.layout() + self.hosting.frame = self.bounds + } + + override func draw(_ dirtyRect: NSRect) { + if self.hovered { + NSColor.selectedContentBackgroundColor.setFill() + self.bounds.fill() + } + super.draw(dirtyRect) + } + + func update(rootView: AnyView, width: CGFloat) { + self.baseView = rootView + self.targetWidth = max(1, width) + self.updateHighlight() + } + + private func updateHighlight() { + self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered)) + self.updateSizing() + self.needsDisplay = true + } + + private func updateSizing() { + self.hosting.frame.size.width = self.targetWidth + let size = self.hosting.fittingSize + self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height)) + } +} + +struct MenuHostedHighlightedItem: NSViewRepresentable { + let width: CGFloat + let rootView: AnyView + + func makeNSView(context _: Context) -> HighlightedMenuItemHostView { + HighlightedMenuItemHostView(rootView: self.rootView, width: self.width) + } + + func updateNSView(_ nsView: HighlightedMenuItemHostView, context _: Context) { + nsView.update(rootView: self.rootView, width: self.width) + } +} diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift index 179ad3b96..ca41f359f 100644 --- a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -6,12 +6,14 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { static let shared = MenuSessionsInjector() private let tag = 9_415_557 + private let nodesTag = 9_415_558 private let fallbackWidth: CGFloat = 320 private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 private weak var originalDelegate: NSMenuDelegate? private weak var statusItem: NSStatusItem? private var loadTask: Task? + private var nodesLoadTask: Task? private var isMenuOpen = false private var lastKnownMenuWidth: CGFloat? @@ -19,6 +21,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private var cachedErrorText: String? private var cacheUpdatedAt: Date? private let refreshIntervalSeconds: TimeInterval = 12 + private let nodesStore = InstancesStore.shared #if DEBUG private var testControlChannelConnected: Bool? #endif @@ -36,6 +39,8 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { if self.loadTask == nil { self.loadTask = Task { await self.refreshCache(force: true) } } + + self.nodesStore.start() } func menuWillOpen(_ menu: NSMenu) { @@ -43,6 +48,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { self.isMenuOpen = true self.inject(into: menu) + self.injectNodes(into: menu) // Refresh in background for the next open (but only when connected). self.loadTask?.cancel() @@ -53,6 +59,17 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { guard self.isMenuOpen else { return } // SwiftUI might have refreshed menu items; re-inject once. self.inject(into: menu) + self.injectNodes(into: menu) + } + } + + self.nodesLoadTask?.cancel() + self.nodesLoadTask = Task { [weak self] in + guard let self else { return } + await self.nodesStore.refresh() + await MainActor.run { + guard self.isMenuOpen else { return } + self.injectNodes(into: menu) } } } @@ -61,6 +78,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { self.originalDelegate?.menuDidClose?(menu) self.isMenuOpen = false self.loadTask?.cancel() + self.nodesLoadTask?.cancel() } func menuNeedsUpdate(_ menu: NSMenu) { @@ -159,6 +177,73 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } } + private func injectNodes(into menu: NSMenu) { + for item in menu.items where item.tag == self.nodesTag { + menu.removeItem(item) + } + + guard let insertIndex = self.findNodesInsertIndex(in: menu) else { return } + let width = self.initialWidth(for: menu) + var cursor = insertIndex + + let entries = self.sortedNodeEntries() + let header = self.makeNodesHeaderItem(width: width, count: entries.count) + menu.insertItem(header, at: cursor) + cursor += 1 + + guard self.isControlChannelConnected else { + menu.insertItem( + self.makeMessageItem(text: "No connection to gateway", symbolName: "wifi.slash", width: width), + at: cursor) + cursor += 1 + let separator = NSMenuItem.separator() + separator.tag = self.nodesTag + menu.insertItem(separator, at: cursor) + return + } + + if let error = self.nodesStore.lastError?.nonEmpty { + menu.insertItem(self.makeMessageItem(text: "Error: \(error)", symbolName: "exclamationmark.triangle", + width: width), at: cursor) + cursor += 1 + } else if let status = self.nodesStore.statusMessage?.nonEmpty { + menu.insertItem(self.makeMessageItem(text: status, symbolName: "info.circle", width: width), at: cursor) + cursor += 1 + } + + if entries.isEmpty { + let title = self.nodesStore.isLoading ? "Loading nodes..." : "No nodes yet" + menu.insertItem(self.makeMessageItem(text: title, symbolName: "circle.dashed", width: width), at: cursor) + cursor += 1 + } else { + for entry in entries.prefix(5) { + let item = NSMenuItem() + item.tag = self.nodesTag + item.target = self + item.action = #selector(self.copyNodeSummary(_:)) + item.representedObject = NodeMenuEntryFormatter.summaryText(entry) + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), + width: width) + menu.insertItem(item, at: cursor) + cursor += 1 + } + + if entries.count > 5 { + let moreItem = NSMenuItem() + moreItem.tag = self.nodesTag + moreItem.title = "More Nodes..." + moreItem.image = NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: nil) + let overflow = Array(entries.dropFirst(5)) + moreItem.submenu = self.buildNodesOverflowMenu(entries: overflow, width: width) + menu.insertItem(moreItem, at: cursor) + cursor += 1 + } + } + + _ = cursor + } + private var isControlChannelConnected: Bool { #if DEBUG if let override = self.testControlChannelConnected { return override } @@ -321,6 +406,21 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return menu } + private func buildNodesOverflowMenu(entries: [InstanceInfo], width: CGFloat) -> NSMenu { + let menu = NSMenu() + for entry in entries { + let item = NSMenuItem() + item.target = self + item.action = #selector(self.copyNodeSummary(_:)) + item.representedObject = NodeMenuEntryFormatter.summaryText(entry) + item.view = HighlightedMenuItemHostView( + rootView: AnyView(NodeMenuRowView(entry: entry, width: width)), + width: width) + menu.addItem(item) + } + return menu + } + @objc private func patchThinking(_ sender: NSMenuItem) { guard let dict = sender.representedObject as? [String: Any], @@ -423,6 +523,13 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { } } + @objc + private func copyNodeSummary(_ sender: NSMenuItem) { + guard let summary = sender.representedObject as? String else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(summary, forType: .string) + } + // MARK: - Width + placement private func findInsertIndex(in menu: NSMenu) -> Int? { @@ -442,6 +549,22 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return menu.items.count } + private func findNodesInsertIndex(in menu: NSMenu) -> Int? { + if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { + if let sepIdx = menu.items[..= 1 { return 1 } + return menu.items.count + } + private func initialWidth(for menu: NSMenu) -> CGFloat { let candidates: [CGFloat] = [ menu.minimumWidth, @@ -452,6 +575,53 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { return max(300, resolved) } + private func sortedNodeEntries() -> [InstanceInfo] { + let entries = self.nodesStore.instances.filter { entry in + let mode = entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return mode != "health" + } + return entries.sorted { lhs, rhs in + let lhsGateway = NodeMenuEntryFormatter.isGateway(lhs) + let rhsGateway = NodeMenuEntryFormatter.isGateway(rhs) + if lhsGateway != rhsGateway { return lhsGateway } + + let lhsLocal = NodeMenuEntryFormatter.isLocal(lhs) + let rhsLocal = NodeMenuEntryFormatter.isLocal(rhs) + if lhsLocal != rhsLocal { return lhsLocal } + + let lhsName = NodeMenuEntryFormatter.primaryName(lhs).lowercased() + let rhsName = NodeMenuEntryFormatter.primaryName(rhs).lowercased() + if lhsName == rhsName { return lhs.ts > rhs.ts } + return lhsName < rhsName + } + } + + private func makeNodesHeaderItem(width: CGFloat, count: Int) -> NSMenuItem { + let view = AnyView( + HStack(spacing: 6) { + Image(systemName: "network") + .font(.caption) + .foregroundStyle(.secondary) + Text("Nodes") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + Text("\(count)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.leading, 18) + .padding(.trailing, 12) + .padding(.vertical, 6) + .frame(minWidth: 300, alignment: .leading)) + + let item = NSMenuItem() + item.tag = self.nodesTag + item.isEnabled = false + item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) + return item + } + // MARK: - Views private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { @@ -490,81 +660,3 @@ extension MenuSessionsInjector { } } #endif - -private final class HighlightedMenuItemHostView: NSView { - private let baseView: AnyView - private let hosting: NSHostingView - private var targetWidth: CGFloat - private var tracking: NSTrackingArea? - private var hovered = false { - didSet { self.updateHighlight() } - } - - init(rootView: AnyView, width: CGFloat) { - self.baseView = rootView - self.hosting = NSHostingView(rootView: AnyView(rootView.environment(\.menuItemHighlighted, false))) - self.targetWidth = max(1, width) - super.init(frame: .zero) - - self.addSubview(self.hosting) - self.hosting.autoresizingMask = [.width, .height] - self.updateSizing() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - - override var intrinsicContentSize: NSSize { - self.hosting.fittingSize - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let tracking { - self.removeTrackingArea(tracking) - } - let options: NSTrackingArea.Options = [ - .mouseEnteredAndExited, - .activeAlways, - .inVisibleRect, - ] - let area = NSTrackingArea(rect: self.bounds, options: options, owner: self, userInfo: nil) - self.addTrackingArea(area) - self.tracking = area - } - - override func mouseEntered(with event: NSEvent) { - _ = event - self.hovered = true - } - - override func mouseExited(with event: NSEvent) { - _ = event - self.hovered = false - } - - override func layout() { - super.layout() - self.hosting.frame = self.bounds - } - - override func draw(_ dirtyRect: NSRect) { - if self.hovered { - NSColor.selectedContentBackgroundColor.setFill() - self.bounds.fill() - } - super.draw(dirtyRect) - } - - private func updateHighlight() { - self.hosting.rootView = AnyView(self.baseView.environment(\.menuItemHighlighted, self.hovered)) - self.updateSizing() - self.needsDisplay = true - } - - private func updateSizing() { - self.hosting.frame.size.width = self.targetWidth - let size = self.hosting.fittingSize - self.frame = NSRect(origin: .zero, size: NSSize(width: self.targetWidth, height: size.height)) - } -} diff --git a/apps/macos/Sources/Clawdis/NodesMenu.swift b/apps/macos/Sources/Clawdis/NodesMenu.swift new file mode 100644 index 000000000..8f47140f9 --- /dev/null +++ b/apps/macos/Sources/Clawdis/NodesMenu.swift @@ -0,0 +1,157 @@ +import AppKit +import SwiftUI + +struct NodeMenuEntryFormatter { + static func isGateway(_ entry: InstanceInfo) -> Bool { + entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "gateway" + } + + static func isLocal(_ entry: InstanceInfo) -> Bool { + entry.mode?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "local" + } + + static func primaryName(_ entry: InstanceInfo) -> String { + if self.isGateway(entry) { + let host = entry.host?.nonEmpty + if let host, host.lowercased() != "gateway" { return host } + return "Gateway" + } + return entry.host?.nonEmpty ?? entry.id + } + + static func summaryText(_ entry: InstanceInfo) -> String { + entry.text.nonEmpty ?? self.primaryName(entry) + } + + static func detailText(_ entry: InstanceInfo) -> String { + var parts: [String] = [] + + if self.isGateway(entry) { + parts.append("gateway") + } else if let mode = entry.mode?.nonEmpty { + parts.append(mode) + } + + if let ip = entry.ip?.nonEmpty { parts.append(ip) } + if let version = entry.version?.nonEmpty { parts.append("app \(version)") } + if let platform = entry.platform?.nonEmpty { parts.append(platform) } + + if parts.isEmpty, 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 !candidates.isEmpty { + parts.append(contentsOf: candidates.prefix(2)) + } + } + + if parts.isEmpty { + parts.append(entry.ageDescription) + } + + if parts.count > 2 { + parts = Array(parts.prefix(2)) + } + return parts.joined(separator: " / ") + } + + 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() { + if family.contains("mac") { + return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") + } + if family.contains("iphone") { return self.safeSystemSymbol("iphone", fallback: "iphone") } + if family.contains("ipad") { return self.safeSystemSymbol("ipad", fallback: "ipad") } + } + if let platform = entry.platform?.lowercased() { + if platform.contains("mac") { return self.safeSystemSymbol("laptopcomputer", fallback: "laptopcomputer") } + if platform.contains("ios") { return self.safeSystemSymbol("iphone", fallback: "iphone") } + if platform.contains("android") { return self.safeSystemSymbol("cpu", fallback: "cpu") } + } + return "cpu" + } + + static func isAndroid(_ entry: InstanceInfo) -> Bool { + let family = entry.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return family == "android" + } + + private static func safeSystemSymbol(_ preferred: String, fallback: String) -> String { + if NSImage(systemSymbolName: preferred, accessibilityDescription: nil) != nil { return preferred } + return fallback + } +} + +struct NodeMenuRowView: View { + let entry: InstanceInfo + let width: CGFloat + @Environment(\.menuItemHighlighted) private var isHighlighted + + private var primaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } + + var body: some View { + HStack(alignment: .center, spacing: 10) { + self.leadingIcon + .frame(width: 22, height: 22, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(NodeMenuEntryFormatter.primaryName(self.entry)) + .font(.callout.weight(NodeMenuEntryFormatter.isGateway(self.entry) ? .semibold : .regular)) + .foregroundStyle(self.primaryColor) + .lineLimit(1) + .truncationMode(.middle) + + Text(NodeMenuEntryFormatter.detailText(self.entry)) + .font(.caption) + .foregroundStyle(self.secondaryColor) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + + } + .padding(.vertical, 8) + .padding(.leading, 18) + .padding(.trailing, 12) + .frame(width: max(1, self.width), alignment: .leading) + } + + @ViewBuilder + private var leadingIcon: some View { + if NodeMenuEntryFormatter.isAndroid(self.entry) { + AndroidMark() + .foregroundStyle(self.secondaryColor) + } else { + Image(systemName: NodeMenuEntryFormatter.leadingSymbol(self.entry)) + .font(.system(size: 18, weight: .regular)) + .foregroundStyle(self.secondaryColor) + } + } +} + +struct AndroidMark: View { + var body: some View { + GeometryReader { geo in + let w = geo.size.width + let h = geo.size.height + let headHeight = h * 0.68 + let headWidth = w * 0.92 + let headX = (w - headWidth) * 0.5 + let headY = (h - headHeight) * 0.5 + let corner = min(w, h) * 0.18 + RoundedRectangle(cornerRadius: corner, style: .continuous) + .frame(width: headWidth, height: headHeight) + .position(x: headX + headWidth * 0.5, y: headY + headHeight * 0.5) + } + } +}