Files
clawdbot/apps/macos/Sources/Clawdbot/SessionMenuPreviewView.swift
2026-01-04 16:24:17 +01:00

305 lines
10 KiB
Swift

import ClawdbotChatUI
import ClawdbotKit
import OSLog
import SwiftUI
private struct SessionPreviewItem: Identifiable, Sendable {
let id: String
let role: PreviewRole
let text: String
}
private enum PreviewRole: String, Sendable {
case user
case assistant
case tool
case system
case other
var label: String {
switch self {
case .user: "User"
case .assistant: "Agent"
case .tool: "Tool"
case .system: "System"
case .other: "Other"
}
}
}
private actor SessionPreviewCache {
static let shared = SessionPreviewCache()
private struct CacheEntry {
let items: [SessionPreviewItem]
let updatedAt: Date
}
private var entries: [String: CacheEntry] = [:]
func cachedItems(for sessionKey: String, maxAge: TimeInterval) -> [SessionPreviewItem]? {
guard let entry = self.entries[sessionKey] else { return nil }
guard Date().timeIntervalSince(entry.updatedAt) < maxAge else { return nil }
return entry.items
}
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.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" }
}
private enum LoadStatus: Equatable {
case loading
case ready
case empty
case error(String)
}
private var primaryColor: Color {
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
}
private var secondaryColor: Color {
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) {
Text(self.title)
.font(.caption.weight(.semibold))
.foregroundStyle(self.secondaryColor)
Spacer(minLength: 8)
}
switch self.status {
case .loading:
Text("Loading preview…")
.font(.caption)
.foregroundStyle(self.secondaryColor)
case .empty:
Text("No recent messages")
.font(.caption)
.foregroundStyle(self.secondaryColor)
case let .error(message):
Text(message)
.font(.caption)
.foregroundStyle(self.secondaryColor)
case .ready:
VStack(alignment: .leading, spacing: 6) {
ForEach(self.items) { item in
self.previewRow(item)
}
}
}
}
.padding(.vertical, 6)
.padding(.leading, 16)
.padding(.trailing, 11)
.frame(width: max(1, self.width), alignment: .leading)
.task(id: self.sessionKey) {
await self.loadPreview()
}
}
@ViewBuilder
private func previewRow(_ item: SessionPreviewItem) -> some View {
HStack(alignment: .top, spacing: 4) {
Text(item.role.label)
.font(.caption2.monospacedDigit())
.foregroundStyle(self.roleColor(item.role))
.frame(width: 50, alignment: .leading)
Text(item.text)
.font(.caption)
.foregroundStyle(self.primaryColor)
.multilineTextAlignment(.leading)
.lineLimit(self.maxLines)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: true)
}
}
private func roleColor(_ role: PreviewRole) -> Color {
if self.isHighlighted { return Color(nsColor: .selectedMenuItemTextColor).opacity(0.9) }
switch role {
case .user: return .accentColor
case .assistant: return .secondary
case .tool: return .orange
case .system: return .gray
case .other: return .secondary
}
}
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
}
do {
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
let payload = try await AsyncTimeout.withTimeout(
seconds: Self.previewTimeoutSeconds,
onTimeout: { PreviewTimeoutError() },
operation: {
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 {
if let fallback {
self.items = fallback
self.status = fallback.isEmpty ? .empty : .ready
} else {
self.status = .error("Preview unavailable")
}
}
let errorDescription = String(describing: error)
Self.logger.warning(
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
"error=\(errorDescription, privacy: .public)")
}
}
private static func previewItems(
from payload: ClawdbotChatHistoryPayload,
maxItems: Int) -> [SessionPreviewItem]
{
let raw: [ClawdbotKit.AnyCodable] = payload.messages ?? []
let messages = self.decodeMessages(raw)
let built = messages.compactMap { message -> SessionPreviewItem? in
guard let text = self.previewText(for: message) else { return nil }
let isTool = self.isToolCall(message)
let role = self.previewRole(message.role, isTool: isTool)
let id = "\(message.timestamp ?? 0)-\(UUID().uuidString)"
return SessionPreviewItem(id: id, role: role, text: text)
}
let trimmed = built.suffix(maxItems)
return Array(trimmed.reversed())
}
private static func decodeMessages(_ raw: [ClawdbotKit.AnyCodable]) -> [ClawdbotChatMessage] {
raw.compactMap { item in
guard let data = try? JSONEncoder().encode(item) else { return nil }
return try? JSONDecoder().decode(ClawdbotChatMessage.self, from: data)
}
}
private static func previewRole(_ raw: String, isTool: Bool) -> PreviewRole {
if isTool { return .tool }
switch raw.lowercased() {
case "user": return .user
case "assistant": return .assistant
case "system": return .system
case "tool": return .tool
default: return .other
}
}
private static func previewText(for message: ClawdbotChatMessage) -> String? {
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !text.isEmpty { return text }
let toolNames = self.toolNames(for: message)
if !toolNames.isEmpty {
let shown = toolNames.prefix(2)
let overflow = toolNames.count - shown.count
var label = "call \(shown.joined(separator: ", "))"
if overflow > 0 { label += " +\(overflow)" }
return label
}
if let media = self.mediaSummary(for: message) {
return media
}
return nil
}
private static func isToolCall(_ message: ClawdbotChatMessage) -> Bool {
if message.toolName?.nonEmpty != nil { return true }
return message.content.contains { $0.name?.nonEmpty != nil || $0.type?.lowercased() == "toolcall" }
}
private static func toolNames(for message: ClawdbotChatMessage) -> [String] {
var names: [String] = []
for content in message.content {
if let name = content.name?.nonEmpty {
names.append(name)
}
}
if let toolName = message.toolName?.nonEmpty {
names.append(toolName)
}
return Self.dedupePreservingOrder(names)
}
private static func mediaSummary(for message: ClawdbotChatMessage) -> String? {
let types = message.content.compactMap { content -> String? in
let raw = content.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard let raw, !raw.isEmpty else { return nil }
if raw == "text" || raw == "toolcall" { return nil }
return raw
}
guard let first = types.first else { return nil }
return "[\(first)]"
}
private static func dedupePreservingOrder(_ values: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for value in values where !seen.contains(value) {
seen.insert(value)
result.append(value)
}
return result
}
}