fix(macos): improve session preview loading

This commit is contained in:
Peter Steinberger
2026-01-02 19:55:19 +01:00
parent 49e89cf3f1
commit 8b47315845
4 changed files with 55 additions and 13 deletions

View File

@@ -74,6 +74,7 @@
- macOS: keep config writes on the main actor to satisfy Swift concurrency rules. - 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 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: 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: 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: 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. - macOS codesign: include camera entitlement so permission prompts work in the menu bar app.

View File

@@ -450,10 +450,18 @@ extension GatewayConnection {
// MARK: - Chat // MARK: - Chat
func chatHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload { func chatHistory(
try await self.requestDecoded( 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, method: .chatHistory,
params: ["sessionKey": AnyCodable(sessionKey)]) params: params,
timeoutMs: timeout)
} }
func chatSend( func chatSend(

View File

@@ -15,8 +15,8 @@ struct SessionMenuLabelView: View {
let row: SessionRow let row: SessionRow
let width: CGFloat let width: CGFloat
@Environment(\.menuItemHighlighted) private var isHighlighted @Environment(\.menuItemHighlighted) private var isHighlighted
private let paddingLeading: CGFloat = 26 private let paddingLeading: CGFloat = 22
private let paddingTrailing: CGFloat = 18 private let paddingTrailing: CGFloat = 14
private let barHeight: CGFloat = 6 private let barHeight: CGFloat = 6
private var primaryTextColor: Color { private var primaryTextColor: Color {
@@ -35,7 +35,7 @@ struct SessionMenuLabelView: View {
width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)), width: max(1, self.width - (self.paddingLeading + self.paddingTrailing)),
height: self.barHeight) height: self.barHeight)
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 2) {
Text(self.row.label) Text(self.row.label)
.font(.caption.weight(self.row.key == "main" ? .semibold : .regular)) .font(.caption.weight(self.row.key == "main" ? .semibold : .regular))
.foregroundStyle(self.primaryTextColor) .foregroundStyle(self.primaryTextColor)
@@ -43,7 +43,7 @@ struct SessionMenuLabelView: View {
.truncationMode(.middle) .truncationMode(.middle)
.layoutPriority(1) .layoutPriority(1)
Spacer(minLength: 8) Spacer(minLength: 4)
Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)") Text("\(self.row.tokens.contextSummaryShort) · \(self.row.ageText)")
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())

View File

@@ -1,5 +1,6 @@
import ClawdisChatUI import ClawdisChatUI
import ClawdisKit import ClawdisKit
import OSLog
import SwiftUI import SwiftUI
private struct SessionPreviewItem: Identifiable, Sendable { private struct SessionPreviewItem: Identifiable, Sendable {
@@ -45,9 +46,16 @@ private actor SessionPreviewCache {
func store(items: [SessionPreviewItem], for sessionKey: String) { func store(items: [SessionPreviewItem], for sessionKey: String) {
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date()) self.entries[sessionKey] = CacheEntry(items: items, updatedAt: Date())
} }
func lastItems(for sessionKey: String) -> [SessionPreviewItem]? {
self.entries[sessionKey]?.items
}
} }
struct SessionMenuPreviewView: View { struct SessionMenuPreviewView: View {
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "SessionPreview")
private static let previewTimeoutSeconds: Double = 4
let sessionKey: String let sessionKey: String
let width: CGFloat let width: CGFloat
let maxItems: Int let maxItems: Int
@@ -58,6 +66,10 @@ struct SessionMenuPreviewView: View {
@State private var items: [SessionPreviewItem] = [] @State private var items: [SessionPreviewItem] = []
@State private var status: LoadStatus = .loading @State private var status: LoadStatus = .loading
private struct PreviewTimeoutError: LocalizedError {
var errorDescription: String? { "preview timeout" }
}
private enum LoadStatus: Equatable { private enum LoadStatus: Equatable {
case loading case loading
case ready case ready
@@ -73,9 +85,13 @@ 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: 6) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(self.title) Text(self.title)
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryColor) .foregroundStyle(self.secondaryColor)
@@ -104,8 +120,8 @@ struct SessionMenuPreviewView: View {
} }
} }
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.leading, 18) .padding(.leading, 16)
.padding(.trailing, 12) .padding(.trailing, 11)
.frame(width: max(1, self.width), alignment: .leading) .frame(width: max(1, self.width), alignment: .leading)
.task(id: self.sessionKey) { .task(id: self.sessionKey) {
await self.loadPreview() await self.loadPreview()
@@ -114,7 +130,7 @@ struct SessionMenuPreviewView: View {
@ViewBuilder @ViewBuilder
private func previewRow(_ item: SessionPreviewItem) -> some View { private func previewRow(_ item: SessionPreviewItem) -> some View {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 4) {
Text(item.role.label) Text(item.role.label)
.font(.caption2.monospacedDigit()) .font(.caption2.monospacedDigit())
.foregroundStyle(self.roleColor(item.role)) .foregroundStyle(self.roleColor(item.role))
@@ -155,17 +171,34 @@ struct SessionMenuPreviewView: View {
} }
do { 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) let built = Self.previewItems(from: payload, maxItems: self.maxItems)
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey) await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
await MainActor.run { await MainActor.run {
self.items = built self.items = built
self.status = built.isEmpty ? .empty : .ready self.status = built.isEmpty ? .empty : .ready
} }
} catch is CancellationError {
return
} catch { } catch {
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
await MainActor.run { 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)")
} }
} }