feat(mac): sessions submenus

This commit is contained in:
Peter Steinberger
2025-12-22 19:29:24 +01:00
parent 19b847b23b
commit a0dd504991
9 changed files with 1034 additions and 74 deletions

View File

@@ -15,7 +15,6 @@ struct ClawdisApp: App {
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@State private var menuInjector = MenuContextCardInjector.shared
@State private var tailscaleService = TailscaleService.shared
@MainActor
@@ -49,7 +48,6 @@ struct ClawdisApp: App {
self.statusItem = item
self.applyStatusItemAppearance(paused: self.state.isPaused)
self.installStatusItemMouseHandler(for: item)
self.menuInjector.install(into: item)
self.updateHoverHUDSuppression()
}
.onChange(of: self.state.isPaused) { _, paused in

View File

@@ -17,7 +17,10 @@ struct MenuContent: View {
@State private var availableMics: [AudioInputDevice] = []
@State private var loadingMics = false
@State private var sessionMenu: [SessionRow] = []
@State private var sessionStorePath: String?
@State private var browserControlEnabled = true
private let sessionMenuItemWidth: CGFloat = 320
private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -28,6 +31,7 @@ struct MenuContent: View {
}
}
.disabled(self.state.connectionMode == .unconfigured)
self.sessionsSection
Divider()
Toggle(isOn: self.heartbeatsBinding) {
VStack(alignment: .leading, spacing: 2) {
@@ -125,55 +129,6 @@ struct MenuContent: View {
private var debugMenu: some View {
if self.state.debugPaneEnabled {
Menu("Debug") {
Menu {
ForEach(self.sessionMenu) { row in
Menu(row.key) {
Menu("Thinking") {
ForEach(["low", "medium", "high", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: normalized,
verbose: row.verboseLevel)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Menu("Verbose") {
ForEach(["on", "off", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
try? await DebugActions.updateSession(
key: row.key,
thinking: row.thinkingLevel,
verbose: normalized)
await self.reloadSessionMenu()
}
} label: {
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Button {
DebugActions.openSessionStoreInCode()
} label: {
Label("Open Session Log", systemImage: "doc.text")
}
}
}
Divider()
} label: {
Label("Sessions", systemImage: "clock.arrow.circlepath")
}
Divider()
Button {
DebugActions.openConfigFolder()
} label: {
@@ -239,6 +194,201 @@ struct MenuContent: View {
}
}
private var sessionsSection: some View {
Group {
Divider()
if self.sessionMenu.isEmpty {
Text("No active sessions")
.font(.caption)
.foregroundStyle(.secondary)
.disabled(true)
} else {
ForEach(self.sessionMenu) { row in
Menu {
self.sessionSubmenu(for: row)
} label: {
MenuHostedItem(
width: self.sessionMenuItemWidth,
rootView: AnyView(SessionMenuLabelView(row: row)))
}
}
}
Button {
Task { @MainActor in
guard let key = SessionActions.promptForSessionKey() else { return }
do {
try await SessionActions.createSession(key: key)
await self.reloadSessionMenu()
} catch {
SessionActions.presentError(title: "Create session failed", error: error)
}
}
} label: {
Label("New Session…", systemImage: "plus.circle")
}
}
}
@ViewBuilder
private func sessionSubmenu(for row: SessionRow) -> some View {
Menu("Syncing") {
ForEach(["on", "off", "default"], id: \.self) { option in
Button {
Task {
do {
let value: SessionSyncingValue? = switch option {
case "on": .bool(true)
case "off": .bool(false)
default: nil
}
try await SessionActions.patchSession(key: row.key, syncing: .some(value))
await self.reloadSessionMenu()
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update syncing failed", error: error)
}
}
}
} label: {
let normalized: SessionSyncingValue? = switch option {
case "on": .bool(true)
case "off": .bool(false)
default: nil
}
let isSelected: Bool = {
switch normalized {
case .none:
row.syncing == nil
case let .some(value):
switch value {
case .bool(true):
row.syncing?.isOn == true
case .bool(false):
row.syncing?.isOff == true
case let .string(v):
row.syncing?.label == v
}
}
}()
Label(option.capitalized, systemImage: isSelected ? "checkmark" : "")
}
}
}
Menu("Thinking") {
ForEach(["off", "minimal", "low", "medium", "high", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
do {
try await SessionActions.patchSession(key: row.key, thinking: .some(normalized))
await self.reloadSessionMenu()
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update thinking failed", error: error)
}
}
}
} label: {
let checkmark = row.thinkingLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
Menu("Verbose") {
ForEach(["on", "off", "default"], id: \.self) { level in
let normalized = level == "default" ? nil : level
Button {
Task {
do {
try await SessionActions.patchSession(key: row.key, verbose: .some(normalized))
await self.reloadSessionMenu()
} catch {
await MainActor.run {
SessionActions.presentError(title: "Update verbose failed", error: error)
}
}
}
} label: {
let checkmark = row.verboseLevel == normalized ? "checkmark" : ""
Label(level.capitalized, systemImage: checkmark)
}
}
}
if self.state.debugPaneEnabled, self.state.connectionMode == .local, let sessionId = row.sessionId, !sessionId.isEmpty {
Button {
SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: self.sessionStorePath)
} label: {
Label("Open Session Log", systemImage: "doc.text")
}
}
Divider()
Button {
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Reset session?",
message: "Starts a new session id for “\(row.key)”.",
action: "Reset")
else { return }
do {
try await SessionActions.resetSession(key: row.key)
await self.reloadSessionMenu()
} catch {
SessionActions.presentError(title: "Reset failed", error: error)
}
}
} label: {
Label("Reset Session", systemImage: "arrow.counterclockwise")
}
Button {
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Compact session log?",
message: "Keeps the last 400 lines; archives the old file.",
action: "Compact")
else { return }
do {
try await SessionActions.compactSession(key: row.key, maxLines: 400)
await self.reloadSessionMenu()
} catch {
SessionActions.presentError(title: "Compact failed", error: error)
}
}
} label: {
Label("Compact Session Log", systemImage: "scissors")
}
if row.key != "main" {
Button(role: .destructive) {
Task { @MainActor in
guard SessionActions.confirmDestructiveAction(
title: "Delete session?",
message: "Deletes the “\(row.key)” entry and archives its transcript.",
action: "Delete")
else { return }
do {
try await SessionActions.deleteSession(key: row.key)
await self.reloadSessionMenu()
} catch {
SessionActions.presentError(title: "Delete failed", error: error)
}
}
} label: {
Label("Delete Session", systemImage: "trash")
}
}
}
private func open(tab: SettingsTab) {
SettingsTabRouter.request(tab)
NSApp.activate(ignoringOtherApps: true)
@@ -427,7 +577,24 @@ struct MenuContent: View {
@MainActor
private func reloadSessionMenu() async {
self.sessionMenu = await DebugActions.recentSessions()
do {
let snapshot = try await SessionLoader.loadSnapshot(limit: 32)
self.sessionStorePath = snapshot.storePath
let now = Date()
let active = snapshot.rows.filter { row in
if row.key == "main" { return true }
guard let updatedAt = row.updatedAt else { return false }
return now.timeIntervalSince(updatedAt) <= self.sessionMenuActiveWindowSeconds
}
self.sessionMenu = active.sorted { lhs, rhs in
if lhs.key == "main" { return true }
if rhs.key == "main" { return false }
return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast)
}
} catch {
self.sessionStorePath = nil
self.sessionMenu = []
}
}
@MainActor

View File

@@ -0,0 +1,133 @@
import AppKit
import Foundation
enum SessionActions {
static func patchSession(
key: String,
thinking: String?? = nil,
verbose: String?? = nil,
syncing: SessionSyncingValue?? = nil) async throws
{
var params: [String: AnyHashable] = ["key": AnyHashable(key)]
if let thinking {
params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull())
}
if let verbose {
params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull())
}
if let syncing {
let payload: AnyHashable = {
switch syncing {
case .none:
AnyHashable(NSNull())
case let .some(value):
switch value {
case let .bool(v): AnyHashable(v)
case let .string(v): AnyHashable(v)
}
}
}()
params["syncing"] = payload
}
_ = try await ControlChannel.shared.request(method: "sessions.patch", params: params)
}
static func createSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.patch",
params: ["key": AnyHashable(key)])
}
static func resetSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.reset",
params: ["key": AnyHashable(key)])
}
static func deleteSession(key: String) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.delete",
params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)])
}
static func compactSession(key: String, maxLines: Int = 400) async throws {
_ = try await ControlChannel.shared.request(
method: "sessions.compact",
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)])
}
@MainActor
static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: action)
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
return alert.runModal() == .alertFirstButtonReturn
}
@MainActor
static func promptForSessionKey() -> String? {
let alert = NSAlert()
alert.messageText = "New Session"
alert.informativeText = "Create a new session key (e.g. \"main\", \"group:dev\", \"scratch\")."
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24))
field.placeholderString = "session key"
alert.accessoryView = field
alert.addButton(withTitle: "Create")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .informational
let result = alert.runModal()
guard result == .alertFirstButtonReturn else { return nil }
let trimmed = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
@MainActor
static func presentError(title: String, error: Error) {
let alert = NSAlert()
alert.messageText = title
alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
alert.addButton(withTitle: "OK")
alert.alertStyle = .warning
alert.runModal()
}
@MainActor
static func openSessionLogInCode(sessionId: String, storePath: String?) {
let candidates: [URL] = {
var urls: [URL] = []
if let storePath, !storePath.isEmpty {
let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent()
urls.append(dir.appendingPathComponent("\(sessionId).jsonl"))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl"))
return urls
}()
let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
guard let url = existing else {
let alert = NSAlert()
alert.messageText = "Session log not found"
alert.informativeText = sessionId
alert.runModal()
return
}
let proc = Process()
proc.launchPath = "/usr/bin/env"
proc.arguments = ["code", url.path]
if (try? proc.run()) != nil {
return
}
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}

View File

@@ -1,6 +1,65 @@
import Foundation
import SwiftUI
enum SessionSyncingValue: Codable, Equatable {
case bool(Bool)
case string(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(Bool.self) {
self = .bool(value)
return
}
if let value = try? container.decode(String.self) {
self = .string(value)
return
}
throw DecodingError.typeMismatch(
SessionSyncingValue.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected Bool or String"))
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .bool(value):
try container.encode(value)
case let .string(value):
try container.encode(value)
}
}
var isOn: Bool {
switch self {
case let .bool(value):
value
case let .string(value):
value.lowercased() == "on"
}
}
var isOff: Bool {
switch self {
case let .bool(value):
!value
case let .string(value):
value.lowercased() == "off"
}
}
var label: String {
switch self {
case let .bool(value):
value ? "on" : "off"
case let .string(value):
value
}
}
}
struct GatewaySessionDefaultsRecord: Codable {
let model: String?
let contextTokens: Int?
@@ -14,6 +73,7 @@ struct GatewaySessionEntryRecord: Codable {
let abortedLastRun: Bool?
let thinkingLevel: String?
let verboseLevel: String?
let syncing: SessionSyncingValue?
let inputTokens: Int?
let outputTokens: Int?
let totalTokens: Int?
@@ -69,6 +129,7 @@ struct SessionRow: Identifiable {
let sessionId: String?
let thinkingLevel: String?
let verboseLevel: String?
let syncing: SessionSyncingValue?
let systemSent: Bool
let abortedLastRun: Bool
let tokens: SessionTokenStats
@@ -80,6 +141,13 @@ struct SessionRow: Identifiable {
var flags: [String] = []
if let thinkingLevel { flags.append("think \(thinkingLevel)") }
if let verboseLevel { flags.append("verbose \(verboseLevel)") }
if let syncing {
if syncing.isOn {
flags.append("syncing")
} else if !syncing.label.isEmpty {
flags.append("sync \(syncing.label)")
}
}
if self.systemSent { flags.append("system sent") }
if self.abortedLastRun { flags.append("aborted") }
return flags
@@ -131,6 +199,7 @@ extension SessionRow {
sessionId: "sess-direct-1234",
thinkingLevel: "low",
verboseLevel: "info",
syncing: .bool(true),
systemSent: false,
abortedLastRun: false,
tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000),
@@ -143,6 +212,7 @@ extension SessionRow {
sessionId: "sess-group-4321",
thinkingLevel: "medium",
verboseLevel: nil,
syncing: nil,
systemSent: true,
abortedLastRun: true,
tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000),
@@ -155,6 +225,7 @@ extension SessionRow {
sessionId: nil,
thinkingLevel: nil,
verboseLevel: nil,
syncing: nil,
systemSent: false,
abortedLastRun: false,
tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000),
@@ -273,6 +344,7 @@ enum SessionLoader {
sessionId: entry.sessionId,
thinkingLevel: entry.thinkingLevel,
verboseLevel: entry.verboseLevel,
syncing: entry.syncing,
systemSent: entry.systemSent ?? false,
abortedLastRun: entry.abortedLastRun ?? false,
tokens: SessionTokenStats(

View File

@@ -0,0 +1,35 @@
import SwiftUI
struct SessionMenuLabelView: View {
let row: SessionRow
var body: some View {
VStack(alignment: .leading, spacing: 5) {
ContextUsageBar(
usedTokens: row.tokens.total,
contextTokens: row.tokens.contextTokens,
width: nil,
height: 3)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(row.key)
.font(.caption.weight(row.key == "main" ? .semibold : .regular))
.lineLimit(1)
.truncationMode(.middle)
.layoutPriority(1)
Spacer(minLength: 8)
Text(row.tokens.contextSummaryShort)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 6)
}
}