feat(mac): compact context sessions in menu

This commit is contained in:
Peter Steinberger
2025-12-13 00:39:12 +00:00
parent 7f4f01009b
commit 854f07d735

View File

@@ -16,8 +16,13 @@ struct MenuContent: View {
@State private var availableMics: [AudioInputDevice] = [] @State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false @State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = [] @State private var sessionMenu: [SessionRow] = []
@State private var mainSessionRow: SessionRow? @State private var contextSessions: [SessionRow] = []
@State private var mainSessionContextWidth: CGFloat = 0 @State private var contextActiveCount: Int = 0
@State private var contextCardWidth: CGFloat = 0
private let activeSessionWindowSeconds: TimeInterval = 24 * 60 * 60
private let contextCardPadding: CGFloat = 10
private let contextPillHeight: CGFloat = 16
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -26,7 +31,7 @@ struct MenuContent: View {
Text(label) Text(label)
} }
self.statusRow self.statusRow
self.mainSessionContextRow self.contextCardRow
Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") } Toggle(isOn: self.heartbeatsBinding) { Text("Send Heartbeats") }
self.heartbeatStatusRow self.heartbeatStatusRow
Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") } Toggle(isOn: self.voiceWakeBinding) { Text("Voice Wake") }
@@ -185,7 +190,7 @@ struct MenuContent: View {
} }
.task { .task {
await self.reloadSessionMenu() await self.reloadSessionMenu()
await self.reloadMainSessionRow() await self.reloadContextSessions()
} }
.task { .task {
VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled) VoicePushToTalkHotkey.shared.setEnabled(voiceWakeSupported && self.state.voicePushToTalkEnabled)
@@ -252,41 +257,118 @@ struct MenuContent: View {
} }
@ViewBuilder @ViewBuilder
private var mainSessionContextRow: some View { private var contextCardRow: some View {
if let row = self.mainSessionRow { Button(action: {}, label: {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) { HStack(alignment: .firstTextBaseline) {
Text("Context (\(row.key))") Text("Context")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Spacer() Spacer(minLength: 10)
Text(row.tokens.contextSummaryShort) Text(self.contextSubtitle)
.font(.caption.monospacedDigit()) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
ContextUsageBar(
usedTokens: row.tokens.total, VStack(alignment: .leading, spacing: 6) {
contextTokens: row.tokens.contextTokens, if self.contextSessions.isEmpty {
width: self.mainSessionContextWidth > 0 ? self.mainSessionContextWidth : nil) Text("No sessions yet")
} .font(.caption)
.padding(.vertical, 2) .foregroundStyle(.secondary)
.onWidthChange { width in } else {
let next = max(120, width) ForEach(self.contextSessions) { row in
if abs(next - self.mainSessionContextWidth) > 1 { self.contextSessionPill(row)
self.mainSessionContextWidth = next }
}
} }
} }
} else { .padding(self.contextCardPadding)
HStack(spacing: 8) { .background {
Text("Context (main)") RoundedRectangle(cornerRadius: 12, style: .continuous)
.font(.caption.weight(.semibold)) .fill(Color.white.opacity(0.04))
.foregroundStyle(.secondary) .overlay {
Spacer() RoundedRectangle(cornerRadius: 12, style: .continuous)
Text("") .strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
.font(.caption.monospacedDigit()) }
.foregroundStyle(.secondary) }
.onWidthChange { width in
// Keep a stable width; menu measurement can be noisy across opens.
let next = max(0, width)
if abs(next - self.contextCardWidth) > 1 {
self.contextCardWidth = next
}
}
})
.buttonStyle(.plain)
.disabled(true)
}
private var contextSubtitle: String {
let count = self.contextActiveCount
if count == 0 { return "Main session" }
if count == 1 { return "1 active session" }
return "\(count) active sessions"
}
private var contextPillWidth: CGFloat? {
let width = self.contextCardWidth
guard width > 0 else { return nil }
return max(1, width - (self.contextCardPadding * 2))
}
@ViewBuilder
private func contextSessionPill(_ row: SessionRow) -> some View {
let label = row.key
let summary = row.tokens.contextSummaryShort
Group {
if let width = self.contextPillWidth {
ZStack(alignment: .center) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: width,
height: self.contextPillHeight)
HStack(spacing: 8) {
Text(label)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(summary)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 1)
}
.frame(width: width, height: self.contextPillHeight)
} else {
ZStack(alignment: .center) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: nil,
height: self.contextPillHeight)
HStack(spacing: 8) {
Text(label)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(summary)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 1)
}
.frame(height: self.contextPillHeight)
} }
.padding(.vertical, 2)
} }
} }
@@ -441,7 +523,7 @@ struct MenuContent: View {
var id: String { self.uid } var id: String { self.uid }
} }
private func reloadMainSessionRow() async { private func reloadContextSessions() async {
let hints = SessionLoader.configHints() let hints = SessionLoader.configHints()
let store = SessionLoader.resolveStorePath(override: hints.storePath) let store = SessionLoader.resolveStorePath(override: hints.storePath)
let defaults = SessionDefaults( let defaults = SessionDefaults(
@@ -449,13 +531,31 @@ struct MenuContent: View {
contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens) contextTokens: hints.contextTokens ?? SessionLoader.fallbackContextTokens)
guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else { guard let rows = try? await SessionLoader.loadRows(at: store, defaults: defaults) else {
self.mainSessionRow = nil self.contextSessions = []
return return
} }
let preferred = WebChatManager.shared.preferredSessionKey()
self.mainSessionRow = let now = Date()
rows.first(where: { $0.key == "main" }) ?? let active = rows.filter { row in
rows.first(where: { $0.key == preferred }) ?? guard let updatedAt = row.updatedAt else { return false }
rows.first return now.timeIntervalSince(updatedAt) <= self.activeSessionWindowSeconds
}
let activeCount = active.count
let main = rows.first(where: { $0.key == "main" })
var merged = active
if let main, !merged.contains(where: { $0.key == "main" }) {
merged.insert(main, at: 0)
}
// Keep stable ordering: main first, then most recent.
let sorted = merged.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
self.contextSessions = sorted
self.contextActiveCount = activeCount
} }
} }