fix(mac): load menu session previews
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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