diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift index a7848e963..3ac0e45b6 100644 --- a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -3,17 +3,17 @@ import SwiftUI /// Context usage card shown at the top of the menubar menu. struct ContextMenuCardView: View { - private let width: CGFloat + private let rows: [SessionRow] + private let statusText: String? private let padding: CGFloat = 10 - private let barHeight: CGFloat = 4 + private let barHeight: CGFloat = 3 - @State private var rows: [SessionRow] = [] - @State private var activeCount: Int = 0 - - private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 - - init(width: CGFloat) { - self.width = width + init( + rows: [SessionRow], + statusText: String? = nil + ) { + self.rows = rows + self.statusText = statusText } var body: some View { @@ -28,7 +28,11 @@ struct ContextMenuCardView: View { .foregroundStyle(.secondary) } - if self.rows.isEmpty { + if let statusText { + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + } else if self.rows.isEmpty { Text("No active sessions") .font(.caption) .foregroundStyle(.secondary) @@ -41,7 +45,7 @@ struct ContextMenuCardView: View { } } .padding(self.padding) - .frame(width: self.width, alignment: .leading) + .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) .background { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color.white.opacity(0.04)) @@ -50,23 +54,22 @@ struct ContextMenuCardView: View { .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) } } - .task { await self.reload() } } private var subtitle: String { - let count = self.activeCount + let count = self.rows.count if count == 1 { return "1 session · 24h" } return "\(count) sessions · 24h" } - private var contentWidth: CGFloat { - max(1, self.width - (self.padding * 2)) - } - @ViewBuilder private func sessionRow(_ row: SessionRow) -> some View { - let width = self.contentWidth - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 5) { + ContextUsageBar( + usedTokens: row.tokens.total, + contextTokens: row.tokens.contextTokens, + height: self.barHeight) + HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.key) .font(.caption.weight(row.key == "main" ? .semibold : .regular)) @@ -81,51 +84,6 @@ struct ContextMenuCardView: View { .fixedSize(horizontal: true, vertical: false) .layoutPriority(2) } - .frame(width: width) - - ContextUsageBar( - usedTokens: row.tokens.total, - contextTokens: row.tokens.contextTokens, - width: width, - height: self.barHeight) } - .frame(width: width) - } - - @MainActor - private func reload() async { - let hints = SessionLoader.configHints() - let store = SessionLoader.resolveStorePath(override: hints.storePath) - let defaults = SessionDefaults( - model: hints.model ?? SessionLoader.fallbackModel, - contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) - - guard let loaded = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { - self.rows = [] - self.activeCount = 0 - return - } - - let now = Date() - let active = loaded.filter { row in - guard let updatedAt = row.updatedAt else { return false } - return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds - } - - let main = loaded.first(where: { $0.key == "main" }) - var merged = active - if let main, !merged.contains(where: { $0.key == "main" }) { - merged.insert(main, at: 0) - } - - merged.sort { lhs, rhs in - if lhs.key == "main" { return true } - if rhs.key == "main" { return false } - return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) - } - - self.rows = merged - self.activeCount = active.count } } - diff --git a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift index 22ae51593..4aca3da00 100644 --- a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift @@ -6,8 +6,10 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { static let shared = MenuContextCardInjector() private let tag = 9_415_227 - private let cardWidth: CGFloat = 320 + private let fallbackCardWidth: CGFloat = 320 private weak var originalDelegate: NSMenuDelegate? + private var loadTask: Task? + private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 func install(into statusItem: NSStatusItem) { // SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. @@ -29,10 +31,15 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { guard let insertIndex = self.findInsertIndex(in: menu) else { return } - let cardView = ContextMenuCardView(width: self.cardWidth) - let hosting = NSHostingView(rootView: cardView) + self.loadTask?.cancel() + + let placeholder = AnyView(ContextMenuCardView( + rows: [], + statusText: "Loading sessions…")) + + let hosting = NSHostingView(rootView: placeholder) let size = hosting.fittingSize - hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.cardWidth, height: size.height)) + hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height)) let item = NSMenuItem() item.tag = self.tag @@ -40,10 +47,30 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { item.isEnabled = false menu.insertItem(item, at: insertIndex) + + // After the menu attaches the view to its window, adopt the menu's computed width. + DispatchQueue.main.async { [weak self, weak hosting] in + guard let self, let hosting else { return } + self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting) + } + + self.loadTask = Task { [weak hosting] in + let view = await self.makeCardView() + await MainActor.run { + hosting?.rootView = view + hosting?.invalidateIntrinsicContentSize() + if let hosting { + self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting) + let size = hosting.fittingSize + hosting.frame.size.height = size.height + } + } + } } func menuDidClose(_ menu: NSMenu) { self.originalDelegate?.menuDidClose?(menu) + self.loadTask?.cancel() } func menuNeedsUpdate(_ menu: NSMenu) { @@ -57,6 +84,34 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { return NSRect.zero } + private func makeCardView() async -> AnyView { + let hints = SessionLoader.configHints() + let store = SessionLoader.resolveStorePath(override: hints.storePath) + let defaults = SessionDefaults( + model: hints.model ?? SessionLoader.fallbackModel, + contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) + + do { + let loaded = try await SessionLoader.loadRows(at: store, defaults: defaults) + let now = Date() + let current = loaded.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds + } + + let sorted = current.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + + return AnyView(ContextMenuCardView(rows: sorted)) + } catch { + return AnyView(ContextMenuCardView(rows: [], statusText: "Could not load sessions")) + } + } + private func findInsertIndex(in menu: NSMenu) -> Int? { // Prefer inserting before the "Send Heartbeats" toggle item. if let idx = menu.items.firstIndex(where: { $0.title == "Send Heartbeats" }) { @@ -66,4 +121,31 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { if menu.items.count >= 2 { return 2 } return menu.items.count } + + private func initialCardWidth(for menu: NSMenu) -> CGFloat { + let width = menu.minimumWidth + if width > 0 { return max(300, width) } + return 300 + } + + private func adoptMenuWidthIfAvailable(for menu: NSMenu, hosting: NSHostingView) { + let targetWidth: CGFloat? = { + if let contentWidth = hosting.window?.contentView?.bounds.width, contentWidth > 0 { return contentWidth } + if let superWidth = hosting.superview?.bounds.width, superWidth > 0 { return superWidth } + let minimumWidth = menu.minimumWidth + if minimumWidth > 0 { return minimumWidth } + return nil + }() + + guard let targetWidth else { + if hosting.frame.width <= 0 { + hosting.frame.size.width = self.fallbackCardWidth + } + return + } + + let clamped = max(300, targetWidth) + if abs(hosting.frame.width - clamped) < 1 { return } + hosting.frame.size.width = clamped + } }