fix(mac): load menu session previews

This commit is contained in:
Peter Steinberger
2026-01-18 18:28:44 +00:00
parent ee2f0a175a
commit c0457e0cc4
4 changed files with 129 additions and 61 deletions

View File

@@ -7,6 +7,9 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - 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 ## 2026.1.18-4
### Changes ### Changes

View File

@@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
private weak var statusItem: NSStatusItem? private weak var statusItem: NSStatusItem?
private var loadTask: Task<Void, Never>? private var loadTask: Task<Void, Never>?
private var nodesLoadTask: Task<Void, Never>? private var nodesLoadTask: Task<Void, Never>?
private var previewTasks: [Task<Void, Never>] = []
private var isMenuOpen = false private var isMenuOpen = false
private var lastKnownMenuWidth: CGFloat? private var lastKnownMenuWidth: CGFloat?
private var menuOpenWidth: CGFloat? private var menuOpenWidth: CGFloat?
@@ -87,6 +88,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
self.menuOpenWidth = nil self.menuOpenWidth = nil
self.loadTask?.cancel() self.loadTask?.cancel()
self.nodesLoadTask?.cancel() self.nodesLoadTask?.cancel()
self.cancelPreviewTasks()
} }
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
@@ -107,6 +109,7 @@ extension MenuSessionsInjector {
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey } private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
private func inject(into menu: NSMenu) { private func inject(into menu: NSMenu) {
self.cancelPreviewTasks()
// Remove any previous injected items. // Remove any previous injected items.
for item in menu.items where item.tag == self.tag { for item in menu.items where item.tag == self.tag {
menu.removeItem(item) menu.removeItem(item)
@@ -454,15 +457,46 @@ extension MenuSessionsInjector {
item.tag = self.tag item.tag = self.tag
item.isEnabled = false item.isEnabled = false
let view = AnyView(SessionMenuPreviewView( let view = AnyView(SessionMenuPreviewView(
sessionKey: sessionKey,
width: width, width: width,
maxItems: 10,
maxLines: maxLines, maxLines: maxLines,
title: title)) title: title,
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false) 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 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 { private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
let view = AnyView( let view = AnyView(
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {

View File

@@ -3,13 +3,13 @@ import ClawdbotKit
import OSLog import OSLog
import SwiftUI import SwiftUI
private struct SessionPreviewItem: Identifiable, Sendable { struct SessionPreviewItem: Identifiable, Sendable {
let id: String let id: String
let role: PreviewRole let role: PreviewRole
let text: String let text: String
} }
private enum PreviewRole: String, Sendable { enum PreviewRole: String, Sendable {
case user case user
case assistant case assistant
case tool case tool
@@ -27,7 +27,7 @@ private enum PreviewRole: String, Sendable {
} }
} }
private actor SessionPreviewCache { actor SessionPreviewCache {
static let shared = SessionPreviewCache() static let shared = SessionPreviewCache()
private struct CacheEntry { private struct CacheEntry {
@@ -52,25 +52,33 @@ private actor SessionPreviewCache {
} }
} }
struct SessionMenuPreviewView: View { #if DEBUG
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview") extension SessionPreviewCache {
private static let previewTimeoutSeconds: Double = 4 func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
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" }
} }
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 loading
case ready case ready
case empty case empty
@@ -85,10 +93,6 @@ struct SessionMenuPreviewView: View {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
} }
private var previewLimit: Int {
min(max(self.maxItems * 3, 20), 120)
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
@@ -123,9 +127,6 @@ struct SessionMenuPreviewView: View {
.padding(.leading, 16) .padding(.leading, 16)
.padding(.trailing, 11) .padding(.trailing, 11)
.frame(width: max(1, self.width), alignment: .leading) .frame(width: max(1, self.width), alignment: .leading)
.task(id: self.sessionKey) {
await self.loadPreview()
}
} }
@ViewBuilder @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 { enum SessionMenuPreviewLoader {
self.status = .loading 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 { do {
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout( let payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds, seconds: self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() }, onTimeout: { PreviewTimeoutError() },
operation: { operation: {
try await GatewayConnection.shared.chatHistory( try await GatewayConnection.shared.chatHistory(
sessionKey: self.sessionKey, sessionKey: sessionKey,
limit: self.previewLimit, limit: self.previewLimit(for: maxItems),
timeoutMs: timeoutMs) timeoutMs: timeoutMs)
}) })
let built = Self.previewItems(from: payload, maxItems: self.maxItems) let built = Self.previewItems(from: payload, maxItems: maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) await SessionPreviewCache.shared.store(items: built, for: sessionKey)
await MainActor.run { return Self.snapshot(from: built)
self.items = built
self.status = built.isEmpty ? .empty : .ready
}
} catch is CancellationError { } catch is CancellationError {
return return SessionMenuPreviewSnapshot(items: [], status: .loading)
} catch { } catch {
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey) let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
await MainActor.run { if let fallback {
if let fallback { return Self.snapshot(from: fallback)
self.items = fallback
self.status = fallback.isEmpty ? .empty : .ready
} else {
self.status = .error("Preview unavailable")
}
} }
let errorDescription = String(describing: error) let errorDescription = String(describing: error)
Self.logger.warning( Self.logger.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) " + "Session preview failed session=\(sessionKey, privacy: .public) " +
"error=\(errorDescription, 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( private static func previewItems(
from payload: ClawdbotChatHistoryPayload, from payload: ClawdbotChatHistoryPayload,
maxItems: Int) -> [SessionPreviewItem] maxItems: Int) -> [SessionPreviewItem]

View File

@@ -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)
}
}