From a641250da65b815db5e3449d9ef31a9cd76311f0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 03:42:36 +0000 Subject: [PATCH] macOS: prewarm context menu card --- .../Sources/Clawdis/ContextMenuCardView.swift | 49 +++++++--- .../Clawdis/MenuContextCardInjector.swift | 95 +++++++++++++------ apps/macos/Sources/Clawdis/SessionData.swift | 2 +- 3 files changed, 103 insertions(+), 43 deletions(-) diff --git a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift index 3ac0e45b6..5a52a02dd 100644 --- a/apps/macos/Sources/Clawdis/ContextMenuCardView.swift +++ b/apps/macos/Sources/Clawdis/ContextMenuCardView.swift @@ -5,15 +5,18 @@ import SwiftUI struct ContextMenuCardView: View { private let rows: [SessionRow] private let statusText: String? + private let isLoading: Bool private let padding: CGFloat = 10 private let barHeight: CGFloat = 3 init( rows: [SessionRow], - statusText: String? = nil + statusText: String? = nil, + isLoading: Bool = false ) { self.rows = rows self.statusText = statusText + self.isLoading = isLoading } var body: some View { @@ -32,28 +35,27 @@ struct ContextMenuCardView: View { Text(statusText) .font(.caption) .foregroundStyle(.secondary) - } else if self.rows.isEmpty { + } else if self.rows.isEmpty, !self.isLoading { Text("No active sessions") .font(.caption) .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { - ForEach(self.rows) { row in - self.sessionRow(row) + if self.rows.isEmpty, self.isLoading { + ForEach(0..<2, id: \.self) { _ in + self.placeholderRow + } + } else { + ForEach(self.rows) { row in + self.sessionRow(row) + } } } } } .padding(self.padding) .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.white.opacity(0.04)) - .overlay { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .strokeBorder(Color.white.opacity(0.06), lineWidth: 1) - } - } + .transaction { txn in txn.animation = nil } } private var subtitle: String { @@ -86,4 +88,27 @@ struct ContextMenuCardView: View { } } } + + private var placeholderRow: some View { + VStack(alignment: .leading, spacing: 5) { + ContextUsageBar( + usedTokens: 0, + contextTokens: 200_000, + height: self.barHeight) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("main") + .font(.caption.weight(.semibold)) + .lineLimit(1) + .layoutPriority(1) + Spacer(minLength: 8) + Text("000k/000k") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + .redacted(reason: .placeholder) + } + } } diff --git a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift index 4aca3da00..97b68f45e 100644 --- a/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift +++ b/apps/macos/Sources/Clawdis/MenuContextCardInjector.swift @@ -9,7 +9,13 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { private let fallbackCardWidth: CGFloat = 320 private weak var originalDelegate: NSMenuDelegate? private var loadTask: Task? + private var warmTask: Task? + private var cachedRows: [SessionRow] = [] + private var cacheErrorText: String? + private var cacheUpdatedAt: Date? private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 + private let refreshIntervalSeconds: TimeInterval = 15 + private var isMenuOpen = false func install(into statusItem: NSStatusItem) { // SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. @@ -19,10 +25,15 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { self.originalDelegate = menu.delegate menu.delegate = self } + + if self.warmTask == nil { + self.warmTask = Task { await self.refreshCache(force: true) } + } } func menuWillOpen(_ menu: NSMenu) { self.originalDelegate?.menuWillOpen?(menu) + self.isMenuOpen = true // Remove any previous injected card items. for item in menu.items where item.tag == self.tag { @@ -33,11 +44,16 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { self.loadTask?.cancel() - let placeholder = AnyView(ContextMenuCardView( - rows: [], - statusText: "Loading sessions…")) + let initialRows = self.cachedRows + let initialIsLoading = initialRows.isEmpty + let initialStatusText = initialIsLoading ? self.cacheErrorText : nil - let hosting = NSHostingView(rootView: placeholder) + let initial = AnyView(ContextMenuCardView( + rows: initialRows, + statusText: initialStatusText, + isLoading: initialIsLoading)) + + let hosting = NSHostingView(rootView: initial) let size = hosting.fittingSize hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height)) @@ -55,21 +71,22 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { } self.loadTask = Task { [weak hosting] in - let view = await self.makeCardView() + await self.refreshCache(force: initialIsLoading) + guard let hosting else { return } + let view = self.cachedView() 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 - } + hosting.rootView = view + hosting.invalidateIntrinsicContentSize() + 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.isMenuOpen = false self.loadTask?.cancel() } @@ -84,31 +101,49 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate { return NSRect.zero } - private func makeCardView() async -> AnyView { + private func refreshCache(force: Bool) async { + if !force, let cacheUpdatedAt, Date().timeIntervalSince(cacheUpdatedAt) < self.refreshIntervalSeconds { + return + } + + do { + let rows = try await self.loadCurrentRows() + self.cachedRows = rows + self.cacheErrorText = nil + self.cacheUpdatedAt = Date() + } catch { + if self.cachedRows.isEmpty { + self.cacheErrorText = "Could not load sessions" + } + self.cacheUpdatedAt = Date() + } + } + + private func cachedView() -> AnyView { + let rows = self.cachedRows + let isLoading = rows.isEmpty && self.cacheErrorText == nil + return AnyView(ContextMenuCardView(rows: rows, statusText: self.cacheErrorText, isLoading: isLoading)) + } + + private func loadCurrentRows() async throws -> [SessionRow] { 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 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")) + return current.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) } } diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index ea23abc0e..92106c6a5 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -257,7 +257,7 @@ enum SessionLoader { } static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { - try await Task.detached(priority: .utility) { + try await Task.detached(priority: .userInitiated) { guard FileManager.default.fileExists(atPath: path) else { throw SessionLoadError.missingStore(path) }