diff --git a/CHANGELOG.md b/CHANGELOG.md index b2ec22245..491b57792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift index 4ceb619c8..fd1727c00 100644 --- a/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift +++ b/apps/macos/Sources/Clawdbot/MenuSessionsInjector.swift @@ -14,6 +14,7 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate { private weak var statusItem: NSStatusItem? private var loadTask: Task? private var nodesLoadTask: Task? + private var previewTasks: [Task] = [] 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) { diff --git a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift index 76bf271fd..497e54a5c 100644 --- a/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift +++ b/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift @@ -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] diff --git a/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift new file mode 100644 index 000000000..b1d7b462c --- /dev/null +++ b/apps/macos/Tests/ClawdbotIPCTests/SessionMenuPreviewTests.swift @@ -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) + } +}