fix(macos): improve session preview loading
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user