From 1e1d76d6007c37bcf47766a974d70be7f28f245e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 22 Dec 2025 22:49:37 +0100 Subject: [PATCH] fix(mac): restore sessions bars with injected submenus --- apps/macos/Sources/Clawdis/MenuBar.swift | 1 + .../Sources/Clawdis/MenuContentView.swift | 290 --------- .../Clawdis/MenuSessionsInjector.swift | 584 ++++++++++++++++++ .../Clawdis/SessionMenuLabelView.swift | 37 +- 4 files changed, 617 insertions(+), 295 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/MenuSessionsInjector.swift diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index e1de26698..cd3347d23 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -48,6 +48,7 @@ struct ClawdisApp: App { .menuBarExtraStyle(.menu) .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in self.statusItem = item + MenuSessionsInjector.shared.install(into: item) self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) self.installStatusItemMouseHandler(for: item) self.updateHoverHUDSuppression() diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 2f7ca291b..fd1ff613e 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -16,13 +16,7 @@ struct MenuContent: View { @Environment(\.openSettings) private var openSettings @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false - @State private var sessionMenu: [SessionRow] = [] - @State private var sessionStorePath: String? - @State private var sessionLoading = true - @State private var sessionErrorText: String? @State private var browserControlEnabled = true - private let sessionMenuItemWidth: CGFloat = 320 - private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -34,8 +28,6 @@ struct MenuContent: View { } .disabled(self.state.connectionMode == .unconfigured) - self.sessionsSection - Divider() Toggle(isOn: self.heartbeatsBinding) { VStack(alignment: .leading, spacing: 2) { @@ -104,9 +96,6 @@ struct MenuContent: View { await self.loadMicrophones(force: true) } } - .task { - await self.reloadSessionMenu() - } .task { VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled) } @@ -198,285 +187,6 @@ struct MenuContent: View { } } - private var sessionsSection: some View { - Group { - if !self.isGatewayConnected { - MenuHostedItem( - width: self.sessionMenuItemWidth, - rootView: AnyView( - Label("No connection to gateway", systemImage: "wifi.slash") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .padding(.leading, 20) - .padding(.trailing, 10) - .padding(.vertical, 6) - .frame(minWidth: 300, alignment: .leading))) - .disabled(true) - } else { - MenuHostedItem( - width: self.sessionMenuItemWidth, - rootView: AnyView(MenuSessionsHeaderView( - count: self.sessionMenu.count, - statusText: self.sessionLoading - ? "Loading sessions…" - : (self.sessionMenu.isEmpty ? nil : self.sessionErrorText)))) - .disabled(true) - - if self.sessionMenu.isEmpty, !self.sessionLoading, let error = self.sessionErrorText, !error.isEmpty { - MenuHostedItem( - width: self.sessionMenuItemWidth, - rootView: AnyView( - Label(error, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.tail) - .padding(.leading, 20) - .padding(.trailing, 10) - .padding(.vertical, 6) - .frame(minWidth: 300, alignment: .leading))) - .disabled(true) - } else if self.sessionMenu.isEmpty, !self.sessionLoading, self.sessionErrorText == nil { - MenuHostedItem( - width: self.sessionMenuItemWidth, - rootView: AnyView(Text("No active sessions") - .font(.caption) - .foregroundStyle(.secondary) - .padding(.leading, 20) - .padding(.trailing, 10) - .padding(.vertical, 6) - .frame(minWidth: 300, alignment: .leading))) - .disabled(true) - } else { - ForEach(self.sessionMenu) { row in - Menu { - self.sessionSubmenu(for: row) - } label: { - MenuHostedItem( - width: self.sessionMenuItemWidth, - rootView: AnyView(SessionMenuLabelView(row: row, width: self.sessionMenuItemWidth))) - } - } - } - } - } - } - - private var isGatewayConnected: Bool { - if case .connected = self.controlChannel.state { return true } - return false - } - - @ViewBuilder - private func sessionSubmenu(for row: SessionRow) -> some View { - Menu("Syncing") { - ForEach(["on", "off", "default"], id: \.self) { option in - Button { - Task { - do { - let value: SessionSyncingValue? = switch option { - case "on": .bool(true) - case "off": .bool(false) - default: nil - } - try await SessionActions.patchSession(key: row.key, syncing: .some(value)) - await self.reloadSessionMenu() - } catch { - await MainActor.run { - SessionActions.presentError(title: "Update syncing failed", error: error) - } - } - } - } label: { - let normalized: SessionSyncingValue? = switch option { - case "on": .bool(true) - case "off": .bool(false) - default: nil - } - let isSelected: Bool = { - switch normalized { - case .none: - row.syncing == nil - case let .some(value): - switch value { - case .bool(true): - row.syncing?.isOn == true - case .bool(false): - row.syncing?.isOff == true - case let .string(v): - row.syncing?.label == v - } - } - }() - Label(option.capitalized, systemImage: isSelected ? "checkmark" : "") - } - } - } - - Menu("Thinking") { - ForEach(["off", "minimal", "low", "medium", "high", "default"], id: \.self) { level in - let normalized = level == "default" ? nil : level - Button { - Task { - do { - try await SessionActions.patchSession(key: row.key, thinking: .some(normalized)) - await self.reloadSessionMenu() - } catch { - await MainActor.run { - SessionActions.presentError(title: "Update thinking failed", error: error) - } - } - } - } label: { - let checkmark = row.thinkingLevel == normalized ? "checkmark" : "" - Label(level.capitalized, systemImage: checkmark) - } - } - } - - Menu("Verbose") { - ForEach(["on", "off", "default"], id: \.self) { level in - let normalized = level == "default" ? nil : level - Button { - Task { - do { - try await SessionActions.patchSession(key: row.key, verbose: .some(normalized)) - await self.reloadSessionMenu() - } catch { - await MainActor.run { - SessionActions.presentError(title: "Update verbose failed", error: error) - } - } - } - } label: { - let checkmark = row.verboseLevel == normalized ? "checkmark" : "" - Label(level.capitalized, systemImage: checkmark) - } - } - } - - if self.state.debugPaneEnabled, self.state.connectionMode == .local, let sessionId = row.sessionId, !sessionId.isEmpty { - Button { - SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: self.sessionStorePath) - } label: { - Label("Open Session Log", systemImage: "doc.text") - } - } - - Divider() - - Button { - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Reset session?", - message: "Starts a new session id for “\(row.key)”.", - action: "Reset") - else { return } - - do { - try await SessionActions.resetSession(key: row.key) - await self.reloadSessionMenu() - } catch { - SessionActions.presentError(title: "Reset failed", error: error) - } - } - } label: { - Label("Reset Session", systemImage: "arrow.counterclockwise") - } - - Button { - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Compact session log?", - message: "Keeps the last 400 lines; archives the old file.", - action: "Compact") - else { return } - - do { - try await SessionActions.compactSession(key: row.key, maxLines: 400) - await self.reloadSessionMenu() - } catch { - SessionActions.presentError(title: "Compact failed", error: error) - } - } - } label: { - Label("Compact Session Log", systemImage: "scissors") - } - - if row.key != "main" { - Button(role: .destructive) { - Task { @MainActor in - guard SessionActions.confirmDestructiveAction( - title: "Delete session?", - message: "Deletes the “\(row.key)” entry and archives its transcript.", - action: "Delete") - else { return } - - do { - try await SessionActions.deleteSession(key: row.key) - await self.reloadSessionMenu() - } catch { - SessionActions.presentError(title: "Delete failed", error: error) - } - } - } label: { - Label("Delete Session", systemImage: "trash") - } - } - } - - @MainActor - private func reloadSessionMenu() async { - self.sessionLoading = true - self.sessionErrorText = nil - - if case .connected = self.controlChannel.state { - // ok - } else { - self.sessionStorePath = nil - self.sessionMenu = [] - self.sessionErrorText = "No connection to gateway" - self.sessionLoading = false - return - } - - do { - let snapshot = try await SessionLoader.loadSnapshot(limit: 32) - self.sessionStorePath = snapshot.storePath - let now = Date() - let active = snapshot.rows.filter { row in - if row.key == "main" { return true } - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.sessionMenuActiveWindowSeconds - } - self.sessionMenu = active.sorted { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - } catch { - self.sessionStorePath = nil - self.sessionMenu = [] - self.sessionErrorText = self.compactSessionError(error) - } - - self.sessionLoading = false - } - - private func compactSessionError(_ error: Error) -> String { - if let loadError = error as? SessionLoadError { - switch loadError { - case .gatewayUnavailable: - return "No connection to gateway" - case .decodeFailed: - return "Sessions unavailable" - } - } - return "No connection to gateway" - } - private func open(tab: SettingsTab) { SettingsTabRouter.request(tab) NSApp.activate(ignoringOtherApps: true) diff --git a/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift new file mode 100644 index 000000000..8d456dfd0 --- /dev/null +++ b/apps/macos/Sources/Clawdis/MenuSessionsInjector.swift @@ -0,0 +1,584 @@ +import AppKit +import SwiftUI + +@MainActor +final class MenuSessionsInjector: NSObject, NSMenuDelegate { + static let shared = MenuSessionsInjector() + + private let tag = 9_415_557 + 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 isMenuOpen = false + private var lastKnownMenuWidth: CGFloat? + + private var cachedSnapshot: SessionStoreSnapshot? + private var cachedErrorText: String? + private var cacheUpdatedAt: Date? + private let refreshIntervalSeconds: TimeInterval = 12 + + func install(into statusItem: NSStatusItem) { + self.statusItem = statusItem + guard let menu = statusItem.menu else { return } + + // Preserve SwiftUI's internal NSMenuDelegate, otherwise it may stop populating menu items. + if menu.delegate !== self { + self.originalDelegate = menu.delegate + menu.delegate = self + } + + if self.loadTask == nil { + self.loadTask = Task { await self.refreshCache(force: true) } + } + } + + func menuWillOpen(_ menu: NSMenu) { + self.originalDelegate?.menuWillOpen?(menu) + self.isMenuOpen = true + + self.inject(into: menu) + + // Refresh in background for the next open (but only when connected). + self.loadTask?.cancel() + self.loadTask = Task { [weak self] in + guard let self else { return } + await self.refreshCache(force: false) + await MainActor.run { + guard self.isMenuOpen else { return } + // SwiftUI might have refreshed menu items; re-inject once. + self.inject(into: menu) + } + } + } + + func menuDidClose(_ menu: NSMenu) { + self.originalDelegate?.menuDidClose?(menu) + self.isMenuOpen = false + self.loadTask?.cancel() + } + + func menuNeedsUpdate(_ menu: NSMenu) { + self.originalDelegate?.menuNeedsUpdate?(menu) + } + + func confinementRect(for menu: NSMenu, on screen: NSScreen?) -> NSRect { + if let rect = self.originalDelegate?.confinementRect?(for: menu, on: screen) { + return rect + } + return NSRect.zero + } + + // MARK: - Injection + + private func inject(into menu: NSMenu) { + // Remove any previous injected items. + for item in menu.items where item.tag == self.tag { + menu.removeItem(item) + } + + guard let insertIndex = self.findInsertIndex(in: menu) else { return } + let width = self.initialWidth(for: menu) + + guard self.isControlChannelConnected else { + menu.insertItem(self.makeMessageItem( + text: "No connection to gateway", + symbolName: "wifi.slash", + width: width), at: insertIndex) + return + } + + guard let snapshot = self.cachedSnapshot else { + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + headerItem.view = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView( + count: 0, + statusText: self.cachedErrorText ?? "Loading sessions…")), + width: width, + highlighted: false) + menu.insertItem(headerItem, at: insertIndex) + DispatchQueue.main.async { [weak self, weak view = headerItem.view] in + guard let self, let view else { return } + self.captureMenuWidthIfAvailable(from: view) + } + return + } + + let now = Date() + let rows = snapshot.rows.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + }.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + + let headerItem = NSMenuItem() + headerItem.tag = self.tag + headerItem.isEnabled = false + let headerView = self.makeHostedView( + rootView: AnyView(MenuSessionsHeaderView(count: rows.count, statusText: nil)), + width: width, + highlighted: false) + headerItem.view = headerView + menu.insertItem(headerItem, at: insertIndex) + + var cursor = insertIndex + 1 + if rows.isEmpty { + menu.insertItem(self.makeMessageItem(text: "No active sessions", symbolName: "minus", width: width), at: cursor) + return + } + + for row in rows { + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = true + item.submenu = self.buildSubmenu(for: row, storePath: snapshot.storePath) + item.view = self.makeHostedView( + rootView: AnyView(SessionMenuLabelView(row: row, width: width)), + width: width, + highlighted: true) + menu.insertItem(item, at: cursor) + cursor += 1 + } + + DispatchQueue.main.async { [weak self, weak headerView] in + guard let self, let headerView else { return } + self.captureMenuWidthIfAvailable(from: headerView) + } + } + + private var isControlChannelConnected: Bool { + if case .connected = ControlChannel.shared.state { return true } + return false + } + + private func makeMessageItem(text: String, symbolName: String, width: CGFloat) -> NSMenuItem { + let view = AnyView( + Label(text, systemImage: symbolName) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 18) + .padding(.trailing, 12) + .padding(.vertical, 6) + .frame(minWidth: 300, alignment: .leading)) + + let item = NSMenuItem() + item.tag = self.tag + item.isEnabled = false + item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) + return item + } + + // MARK: - Cache + + private func refreshCache(force: Bool) async { + if !force, let updated = self.cacheUpdatedAt, Date().timeIntervalSince(updated) < self.refreshIntervalSeconds { + return + } + + guard self.isControlChannelConnected else { + self.cachedSnapshot = nil + self.cachedErrorText = nil + self.cacheUpdatedAt = Date() + return + } + + do { + self.cachedSnapshot = try await SessionLoader.loadSnapshot(limit: 32) + self.cachedErrorText = nil + self.cacheUpdatedAt = Date() + } catch { + self.cachedSnapshot = nil + self.cachedErrorText = self.compactError(error) + self.cacheUpdatedAt = Date() + } + } + + private func compactError(_ error: Error) -> String { + if let loadError = error as? SessionLoadError { + switch loadError { + case .gatewayUnavailable: + return "No connection to gateway" + case .decodeFailed: + return "Sessions unavailable" + } + } + return "Sessions unavailable" + } + + // MARK: - Submenus + + private func buildSubmenu(for row: SessionRow, storePath: String) -> NSMenu { + let menu = NSMenu() + + let syncing = NSMenuItem(title: "Syncing", action: nil, keyEquivalent: "") + syncing.submenu = self.buildSyncingMenu(for: row) + menu.addItem(syncing) + + let thinking = NSMenuItem(title: "Thinking", action: nil, keyEquivalent: "") + thinking.submenu = self.buildThinkingMenu(for: row) + menu.addItem(thinking) + + let verbose = NSMenuItem(title: "Verbose", action: nil, keyEquivalent: "") + verbose.submenu = self.buildVerboseMenu(for: row) + menu.addItem(verbose) + + if AppStateStore.shared.debugPaneEnabled, + AppStateStore.shared.connectionMode == .local, + let sessionId = row.sessionId, + !sessionId.isEmpty + { + menu.addItem(NSMenuItem.separator()) + let openLog = NSMenuItem(title: "Open Session Log", action: #selector(self.openSessionLog(_:)), keyEquivalent: "") + openLog.target = self + openLog.representedObject = [ + "sessionId": sessionId, + "storePath": storePath, + ] + menu.addItem(openLog) + } + + menu.addItem(NSMenuItem.separator()) + + let reset = NSMenuItem(title: "Reset Session", action: #selector(self.resetSession(_:)), keyEquivalent: "") + reset.target = self + reset.representedObject = row.key + menu.addItem(reset) + + let compact = NSMenuItem(title: "Compact Session Log", action: #selector(self.compactSession(_:)), keyEquivalent: "") + compact.target = self + compact.representedObject = row.key + menu.addItem(compact) + + if row.key != "main" { + let del = NSMenuItem(title: "Delete Session", action: #selector(self.deleteSession(_:)), keyEquivalent: "") + del.target = self + del.representedObject = row.key + del.isAlternate = false + del.keyEquivalentModifierMask = [] + menu.addItem(del) + } + + return menu + } + + private func buildSyncingMenu(for row: SessionRow) -> NSMenu { + let menu = NSMenu() + let options: [(title: String, value: String?)] = [ + ("On", "on"), + ("Off", "off"), + ("Default", nil), + ] + for (title, value) in options { + let item = NSMenuItem(title: title, action: #selector(self.patchSyncing(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [ + "key": row.key, + "value": value as Any, + ] + let isSelected: Bool = { + switch value { + case .none: + return row.syncing == nil + case "on": + return row.syncing?.isOn == true + case "off": + return row.syncing?.isOff == true + default: + return false + } + }() + item.state = isSelected ? .on : .off + menu.addItem(item) + } + return menu + } + + private func buildThinkingMenu(for row: SessionRow) -> NSMenu { + let menu = NSMenu() + let levels: [String?] = ["off", "minimal", "low", "medium", "high", nil] + for level in levels { + let title = (level ?? "default").capitalized + let item = NSMenuItem(title: title, action: #selector(self.patchThinking(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [ + "key": row.key, + "value": level as Any, + ] + item.state = (row.thinkingLevel == level) ? .on : .off + menu.addItem(item) + } + return menu + } + + private func buildVerboseMenu(for row: SessionRow) -> NSMenu { + let menu = NSMenu() + let levels: [String?] = ["on", "off", nil] + for level in levels { + let title = (level ?? "default").capitalized + let item = NSMenuItem(title: title, action: #selector(self.patchVerbose(_:)), keyEquivalent: "") + item.target = self + item.representedObject = [ + "key": row.key, + "value": level as Any, + ] + item.state = (row.verboseLevel == level) ? .on : .off + menu.addItem(item) + } + return menu + } + + @objc + private func patchThinking(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: Any], + let key = dict["key"] as? String + else { return } + let value = dict["value"] as? String + Task { + do { + try await SessionActions.patchSession(key: key, thinking: .some(value)) + await self.refreshCache(force: true) + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update thinking failed", error: error) + } + } + } + } + + @objc + private func patchVerbose(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: Any], + let key = dict["key"] as? String + else { return } + let value = dict["value"] as? String + Task { + do { + try await SessionActions.patchSession(key: key, verbose: .some(value)) + await self.refreshCache(force: true) + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update verbose failed", error: error) + } + } + } + } + + @objc + private func patchSyncing(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: Any], + let key = dict["key"] as? String + else { return } + + let selection = dict["value"] as? String + let value: SessionSyncingValue? = switch selection { + case "on": .bool(true) + case "off": .bool(false) + default: nil + } + + Task { + do { + try await SessionActions.patchSession(key: key, syncing: .some(value)) + await self.refreshCache(force: true) + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update syncing failed", error: error) + } + } + } + } + + @objc + private func openSessionLog(_ sender: NSMenuItem) { + guard let dict = sender.representedObject as? [String: String], + let sessionId = dict["sessionId"], + let storePath = dict["storePath"] + else { return } + SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: storePath) + } + + @objc + private func resetSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Reset session?", + message: "Starts a new session id for “\(key)”.", + action: "Reset") + else { return } + + do { + try await SessionActions.resetSession(key: key) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Reset failed", error: error) + } + } + } + + @objc + private func compactSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Compact session log?", + message: "Keeps the last 400 lines; archives the old file.", + action: "Compact") + else { return } + + do { + try await SessionActions.compactSession(key: key, maxLines: 400) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Compact failed", error: error) + } + } + } + + @objc + private func deleteSession(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { return } + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Delete session?", + message: "Deletes the “\(key)” entry and archives its transcript.", + action: "Delete") + else { return } + + do { + try await SessionActions.deleteSession(key: key) + await self.refreshCache(force: true) + } catch { + SessionActions.presentError(title: "Delete failed", error: error) + } + } + } + + // MARK: - Width + placement + + private func findInsertIndex(in menu: NSMenu) -> Int? { + // Insert right before the separator above "Send Heartbeats". + 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, + self.lastKnownMenuWidth ?? 0, + self.fallbackWidth, + ] + let resolved = candidates.max() ?? self.fallbackWidth + return max(300, resolved) + } + + // MARK: - Views + + private func makeHostedView(rootView: AnyView, width: CGFloat, highlighted: Bool) -> NSView { + if highlighted { + let container = HighlightedMenuItemHostView(rootView: rootView, width: width) + return container + } + + let hosting = NSHostingView(rootView: rootView) + hosting.frame.size.width = max(1, width) + let size = hosting.fittingSize + hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height)) + return hosting + } + + private func captureMenuWidthIfAvailable(from view: NSView) { + guard let width = view.window?.contentView?.bounds.width, width > 0 else { return } + self.lastKnownMenuWidth = max(300, width) + } +} + +private final class HighlightedMenuItemHostView: NSView { + private let baseView: AnyView + private let hosting: NSHostingView + 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))) + super.init(frame: .zero) + + self.addSubview(self.hosting) + self.hosting.autoresizingMask = [.width, .height] + self.hosting.frame = self.bounds + + self.frame.size.width = max(1, width) + let size = self.fittingSize + self.frame.size.height = size.height + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + 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.needsDisplay = true + } +} diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index 9c59863a5..eae6c31f3 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -1,10 +1,31 @@ import SwiftUI +private struct MenuItemHighlightedKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var menuItemHighlighted: Bool { + get { self[MenuItemHighlightedKey.self] } + set { self[MenuItemHighlightedKey.self] = newValue } + } +} + struct SessionMenuLabelView: View { let row: SessionRow let width: CGFloat - private let paddingLeading: CGFloat = 20 - private let paddingTrailing: CGFloat = 10 + @Environment(\.menuItemHighlighted) private var isHighlighted + private let paddingLeading: CGFloat = 18 + private let paddingTrailing: CGFloat = 12 + private let barHeight: CGFloat = 3 + + private var primaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary + } + + private var secondaryTextColor: Color { + self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary + } var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -12,11 +33,12 @@ struct SessionMenuLabelView: View { usedTokens: row.tokens.total, contextTokens: row.tokens.contextTokens, width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), - height: 4) + height: self.barHeight) HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.key) .font(.caption.weight(row.key == "main" ? .semibold : .regular)) + .foregroundStyle(self.primaryTextColor) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) @@ -25,13 +47,18 @@ struct SessionMenuLabelView: View { Text(row.tokens.contextSummaryShort) .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .foregroundStyle(self.secondaryTextColor) .lineLimit(1) .fixedSize(horizontal: true, vertical: false) .layoutPriority(2) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(self.secondaryTextColor) + .padding(.leading, 2) } } - .padding(.vertical, 4) + .padding(.vertical, 3) .padding(.leading, self.paddingLeading) .padding(.trailing, self.paddingTrailing) }