feat(mac): compact context sessions in menu
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user