Files
clawdbot/apps/macos/Sources/Clawdis/SessionsSettings.swift
2025-12-09 18:04:11 +01:00

233 lines
7.9 KiB
Swift

import AppKit
import SwiftUI
@MainActor
struct SessionsSettings: View {
private let isPreview: Bool
@State private var rows: [SessionRow]
@State private var storePath: String = SessionLoader.defaultStorePath
@State private var lastLoaded: Date?
@State private var errorMessage: String?
@State private var loading = false
@State private var hasLoaded = false
init(rows: [SessionRow]? = nil, isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self._rows = State(initialValue: rows ?? [])
self.isPreview = isPreview
if isPreview {
self._lastLoaded = State(initialValue: Date())
self._hasLoaded = State(initialValue: true)
}
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
self.header
self.storeMetadata
Divider().padding(.vertical, 4)
self.content
Spacer()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
await self.refresh()
}
}
private var header: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Sessions")
.font(.title3.weight(.semibold))
Text("Peek at the stored conversation buckets the CLI reuses for context and rate limits.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
private var storeMetadata: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text("Session store")
.font(.callout.weight(.semibold))
if let lastLoaded {
Text("Updated \(relativeAge(from: lastLoaded))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
Text(self.storePath)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
HStack(spacing: 10) {
Button {
Task { await self.refresh() }
} label: {
Label(self.loading ? "Refreshing..." : "Refresh", systemImage: "arrow.clockwise")
.labelStyle(.titleAndIcon)
}
.disabled(self.loading)
.buttonStyle(.bordered)
.help("Refresh session store")
Button {
self.revealStore()
} label: {
Label("Reveal", systemImage: "folder")
.labelStyle(.titleAndIcon)
}
.disabled(!FileManager.default.fileExists(atPath: self.storePath))
if self.loading {
ProgressView().controlSize(.small)
}
}
if let errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
}
}
private var content: some View {
Group {
if self.rows.isEmpty, self.errorMessage == nil {
Text("No sessions yet. They appear after the first inbound message or heartbeat.")
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.top, 6)
} else {
Table(self.rows) {
TableColumn("Key") { row in
VStack(alignment: .leading, spacing: 4) {
Text(row.key)
.font(.body.weight(.semibold))
HStack(spacing: 6) {
if row.kind != .direct {
SessionKindBadge(kind: row.kind)
}
if !row.flagLabels.isEmpty {
ForEach(row.flagLabels, id: \.self) { flag in
Badge(text: flag)
}
}
}
}
}
.width(220)
TableColumn("Updated", value: \.ageText)
.width(70)
TableColumn("Tokens") { row in
Text(row.tokens.summary)
.font(.caption)
.foregroundStyle(.secondary)
}
.width(170)
TableColumn("Model") { row in
Text(row.model ?? "")
.font(.caption)
.foregroundStyle(.secondary)
}
.width(120)
TableColumn("Session ID") { row in
Text(row.sessionId ?? "")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
.tableStyle(.inset(alternatesRowBackgrounds: true))
.frame(maxHeight: .infinity, alignment: .top)
}
}
}
private func refresh() async {
guard !self.loading else { return }
guard !self.isPreview else { return }
self.loading = true
self.errorMessage = nil
let hints = SessionLoader.configHints()
let resolvedStore = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults(
model: hints.model ?? SessionLoader.fallbackModel,
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
do {
let newRows = try await SessionLoader.loadRows(at: resolvedStore, defaults: defaults)
self.rows = newRows
self.storePath = resolvedStore
self.lastLoaded = Date()
} catch {
self.rows = []
self.storePath = resolvedStore
self.errorMessage = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
}
self.loading = false
}
private func revealStore() {
let url = URL(fileURLWithPath: storePath)
if FileManager.default.fileExists(atPath: self.storePath) {
NSWorkspace.shared.activateFileViewerSelecting([url])
} else {
NSWorkspace.shared.open(url.deletingLastPathComponent())
}
}
}
private struct SessionKindBadge: View {
let kind: SessionKind
var body: some View {
Text(self.kind.label)
.font(.caption2.weight(.bold))
.padding(.horizontal, 7)
.padding(.vertical, 4)
.foregroundStyle(self.kind.tint)
.background(self.kind.tint.opacity(0.15))
.clipShape(Capsule())
}
}
private struct Badge: View {
let text: String
var body: some View {
Text(self.text)
.font(.caption2.weight(.semibold))
.padding(.horizontal, 6)
.padding(.vertical, 3)
.foregroundStyle(.secondary)
.background(Color.secondary.opacity(0.12))
.clipShape(Capsule())
}
}
#if DEBUG
struct SessionsSettings_Previews: PreviewProvider {
static var previews: some View {
SessionsSettings(rows: SessionRow.previewRows, isPreview: true)
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
}
}
#endif