From 8b473158455315e86ca168d2f75f0333f87d0f5b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 19:55:19 +0100 Subject: [PATCH] fix(macos): improve session preview loading --- CHANGELOG.md | 1 + .../Sources/Clawdis/GatewayConnection.swift | 14 ++++-- .../Clawdis/SessionMenuLabelView.swift | 8 ++-- .../Clawdis/SessionMenuPreviewView.swift | 45 ++++++++++++++++--- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 238a7f9c2..a5a54dc7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ - macOS: keep config writes on the main actor to satisfy Swift concurrency rules. - macOS menu: show multi-line gateway error details, add an always-visible gateway row, avoid duplicate gateway status rows, suppress transient `cancelled` device refresh errors, and auto-recover the control channel on disconnect. - macOS menu: show session last-used timestamps in the list and add recent-message previews in session submenus. +- macOS menu: tighten session row padding and time out session preview loading with cached fallback. - macOS: log health refresh failures and recovery to make gateway issues easier to diagnose. - macOS codesign: skip hardened runtime for ad-hoc signing and avoid empty options args (#70) — thanks @petter-b - macOS codesign: include camera entitlement so permission prompts work in the menu bar app. diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index b5ee499e7..55711487f 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -450,10 +450,18 @@ extension GatewayConnection { // MARK: - Chat - func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { - try await self.requestDecoded( + func chatHistory( + sessionKey: String, + limit: Int? = nil, + timeoutMs: Int? = nil) async throws -> ClawdisChatHistoryPayload + { + var params: [String: AnyCodable] = ["sessionKey": AnyCodable(sessionKey)] + if let limit { params["limit"] = AnyCodable(limit) } + let timeout = timeoutMs.map { Double($0) } + return try await self.requestDecoded( method: .chatHistory, - params: ["sessionKey": AnyCodable(sessionKey)]) + params: params, + timeoutMs: timeout) } func chatSend( diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift index 680c381f3..1cbeedd39 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -15,8 +15,8 @@ struct SessionMenuLabelView: View { let row: SessionRow let width: CGFloat @Environment(\.menuItemHighlighted) private var isHighlighted - private let paddingLeading: CGFloat = 26 - private let paddingTrailing: CGFloat = 18 + private let paddingLeading: CGFloat = 22 + private let paddingTrailing: CGFloat = 14 private let barHeight: CGFloat = 6 private var primaryTextColor: Color { @@ -35,7 +35,7 @@ struct SessionMenuLabelView: View { width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), height: self.barHeight) - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 2) { Text(self.row.label) .font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) .foregroundStyle(self.primaryTextColor) @@ -43,7 +43,7 @@ struct SessionMenuLabelView: View { .truncationMode(.middle) .layoutPriority(1) - Spacer(minLength: 8) + Spacer(minLength: 4) Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") .font(.caption.monospacedDigit()) diff --git a/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift b/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift index 5c80032ac..abc6bdcc7 100644 --- a/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift +++ b/apps/macos/Sources/Clawdis/SessionMenuPreviewView.swift @@ -1,5 +1,6 @@ import ClawdisChatUI import ClawdisKit +import OSLog import SwiftUI private struct SessionPreviewItem: Identifiable, Sendable { @@ -45,9 +46,16 @@ private actor SessionPreviewCache { func store(items: [SessionPreviewItem], for sessionKey: String) { self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date()) } + + func lastItems(for sessionKey: String) -> [SessionPreviewItem]? { + self.entries[sessionKey]?.items + } } struct SessionMenuPreviewView: View { + private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "SessionPreview") + private static let previewTimeoutSeconds: Double = 4 + let sessionKey: String let width: CGFloat let maxItems: Int @@ -58,6 +66,10 @@ struct SessionMenuPreviewView: View { @State private var items: [SessionPreviewItem] = [] @State private var status: LoadStatus = .loading + private struct PreviewTimeoutError: LocalizedError { + var errorDescription: String? { "preview timeout" } + } + private enum LoadStatus: Equatable { case loading case ready @@ -73,9 +85,13 @@ 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: 6) { + HStack(alignment: .firstTextBaseline, spacing: 4) { Text(self.title) .font(.caption.weight(.semibold)) .foregroundStyle(self.secondaryColor) @@ -104,8 +120,8 @@ struct SessionMenuPreviewView: View { } } .padding(.vertical, 6) - .padding(.leading, 18) - .padding(.trailing, 12) + .padding(.leading, 16) + .padding(.trailing, 11) .frame(width: max(1, self.width), alignment: .leading) .task(id: self.sessionKey) { await self.loadPreview() @@ -114,7 +130,7 @@ struct SessionMenuPreviewView: View { @ViewBuilder private func previewRow(_ item: SessionPreviewItem) -> some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 4) { Text(item.role.label) .font(.caption2.monospacedDigit()) .foregroundStyle(self.roleColor(item.role)) @@ -155,17 +171,34 @@ struct SessionMenuPreviewView: View { } do { - let payload = try await GatewayConnection.shared.chatHistory(sessionKey: self.sessionKey) + let timeoutMs = Int(Self.previewTimeoutSeconds * 1000) + let payload = try await AsyncTimeout.withTimeout( + seconds: Self.previewTimeoutSeconds, + onTimeout: { PreviewTimeoutError() }) { + try await GatewayConnection.shared.chatHistory( + sessionKey: self.sessionKey, + limit: self.previewLimit, + 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 } + } catch is CancellationError { + return } catch { + let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey) await MainActor.run { - self.status = .error("Preview unavailable") + if let fallback { + self.items = fallback + self.status = fallback.isEmpty ? .empty : .ready + } else { + self.status = .error("Preview unavailable") + } } + Self.logger.warning("Session preview failed session=\(self.sessionKey, privacy: .public) error=\(String(describing: error), privacy: .public)") } }