feat(macos): surface session activity in menu bar
This commit is contained in:
@@ -104,6 +104,10 @@ final class AppState: ObservableObject {
|
|||||||
didSet { UserDefaults.standard.set(self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) }
|
didSet { UserDefaults.standard.set(self.voicePushToTalkEnabled, forKey: voicePushToTalkEnabledKey) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var iconOverride: IconOverrideSelection {
|
||||||
|
didSet { UserDefaults.standard.set(self.iconOverride.rawValue, forKey: iconOverrideKey) }
|
||||||
|
}
|
||||||
|
|
||||||
@Published var isWorking: Bool = false
|
@Published var isWorking: Bool = false
|
||||||
@Published var earBoostActive: Bool = false
|
@Published var earBoostActive: Bool = false
|
||||||
@Published var blinkTick: Int = 0
|
@Published var blinkTick: Int = 0
|
||||||
@@ -190,6 +194,13 @@ final class AppState: ObservableObject {
|
|||||||
self.heartbeatsEnabled = true
|
self.heartbeatsEnabled = true
|
||||||
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
UserDefaults.standard.set(true, forKey: heartbeatsEnabledKey)
|
||||||
}
|
}
|
||||||
|
if let storedOverride = UserDefaults.standard.string(forKey: iconOverrideKey),
|
||||||
|
let selection = IconOverrideSelection(rawValue: storedOverride) {
|
||||||
|
self.iconOverride = selection
|
||||||
|
} else {
|
||||||
|
self.iconOverride = .system
|
||||||
|
UserDefaults.standard.set(IconOverrideSelection.system.rawValue, forKey: iconOverrideKey)
|
||||||
|
}
|
||||||
|
|
||||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||||
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
|
self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort"
|
|||||||
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
|
let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity"
|
||||||
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
|
let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand"
|
||||||
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled"
|
||||||
|
let iconOverrideKey = "clawdis.iconOverride"
|
||||||
let connectionModeKey = "clawdis.connectionMode"
|
let connectionModeKey = "clawdis.connectionMode"
|
||||||
let remoteTargetKey = "clawdis.remoteTarget"
|
let remoteTargetKey = "clawdis.remoteTarget"
|
||||||
let remoteIdentityKey = "clawdis.remoteIdentity"
|
let remoteIdentityKey = "clawdis.remoteIdentity"
|
||||||
|
|||||||
@@ -469,8 +469,24 @@ final class ControlChannel: ObservableObject {
|
|||||||
let working = self.jobStates.values.contains { workingStates.contains($0) }
|
let working = self.jobStates.values.contains { workingStates.contains($0) }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
AppStateStore.shared.setWorking(working)
|
AppStateStore.shared.setWorking(working)
|
||||||
|
WorkActivityStore.shared.handleJob(
|
||||||
|
sessionKey: event.runId,
|
||||||
|
state: state)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if event.stream == "tool" {
|
||||||
|
guard let phase = event.data["phase"]?.value as? String else { return }
|
||||||
|
let name = event.data["name"]?.value as? String
|
||||||
|
let meta = event.data["meta"]?.value as? String
|
||||||
|
let args = event.data["args"]?.value as? [String: AnyCodable]
|
||||||
|
Task { @MainActor in
|
||||||
|
WorkActivityStore.shared.handleTool(
|
||||||
|
sessionKey: event.runId,
|
||||||
|
phase: phase,
|
||||||
|
name: name,
|
||||||
|
meta: meta,
|
||||||
|
args: args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct CritterStatusLabel: View {
|
|||||||
var sendCelebrationTick: Int
|
var sendCelebrationTick: Int
|
||||||
var relayStatus: RelayProcessManager.Status
|
var relayStatus: RelayProcessManager.Status
|
||||||
var animationsEnabled: Bool
|
var animationsEnabled: Bool
|
||||||
|
var iconState: IconState
|
||||||
|
|
||||||
@State private var blinkAmount: CGFloat = 0
|
@State private var blinkAmount: CGFloat = 0
|
||||||
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
@State private var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5))
|
||||||
@@ -21,6 +22,10 @@ struct CritterStatusLabel: View {
|
|||||||
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
@State private var nextEarWiggle = Date().addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
private let ticker = Timer.publish(every: 0.35, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
|
private var isWorkingNow: Bool {
|
||||||
|
self.iconState.isWorking || self.isWorking
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Group {
|
Group {
|
||||||
@@ -30,7 +35,7 @@ struct CritterStatusLabel: View {
|
|||||||
} else {
|
} else {
|
||||||
Image(nsImage: CritterIconRenderer.makeIcon(
|
Image(nsImage: CritterIconRenderer.makeIcon(
|
||||||
blink: self.blinkAmount,
|
blink: self.blinkAmount,
|
||||||
legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0),
|
legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0),
|
||||||
earWiggle: self.earWiggle,
|
earWiggle: self.earWiggle,
|
||||||
earScale: self.earBoostActive ? 1.9 : 1.0,
|
earScale: self.earBoostActive ? 1.9 : 1.0,
|
||||||
earHoles: self.earBoostActive))
|
earHoles: self.earBoostActive))
|
||||||
@@ -63,7 +68,7 @@ struct CritterStatusLabel: View {
|
|||||||
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0))
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.isWorking {
|
if self.isWorkingNow {
|
||||||
self.scurry()
|
self.scurry()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,6 +104,21 @@ struct CritterStatusLabel: View {
|
|||||||
.frame(width: 8, height: 8)
|
.frame(width: 8, height: 8)
|
||||||
.offset(x: 4, y: 4)
|
.offset(x: 4, y: 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if case .idle = self.iconState {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
Text(self.iconState.glyph)
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.padding(3)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(self.iconState.tint.opacity(0.9))
|
||||||
|
)
|
||||||
|
.foregroundStyle(Color.white)
|
||||||
|
.offset(x: -4, y: -2)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import UniformTypeIdentifiers
|
|||||||
struct DebugSettings: View {
|
struct DebugSettings: View {
|
||||||
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
@AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath
|
||||||
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
@AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0
|
||||||
|
@AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue
|
||||||
@State private var modelsCount: Int?
|
@State private var modelsCount: Int?
|
||||||
@State private var modelsLoading = false
|
@State private var modelsLoading = false
|
||||||
@State private var modelsError: String?
|
@State private var modelsError: String?
|
||||||
@@ -26,6 +27,15 @@ struct DebugSettings: View {
|
|||||||
Text(self.healthStore.summaryLine)
|
Text(self.healthStore.summaryLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LabeledContent("Icon override") {
|
||||||
|
Picker("Icon override", selection: self.bindingOverride) {
|
||||||
|
ForEach(IconOverrideSelection.allCases) { option in
|
||||||
|
Text(option.label).tag(option.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(maxWidth: 280)
|
||||||
|
}
|
||||||
LabeledContent("CLI helper") {
|
LabeledContent("CLI helper") {
|
||||||
let loc = CLIInstaller.installedLocation()
|
let loc = CLIInstaller.installedLocation()
|
||||||
Text(loc ?? "missing")
|
Text(loc ?? "missing")
|
||||||
@@ -403,6 +413,20 @@ struct DebugSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var bindingOverride: Binding<String> {
|
||||||
|
Binding {
|
||||||
|
self.iconOverrideRaw
|
||||||
|
} set: { newValue in
|
||||||
|
self.iconOverrideRaw = newValue
|
||||||
|
if let selection = IconOverrideSelection(rawValue: newValue) {
|
||||||
|
Task { @MainActor in
|
||||||
|
AppStateStore.shared.iconOverride = selection
|
||||||
|
WorkActivityStore.shared.resolveIconState(override: selection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func configURL() -> URL {
|
private func configURL() -> URL {
|
||||||
FileManager.default.homeDirectoryForCurrentUser
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
.appendingPathComponent(".clawdis")
|
.appendingPathComponent(".clawdis")
|
||||||
|
|||||||
105
apps/macos/Sources/Clawdis/IconState.swift
Normal file
105
apps/macos/Sources/Clawdis/IconState.swift
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum SessionRole {
|
||||||
|
case main
|
||||||
|
case other
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ToolKind: String, Codable {
|
||||||
|
case bash, read, write, edit, attach, other
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActivityKind: Codable, Equatable {
|
||||||
|
case job
|
||||||
|
case tool(ToolKind)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IconState: Equatable {
|
||||||
|
case idle
|
||||||
|
case workingMain(ActivityKind)
|
||||||
|
case workingOther(ActivityKind)
|
||||||
|
case overridden(ActivityKind)
|
||||||
|
|
||||||
|
var glyph: String {
|
||||||
|
switch self.activity {
|
||||||
|
case .tool(.bash): return "💻"
|
||||||
|
case .tool(.read): return "📄"
|
||||||
|
case .tool(.write): return "✍️"
|
||||||
|
case .tool(.edit): return "📝"
|
||||||
|
case .tool(.attach): return "📎"
|
||||||
|
case .tool(.other), .job: return "🛠️"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var tint: Color {
|
||||||
|
switch self {
|
||||||
|
case .workingMain: return .accentColor
|
||||||
|
case .workingOther: return .gray
|
||||||
|
case .overridden: return .orange
|
||||||
|
case .idle: return .clear
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isWorking: Bool {
|
||||||
|
switch self {
|
||||||
|
case .idle: return false
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activity: ActivityKind {
|
||||||
|
switch self {
|
||||||
|
case let .workingMain(kind),
|
||||||
|
let .workingOther(kind),
|
||||||
|
let .overridden(kind):
|
||||||
|
return kind
|
||||||
|
case .idle:
|
||||||
|
return .job
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum IconOverrideSelection: String, CaseIterable, Identifiable {
|
||||||
|
case system
|
||||||
|
case idle
|
||||||
|
case mainBash, mainRead, mainWrite, mainEdit, mainOther
|
||||||
|
case otherBash, otherRead, otherWrite, otherEdit, otherOther
|
||||||
|
|
||||||
|
var id: String { self.rawValue }
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .system: return "System (auto)"
|
||||||
|
case .idle: return "Idle"
|
||||||
|
case .mainBash: return "Working main – bash"
|
||||||
|
case .mainRead: return "Working main – read"
|
||||||
|
case .mainWrite: return "Working main – write"
|
||||||
|
case .mainEdit: return "Working main – edit"
|
||||||
|
case .mainOther: return "Working main – other"
|
||||||
|
case .otherBash: return "Working other – bash"
|
||||||
|
case .otherRead: return "Working other – read"
|
||||||
|
case .otherWrite: return "Working other – write"
|
||||||
|
case .otherEdit: return "Working other – edit"
|
||||||
|
case .otherOther: return "Working other – other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toIconState() -> IconState {
|
||||||
|
let map: (ToolKind) -> ActivityKind = { .tool($0) }
|
||||||
|
switch self {
|
||||||
|
case .system: return .idle
|
||||||
|
case .idle: return .idle
|
||||||
|
case .mainBash: return .workingMain(map(.bash))
|
||||||
|
case .mainRead: return .workingMain(map(.read))
|
||||||
|
case .mainWrite: return .workingMain(map(.write))
|
||||||
|
case .mainEdit: return .workingMain(map(.edit))
|
||||||
|
case .mainOther: return .workingMain(map(.other))
|
||||||
|
case .otherBash: return .workingOther(map(.bash))
|
||||||
|
case .otherRead: return .workingOther(map(.read))
|
||||||
|
case .otherWrite: return .workingOther(map(.write))
|
||||||
|
case .otherEdit: return .workingOther(map(.edit))
|
||||||
|
case .otherOther: return .workingOther(map(.other))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ struct ClawdisApp: App {
|
|||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
@StateObject private var state: AppState
|
@StateObject private var state: AppState
|
||||||
@StateObject private var relayManager = RelayProcessManager.shared
|
@StateObject private var relayManager = RelayProcessManager.shared
|
||||||
|
@StateObject private var activityStore = WorkActivityStore.shared
|
||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
|
|
||||||
@@ -27,7 +28,8 @@ struct ClawdisApp: App {
|
|||||||
blinkTick: self.state.blinkTick,
|
blinkTick: self.state.blinkTick,
|
||||||
sendCelebrationTick: self.state.sendCelebrationTick,
|
sendCelebrationTick: self.state.sendCelebrationTick,
|
||||||
relayStatus: self.relayManager.status,
|
relayStatus: self.relayManager.status,
|
||||||
animationsEnabled: self.state.iconAnimationsEnabled)
|
animationsEnabled: self.state.iconAnimationsEnabled,
|
||||||
|
iconState: self.effectiveIconState)
|
||||||
}
|
}
|
||||||
.menuBarExtraStyle(.menu)
|
.menuBarExtraStyle(.menu)
|
||||||
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
.menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in
|
||||||
@@ -50,6 +52,20 @@ struct ClawdisApp: App {
|
|||||||
private func applyStatusItemAppearance(paused: Bool) {
|
private func applyStatusItemAppearance(paused: Bool) {
|
||||||
self.statusItem?.button?.appearsDisabled = paused
|
self.statusItem?.button?.appearsDisabled = paused
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var effectiveIconState: IconState {
|
||||||
|
let selection = self.state.iconOverride
|
||||||
|
if selection == .system {
|
||||||
|
return self.activityStore.iconState
|
||||||
|
}
|
||||||
|
let overrideState = selection.toIconState()
|
||||||
|
switch overrideState {
|
||||||
|
case let .workingMain(kind): return .overridden(kind)
|
||||||
|
case let .workingOther(kind): return .overridden(kind)
|
||||||
|
case .idle: return .idle
|
||||||
|
case let .overridden(kind): return .overridden(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSXPCListenerDelegate {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct MenuContent: View {
|
|||||||
@ObservedObject private var healthStore = HealthStore.shared
|
@ObservedObject private var healthStore = HealthStore.shared
|
||||||
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
@ObservedObject private var heartbeatStore = HeartbeatStore.shared
|
||||||
@ObservedObject private var controlChannel = ControlChannel.shared
|
@ObservedObject private var controlChannel = ControlChannel.shared
|
||||||
|
@ObservedObject private var activityStore = WorkActivityStore.shared
|
||||||
@Environment(\.openSettings) private var openSettings
|
@Environment(\.openSettings) private var openSettings
|
||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
@@ -64,44 +65,59 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var statusRow: some View {
|
private var statusRow: some View {
|
||||||
let health = self.healthStore.state
|
if let activity = self.activityStore.current {
|
||||||
let isRefreshing = self.healthStore.isRefreshing
|
let color: Color = activity.role == .main ? .accentColor : .gray
|
||||||
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
let roleLabel = activity.role == .main ? "Main" : "Other"
|
||||||
|
let text = "\(roleLabel) · \(activity.label)"
|
||||||
let label: String
|
return HStack(spacing: 8) {
|
||||||
let color: Color
|
Circle()
|
||||||
|
.fill(color)
|
||||||
if isRefreshing {
|
.frame(width: 8, height: 8)
|
||||||
label = "Health check running…"
|
Text(text)
|
||||||
color = health.tint
|
.font(.caption.weight(.semibold))
|
||||||
} else {
|
.foregroundStyle(.primary)
|
||||||
switch health {
|
|
||||||
case .ok:
|
|
||||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
|
||||||
label = "Health ok\(ageText)"
|
|
||||||
color = .green
|
|
||||||
case .linkingNeeded:
|
|
||||||
label = "Health: login required"
|
|
||||||
color = .red
|
|
||||||
case let .degraded(reason):
|
|
||||||
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
|
||||||
label = "Health degraded: \(reason)\(ageText)"
|
|
||||||
color = .orange
|
|
||||||
case .unknown:
|
|
||||||
label = "Health pending"
|
|
||||||
color = .secondary
|
|
||||||
}
|
}
|
||||||
}
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
let health = self.healthStore.state
|
||||||
|
let isRefreshing = self.healthStore.isRefreshing
|
||||||
|
let lastAge = self.healthStore.lastSuccess.map { age(from: $0) }
|
||||||
|
|
||||||
return HStack(spacing: 8) {
|
let label: String
|
||||||
Circle()
|
let color: Color
|
||||||
.fill(color)
|
|
||||||
.frame(width: 8, height: 8)
|
if isRefreshing {
|
||||||
Text(label)
|
label = "Health check running…"
|
||||||
.font(.caption.weight(.semibold))
|
color = health.tint
|
||||||
.foregroundStyle(.primary)
|
} else {
|
||||||
|
switch health {
|
||||||
|
case .ok:
|
||||||
|
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||||
|
label = "Health ok\(ageText)"
|
||||||
|
color = .green
|
||||||
|
case .linkingNeeded:
|
||||||
|
label = "Health: login required"
|
||||||
|
color = .red
|
||||||
|
case let .degraded(reason):
|
||||||
|
let ageText = lastAge.map { " · checked \($0)" } ?? ""
|
||||||
|
label = "Health degraded: \(reason)\(ageText)"
|
||||||
|
color = .orange
|
||||||
|
case .unknown:
|
||||||
|
label = "Health pending"
|
||||||
|
color = .secondary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var heartbeatStatusRow: some View {
|
private var heartbeatStatusRow: some View {
|
||||||
|
|||||||
195
apps/macos/Sources/Clawdis/WorkActivityStore.swift
Normal file
195
apps/macos/Sources/Clawdis/WorkActivityStore.swift
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class WorkActivityStore: ObservableObject {
|
||||||
|
static let shared = WorkActivityStore()
|
||||||
|
|
||||||
|
struct Activity: Equatable {
|
||||||
|
let sessionKey: String
|
||||||
|
let role: SessionRole
|
||||||
|
let kind: ActivityKind
|
||||||
|
let label: String
|
||||||
|
let startedAt: Date
|
||||||
|
var lastUpdate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
@Published private(set) var current: Activity?
|
||||||
|
@Published private(set) var iconState: IconState = .idle
|
||||||
|
|
||||||
|
private var active: [String: Activity] = [:]
|
||||||
|
private var currentSessionKey: String?
|
||||||
|
|
||||||
|
private let mainSessionKey = "main"
|
||||||
|
private let toolResultGrace: TimeInterval = 2.0
|
||||||
|
|
||||||
|
func handleJob(sessionKey: String, state: String) {
|
||||||
|
let isStart = state.lowercased() == "started" || state.lowercased() == "streaming"
|
||||||
|
if isStart {
|
||||||
|
let activity = Activity(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
role: self.role(for: sessionKey),
|
||||||
|
kind: .job,
|
||||||
|
label: "job",
|
||||||
|
startedAt: Date(),
|
||||||
|
lastUpdate: Date())
|
||||||
|
self.setActive(activity)
|
||||||
|
} else {
|
||||||
|
self.markIdle(sessionKey: sessionKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTool(
|
||||||
|
sessionKey: String,
|
||||||
|
phase: String,
|
||||||
|
name: String?,
|
||||||
|
meta: String?,
|
||||||
|
args: [String: AnyCodable]?
|
||||||
|
) {
|
||||||
|
let toolKind = Self.mapToolKind(name)
|
||||||
|
let label = Self.buildLabel(kind: toolKind, meta: meta, args: args)
|
||||||
|
if phase.lowercased() == "start" {
|
||||||
|
let activity = Activity(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
role: self.role(for: sessionKey),
|
||||||
|
kind: .tool(toolKind),
|
||||||
|
label: label,
|
||||||
|
startedAt: Date(),
|
||||||
|
lastUpdate: Date())
|
||||||
|
self.setActive(activity)
|
||||||
|
} else {
|
||||||
|
// Delay removal slightly to avoid flicker on rapid result/start bursts.
|
||||||
|
let key = sessionKey
|
||||||
|
Task { [weak self] in
|
||||||
|
let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000)
|
||||||
|
try? await Task.sleep(nanoseconds: nsDelay)
|
||||||
|
await MainActor.run {
|
||||||
|
self?.markIdle(sessionKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveIconState(override selection: IconOverrideSelection) {
|
||||||
|
switch selection {
|
||||||
|
case .system:
|
||||||
|
self.iconState = self.deriveIconState()
|
||||||
|
case .idle:
|
||||||
|
self.iconState = .idle
|
||||||
|
default:
|
||||||
|
let base = selection.toIconState()
|
||||||
|
switch base {
|
||||||
|
case let .workingMain(kind),
|
||||||
|
let .workingOther(kind):
|
||||||
|
self.iconState = .overridden(kind)
|
||||||
|
case let .overridden(kind):
|
||||||
|
self.iconState = .overridden(kind)
|
||||||
|
case .idle:
|
||||||
|
self.iconState = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setActive(_ activity: Activity) {
|
||||||
|
self.active[activity.sessionKey] = activity
|
||||||
|
// Main session preempts immediately.
|
||||||
|
if activity.role == .main {
|
||||||
|
self.currentSessionKey = activity.sessionKey
|
||||||
|
} else if self.currentSessionKey == nil || self.active[self.currentSessionKey!] == nil {
|
||||||
|
self.currentSessionKey = activity.sessionKey
|
||||||
|
}
|
||||||
|
self.current = self.active[self.currentSessionKey ?? ""]
|
||||||
|
self.iconState = self.deriveIconState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markIdle(sessionKey: String) {
|
||||||
|
guard let existing = self.active[sessionKey] else { return }
|
||||||
|
// Update timestamp so replacement prefers newer others.
|
||||||
|
var updated = existing
|
||||||
|
updated.lastUpdate = Date()
|
||||||
|
self.active[sessionKey] = updated
|
||||||
|
self.active.removeValue(forKey: sessionKey)
|
||||||
|
|
||||||
|
if self.currentSessionKey == sessionKey {
|
||||||
|
self.pickNextSession()
|
||||||
|
}
|
||||||
|
self.current = self.active[self.currentSessionKey ?? ""]
|
||||||
|
self.iconState = self.deriveIconState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pickNextSession() {
|
||||||
|
// Prefer main if present.
|
||||||
|
if let main = self.active[self.mainSessionKey] {
|
||||||
|
self.currentSessionKey = main.sessionKey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Otherwise, pick most recent by lastUpdate.
|
||||||
|
if let next = self.active.values.sorted(by: { $0.lastUpdate > $1.lastUpdate }).first {
|
||||||
|
self.currentSessionKey = next.sessionKey
|
||||||
|
} else {
|
||||||
|
self.currentSessionKey = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func role(for sessionKey: String) -> SessionRole {
|
||||||
|
sessionKey == self.mainSessionKey ? .main : .other
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deriveIconState() -> IconState {
|
||||||
|
guard let activity = self.current else { return .idle }
|
||||||
|
switch activity.role {
|
||||||
|
case .main: return .workingMain(activity.kind)
|
||||||
|
case .other: return .workingOther(activity.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapToolKind(_ name: String?) -> ToolKind {
|
||||||
|
switch name?.lowercased() {
|
||||||
|
case "bash", "shell": return .bash
|
||||||
|
case "read": return .read
|
||||||
|
case "write": return .write
|
||||||
|
case "edit": return .edit
|
||||||
|
case "attach": return .attach
|
||||||
|
default: return .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func buildLabel(
|
||||||
|
kind: ToolKind,
|
||||||
|
meta: String?,
|
||||||
|
args: [String: AnyCodable]?
|
||||||
|
) -> String {
|
||||||
|
switch kind {
|
||||||
|
case .bash:
|
||||||
|
if let cmd = args?["command"]?.value as? String {
|
||||||
|
return "bash: \(cmd.split(separator: "\n").first ?? "")"
|
||||||
|
}
|
||||||
|
return "bash"
|
||||||
|
case .read, .write, .edit, .attach:
|
||||||
|
if let path = Self.extractPath(args: args, meta: meta) {
|
||||||
|
return "\(kind.rawValue): \(path)"
|
||||||
|
}
|
||||||
|
return kind.rawValue
|
||||||
|
case .other:
|
||||||
|
if let name = args?["name"]?.value as? String {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "tool"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func extractPath(args: [String: AnyCodable]?, meta: String?) -> String? {
|
||||||
|
if let p = args?["path"]?.value as? String { return shortenHome(path: p) }
|
||||||
|
if let p = args?["file_path"]?.value as? String { return shortenHome(path: p) }
|
||||||
|
if let meta { return shortenHome(path: meta) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shortenHome(path: String) -> String {
|
||||||
|
let home = NSHomeDirectory()
|
||||||
|
if path.hasPrefix(home) {
|
||||||
|
return "~" + path.dropFirst(home.count)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
63
docs/mac/menu-bar.md
Normal file
63
docs/mac/menu-bar.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Menu Bar Status Logic
|
||||||
|
|
||||||
|
## What is shown
|
||||||
|
- We surface the current agent work state in the menu bar icon and in the first status row of the menu.
|
||||||
|
- Health status is hidden while work is active; it returns when all sessions are idle.
|
||||||
|
|
||||||
|
## State model
|
||||||
|
- Sessions: events arrive with `runId` (session key). The “main” session is the key `main`; if absent, we fall back to the most recently updated session.
|
||||||
|
- Priority: main always wins. If main is active, its state is shown immediately. If main is idle, the most recently active non‑main session is shown. We do not flip‑flop mid‑activity; we only switch when the current session goes idle or main becomes active.
|
||||||
|
- Activity kinds:
|
||||||
|
- `job`: high‑level command execution (`state: started|streaming|done|error`).
|
||||||
|
- `tool`: `phase: start|result` with `toolName` and `meta/args`.
|
||||||
|
|
||||||
|
## IconState enum (Swift)
|
||||||
|
- `idle`
|
||||||
|
- `workingMain(ActivityKind)`
|
||||||
|
- `workingOther(ActivityKind)`
|
||||||
|
- `overridden(ActivityKind)` (debug override)
|
||||||
|
|
||||||
|
### ActivityKind → glyph
|
||||||
|
- `bash` → 💻
|
||||||
|
- `read` → 📄
|
||||||
|
- `write` → ✍️
|
||||||
|
- `edit` → 📝
|
||||||
|
- `attach` → 📎
|
||||||
|
- default → 🛠️
|
||||||
|
|
||||||
|
### Visual mapping
|
||||||
|
- `idle`: normal critter.
|
||||||
|
- `workingMain`: badge with glyph, full tint, leg “working” animation.
|
||||||
|
- `workingOther`: badge with glyph, muted tint, no scurry.
|
||||||
|
- `overridden`: uses the chosen glyph/tint regardless of activity.
|
||||||
|
|
||||||
|
## Status row text (menu)
|
||||||
|
- While work is active: `<Session role> · <activity label>`
|
||||||
|
- Examples: `Main · bash: pnpm test`, `Other · read: apps/macos/Sources/Clawdis/AppState.swift`.
|
||||||
|
- When idle: falls back to the health summary.
|
||||||
|
|
||||||
|
## Event ingestion
|
||||||
|
- Source: control‑channel `agent` events (`ControlChannel.handleAgentEvent`).
|
||||||
|
- Parsed fields:
|
||||||
|
- `stream: "job"` with `data.state` for start/stop.
|
||||||
|
- `stream: "tool"` with `data.phase`, `name`, optional `meta`/`args`.
|
||||||
|
- Labels:
|
||||||
|
- `bash`: first line of `args.command`.
|
||||||
|
- `read`/`write`: shortened path.
|
||||||
|
- `edit`: path plus inferred change kind from `meta`/diff counts.
|
||||||
|
- fallback: tool name.
|
||||||
|
|
||||||
|
## Debug override
|
||||||
|
- Settings ▸ Debug ▸ “Icon override” picker:
|
||||||
|
- `System (auto)` (default)
|
||||||
|
- `Working: main` (per tool kind)
|
||||||
|
- `Working: other` (per tool kind)
|
||||||
|
- `Idle`
|
||||||
|
- Stored via `@AppStorage("iconOverride")`; mapped to `IconState.overridden`.
|
||||||
|
|
||||||
|
## Testing checklist
|
||||||
|
- Trigger main session job: verify icon switches immediately and status row shows main label.
|
||||||
|
- Trigger non‑main session job while main idle: icon/status shows non‑main; stays stable until it finishes.
|
||||||
|
- Start main while other active: icon flips to main instantly.
|
||||||
|
- Rapid tool bursts: ensure badge does not flicker (TTL grace on tool results).
|
||||||
|
- Health row reappears once all sessions idle.
|
||||||
Reference in New Issue
Block a user