macOS: prewarm context menu card
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user