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
- 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

View File

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

View File

@@ -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]

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