macOS: prewarm context menu card

This commit is contained in:
Peter Steinberger
2025-12-13 03:42:36 +00:00
parent 4d674a3f17
commit a641250da6
3 changed files with 103 additions and 43 deletions

View File

@@ -5,15 +5,18 @@ import SwiftUI
struct ContextMenuCardView: View { struct ContextMenuCardView: View {
private let rows: [SessionRow] private let rows: [SessionRow]
private let statusText: String? private let statusText: String?
private let isLoading: Bool
private let padding: CGFloat = 10 private let padding: CGFloat = 10
private let barHeight: CGFloat = 3 private let barHeight: CGFloat = 3
init( init(
rows: [SessionRow], rows: [SessionRow],
statusText: String? = nil statusText: String? = nil,
isLoading: Bool = false
) { ) {
self.rows = rows self.rows = rows
self.statusText = statusText self.statusText = statusText
self.isLoading = isLoading
} }
var body: some View { var body: some View {
@@ -32,28 +35,27 @@ struct ContextMenuCardView: View {
Text(statusText) Text(statusText)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if self.rows.isEmpty { } else if self.rows.isEmpty, !self.isLoading {
Text("No active sessions") Text("No active sessions")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(self.rows) { row in if self.rows.isEmpty, self.isLoading {
self.sessionRow(row) ForEach(0..<2, id: \.self) { _ in
self.placeholderRow
}
} else {
ForEach(self.rows) { row in
self.sessionRow(row)
}
} }
} }
} }
} }
.padding(self.padding) .padding(self.padding)
.frame(minWidth: 300, maxWidth: .infinity, alignment: .leading) .frame(minWidth: 300, maxWidth: .infinity, alignment: .leading)
.background { .transaction { txn in txn.animation = nil }
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)
}
}
} }
private var subtitle: String { 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)
}
}
} }

View File

@@ -9,7 +9,13 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
private let fallbackCardWidth: CGFloat = 320 private let fallbackCardWidth: CGFloat = 320
private weak var originalDelegate: NSMenuDelegate? private weak var originalDelegate: NSMenuDelegate?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var warmTask: Task<Void, Never>?
private var cachedRows: [SessionRow] = []
private var cacheErrorText: String?
private var cacheUpdatedAt: Date?
private let activeWindowSeconds: TimeInterval = 24 * 60 * 60 private let activeWindowSeconds: TimeInterval = 24 * 60 * 60
private let refreshIntervalSeconds: TimeInterval = 15
private var isMenuOpen = false
func install(into statusItem: NSStatusItem) { func install(into statusItem: NSStatusItem) {
// SwiftUI owns the menu, but we can inject a custom NSMenuItem.view right before display. // 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 self.originalDelegate = menu.delegate
menu.delegate = self menu.delegate = self
} }
if self.warmTask == nil {
self.warmTask = Task { await self.refreshCache(force: true) }
}
} }
func menuWillOpen(_ menu: NSMenu) { func menuWillOpen(_ menu: NSMenu) {
self.originalDelegate?.menuWillOpen?(menu) self.originalDelegate?.menuWillOpen?(menu)
self.isMenuOpen = true
// Remove any previous injected card items. // Remove any previous injected card items.
for item in menu.items where item.tag == self.tag { for item in menu.items where item.tag == self.tag {
@@ -33,11 +44,16 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
self.loadTask?.cancel() self.loadTask?.cancel()
let placeholder = AnyView(ContextMenuCardView( let initialRows = self.cachedRows
rows: [], let initialIsLoading = initialRows.isEmpty
statusText: "Loading sessions…")) 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 let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: self.initialCardWidth(for: menu), height: size.height)) 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 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 { await MainActor.run {
hosting?.rootView = view hosting.rootView = view
hosting?.invalidateIntrinsicContentSize() hosting.invalidateIntrinsicContentSize()
if let hosting { self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting)
self.adoptMenuWidthIfAvailable(for: menu, hosting: hosting) let size = hosting.fittingSize
let size = hosting.fittingSize hosting.frame.size.height = size.height
hosting.frame.size.height = size.height
}
} }
} }
} }
func menuDidClose(_ menu: NSMenu) { func menuDidClose(_ menu: NSMenu) {
self.originalDelegate?.menuDidClose?(menu) self.originalDelegate?.menuDidClose?(menu)
self.isMenuOpen = false
self.loadTask?.cancel() self.loadTask?.cancel()
} }
@@ -84,31 +101,49 @@ final class MenuContextCardInjector: NSObject, NSMenuDelegate {
return NSRect.zero 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 hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath) let store = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults( let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel, model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
do { let loaded = try await SessionLoader.loadRows(at: store, defaults: defaults)
let loaded = try await SessionLoader.loadRows(at: store, defaults: defaults) let now = Date()
let now = Date() let current = loaded.filter { row in
let current = loaded.filter { row in if row.key == "main" { return true }
if row.key == "main" { return true } guard let updatedAt = row.updatedAt else { return false }
guard let updatedAt = row.updatedAt else { return false } return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds
return now.timeIntervalSince(updatedAt) <= self.activeWindowSeconds }
}
let sorted = current.sorted { lhs, rhs in return current.sorted { lhs, rhs in
if lhs.key == "main" { return true } if lhs.key == "main" { return true }
if rhs.key == "main" { return false } if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
return AnyView(ContextMenuCardView(rows: sorted))
} catch {
return AnyView(ContextMenuCardView(rows: [], statusText: "Could not load sessions"))
} }
} }

View File

@@ -257,7 +257,7 @@ enum SessionLoader {
} }
static func loadRows(at path: String, defaults: SessionDefaults) async throws -> [SessionRow] { 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 { guard FileManager.default.fileExists(atPath: path) else {
throw SessionLoadError.missingStore(path) throw SessionLoadError.missingStore(path)
} }