fix(mac): load menu session previews
This commit is contained in:
@@ -7,6 +7,9 @@ Docs: https://docs.clawd.bot
|
||||
### Changes
|
||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
||||
|
||||
### Fixes
|
||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
||||
|
||||
## 2026.1.18-4
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
private weak var statusItem: NSStatusItem?
|
||||
private var loadTask: Task<Void, Never>?
|
||||
private var nodesLoadTask: Task<Void, Never>?
|
||||
private var previewTasks: [Task<Void, Never>] = []
|
||||
private var isMenuOpen = false
|
||||
private var lastKnownMenuWidth: CGFloat?
|
||||
private var menuOpenWidth: CGFloat?
|
||||
@@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
||||
self.menuOpenWidth = nil
|
||||
self.loadTask?.cancel()
|
||||
self.nodesLoadTask?.cancel()
|
||||
self.cancelPreviewTasks()
|
||||
}
|
||||
|
||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||
@@ -107,6 +109,7 @@ extension MenuSessionsInjector {
|
||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||
|
||||
private func inject(into menu: NSMenu) {
|
||||
self.cancelPreviewTasks()
|
||||
// Remove any previous injected items.
|
||||
for item in menu.items where item.tag == self.tag {
|
||||
menu.removeItem(item)
|
||||
@@ -454,15 +457,46 @@ extension MenuSessionsInjector {
|
||||
item.tag = self.tag
|
||||
item.isEnabled = false
|
||||
let view = AnyView(SessionMenuPreviewView(
|
||||
sessionKey: sessionKey,
|
||||
width: width,
|
||||
maxItems: 10,
|
||||
maxLines: maxLines,
|
||||
title: title))
|
||||
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||
title: title,
|
||||
items: [],
|
||||
status: .loading))
|
||||
let hosting = NSHostingView(rootView: view)
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
||||
item.view = hosting
|
||||
|
||||
let task = Task { [weak hosting] in
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
guard let hosting else { return }
|
||||
let nextView = AnyView(SessionMenuPreviewView(
|
||||
width: width,
|
||||
maxLines: maxLines,
|
||||
title: title,
|
||||
items: snapshot.items,
|
||||
status: snapshot.status))
|
||||
hosting.rootView = nextView
|
||||
hosting.invalidateIntrinsicContentSize()
|
||||
hosting.frame.size.width = max(1, width)
|
||||
let size = hosting.fittingSize
|
||||
hosting.frame.size.height = size.height
|
||||
}
|
||||
}
|
||||
self.previewTasks.append(task)
|
||||
return item
|
||||
}
|
||||
|
||||
private func cancelPreviewTasks() {
|
||||
for task in self.previewTasks {
|
||||
task.cancel()
|
||||
}
|
||||
self.previewTasks.removeAll()
|
||||
}
|
||||
|
||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||
let view = AnyView(
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
|
||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
|
||||
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||
struct SessionPreviewItem: Identifiable, Sendable {
|
||||
let id: String
|
||||
let role: PreviewRole
|
||||
let text: String
|
||||
}
|
||||
|
||||
private enum PreviewRole: String, Sendable {
|
||||
enum PreviewRole: String, Sendable {
|
||||
case user
|
||||
case assistant
|
||||
case tool
|
||||
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private actor SessionPreviewCache {
|
||||
actor SessionPreviewCache {
|
||||
static let shared = SessionPreviewCache()
|
||||
|
||||
private struct CacheEntry {
|
||||
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
|
||||
}
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
|
||||
let sessionKey: String
|
||||
let width: CGFloat
|
||||
let maxItems: Int
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
@State private var items: [SessionPreviewItem] = []
|
||||
@State private var status: LoadStatus = .loading
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
#if DEBUG
|
||||
extension SessionPreviewCache {
|
||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
||||
}
|
||||
|
||||
private enum LoadStatus: Equatable {
|
||||
func _testReset() {
|
||||
self.entries = [:]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SessionMenuPreviewSnapshot: Sendable {
|
||||
let items: [SessionPreviewItem]
|
||||
let status: SessionMenuPreviewView.LoadStatus
|
||||
}
|
||||
|
||||
struct SessionMenuPreviewView: View {
|
||||
let width: CGFloat
|
||||
let maxLines: Int
|
||||
let title: String
|
||||
let items: [SessionPreviewItem]
|
||||
let status: LoadStatus
|
||||
|
||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||
|
||||
enum LoadStatus: Equatable {
|
||||
case loading
|
||||
case ready
|
||||
case empty
|
||||
@@ -85,10 +93,6 @@ struct SessionMenuPreviewView: View {
|
||||
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||
}
|
||||
|
||||
private var previewLimit: Int {
|
||||
min(max(self.maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
@@ -123,9 +127,6 @@ struct SessionMenuPreviewView: View {
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 11)
|
||||
.frame(width: max(1, self.width), alignment: .leading)
|
||||
.task(id: self.sessionKey) {
|
||||
await self.loadPreview()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -157,55 +158,59 @@ struct SessionMenuPreviewView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPreview() async {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||
await MainActor.run {
|
||||
self.items = cached
|
||||
self.status = cached.isEmpty ? .empty : .ready
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.status = .loading
|
||||
enum SessionMenuPreviewLoader {
|
||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||
private static let previewTimeoutSeconds: Double = 4
|
||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
||||
|
||||
private struct PreviewTimeoutError: LocalizedError {
|
||||
var errorDescription: String? { "preview timeout" }
|
||||
}
|
||||
|
||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
||||
return Self.snapshot(from: cached)
|
||||
}
|
||||
|
||||
do {
|
||||
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
||||
let payload = try await AsyncTimeout.withTimeout(
|
||||
seconds: Self.previewTimeoutSeconds,
|
||||
seconds: self.previewTimeoutSeconds,
|
||||
onTimeout: { PreviewTimeoutError() },
|
||||
operation: {
|
||||
try await GatewayConnection.shared.chatHistory(
|
||||
sessionKey: self.sessionKey,
|
||||
limit: self.previewLimit,
|
||||
sessionKey: sessionKey,
|
||||
limit: self.previewLimit(for: maxItems),
|
||||
timeoutMs: timeoutMs)
|
||||
})
|
||||
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
self.items = built
|
||||
self.status = built.isEmpty ? .empty : .ready
|
||||
}
|
||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
||||
return Self.snapshot(from: built)
|
||||
} catch is CancellationError {
|
||||
return
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
||||
} catch {
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||
await MainActor.run {
|
||||
if let fallback {
|
||||
self.items = fallback
|
||||
self.status = fallback.isEmpty ? .empty : .ready
|
||||
} else {
|
||||
self.status = .error("Preview unavailable")
|
||||
}
|
||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
||||
if let fallback {
|
||||
return Self.snapshot(from: fallback)
|
||||
}
|
||||
let errorDescription = String(describing: error)
|
||||
Self.logger.warning(
|
||||
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
||||
"error=\(errorDescription, privacy: .public)")
|
||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
||||
}
|
||||
|
||||
private static func previewLimit(for maxItems: Int) -> Int {
|
||||
min(max(maxItems * 3, 20), 120)
|
||||
}
|
||||
|
||||
private static func previewItems(
|
||||
from payload: ClawdbotChatHistoryPayload,
|
||||
maxItems: Int) -> [SessionPreviewItem]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct SessionMenuPreviewTests {
|
||||
@Test func loaderReturnsCachedItems() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .ready)
|
||||
#expect(snapshot.items.count == 1)
|
||||
#expect(snapshot.items.first?.text == "Hi")
|
||||
}
|
||||
|
||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
||||
await SessionPreviewCache.shared._testReset()
|
||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
||||
|
||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
||||
#expect(snapshot.status == .empty)
|
||||
#expect(snapshot.items.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user