From 6b10f4241df305fb81b5d4a9195c520ee1b9d69f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 9 Dec 2025 01:28:16 +0100 Subject: [PATCH] feat(macos): surface session activity in menu bar --- apps/macos/Sources/Clawdis/AppState.swift | 11 + apps/macos/Sources/Clawdis/Constants.swift | 1 + .../Sources/Clawdis/ControlChannel.swift | 16 ++ .../Sources/Clawdis/CritterStatusLabel.swift | 24 ++- .../macos/Sources/Clawdis/DebugSettings.swift | 24 +++ apps/macos/Sources/Clawdis/IconState.swift | 105 ++++++++++ apps/macos/Sources/Clawdis/MenuBar.swift | 18 +- .../Sources/Clawdis/MenuContentView.swift | 86 ++++---- .../Sources/Clawdis/WorkActivityStore.swift | 195 ++++++++++++++++++ docs/mac/menu-bar.md | 63 ++++++ 10 files changed, 505 insertions(+), 38 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/IconState.swift create mode 100644 apps/macos/Sources/Clawdis/WorkActivityStore.swift create mode 100644 docs/mac/menu-bar.md diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index b723e1f7d..30c3885cf 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -104,6 +104,10 @@ final class AppState: ObservableObject { 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 earBoostActive: Bool = false @Published var blinkTick: Int = 0 @@ -190,6 +194,13 @@ final class AppState: ObservableObject { self.heartbeatsEnabled = true 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) self.connectionMode = ConnectionMode(rawValue: storedMode ?? "local") ?? .local diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index e703d3ee3..9b115f511 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -23,6 +23,7 @@ let voiceWakeForwardPortKey = "clawdis.voiceWakeForwardPort" let voiceWakeForwardIdentityKey = "clawdis.voiceWakeForwardIdentity" let voiceWakeForwardCommandKey = "clawdis.voiceWakeForwardCommand" let voicePushToTalkEnabledKey = "clawdis.voicePushToTalkEnabled" +let iconOverrideKey = "clawdis.iconOverride" let connectionModeKey = "clawdis.connectionMode" let remoteTargetKey = "clawdis.remoteTarget" let remoteIdentityKey = "clawdis.remoteIdentity" diff --git a/apps/macos/Sources/Clawdis/ControlChannel.swift b/apps/macos/Sources/Clawdis/ControlChannel.swift index 3aae36b3e..8d4aa0ac1 100644 --- a/apps/macos/Sources/Clawdis/ControlChannel.swift +++ b/apps/macos/Sources/Clawdis/ControlChannel.swift @@ -469,8 +469,24 @@ final class ControlChannel: ObservableObject { let working = self.jobStates.values.contains { workingStates.contains($0) } Task { @MainActor in 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) + } } } diff --git a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift index ffae26a37..64de26f66 100644 --- a/apps/macos/Sources/Clawdis/CritterStatusLabel.swift +++ b/apps/macos/Sources/Clawdis/CritterStatusLabel.swift @@ -9,6 +9,7 @@ struct CritterStatusLabel: View { var sendCelebrationTick: Int var relayStatus: RelayProcessManager.Status var animationsEnabled: Bool + var iconState: IconState @State private var blinkAmount: CGFloat = 0 @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)) 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 { ZStack(alignment: .bottomTrailing) { Group { @@ -30,7 +35,7 @@ struct CritterStatusLabel: View { } else { Image(nsImage: CritterIconRenderer.makeIcon( blink: self.blinkAmount, - legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0), + legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), earWiggle: self.earWiggle, earScale: self.earBoostActive ? 1.9 : 1.0, earHoles: self.earBoostActive)) @@ -63,7 +68,7 @@ struct CritterStatusLabel: View { self.nextEarWiggle = now.addingTimeInterval(Double.random(in: 7.0...14.0)) } - if self.isWorking { + if self.isWorkingNow { self.scurry() } } @@ -99,6 +104,21 @@ struct CritterStatusLabel: View { .frame(width: 8, height: 8) .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) + } } } diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index 826445dab..402325682 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -5,6 +5,7 @@ import UniformTypeIdentifiers struct DebugSettings: View { @AppStorage(modelCatalogPathKey) private var modelCatalogPath: String = ModelCatalogLoader.defaultPath @AppStorage(modelCatalogReloadKey) private var modelCatalogReloadBump: Int = 0 + @AppStorage(iconOverrideKey) private var iconOverrideRaw: String = IconOverrideSelection.system.rawValue @State private var modelsCount: Int? @State private var modelsLoading = false @State private var modelsError: String? @@ -26,6 +27,15 @@ struct DebugSettings: View { 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") { let loc = CLIInstaller.installedLocation() Text(loc ?? "missing") @@ -403,6 +413,20 @@ struct DebugSettings: View { } } + private var bindingOverride: Binding { + 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 { FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".clawdis") diff --git a/apps/macos/Sources/Clawdis/IconState.swift b/apps/macos/Sources/Clawdis/IconState.swift new file mode 100644 index 000000000..b2b80ca1f --- /dev/null +++ b/apps/macos/Sources/Clawdis/IconState.swift @@ -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)) + } + } +} diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 9beae3329..366d45eed 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -11,6 +11,7 @@ struct ClawdisApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate @StateObject private var state: AppState @StateObject private var relayManager = RelayProcessManager.shared + @StateObject private var activityStore = WorkActivityStore.shared @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false @@ -27,7 +28,8 @@ struct ClawdisApp: App { blinkTick: self.state.blinkTick, sendCelebrationTick: self.state.sendCelebrationTick, relayStatus: self.relayManager.status, - animationsEnabled: self.state.iconAnimationsEnabled) + animationsEnabled: self.state.iconAnimationsEnabled, + iconState: self.effectiveIconState) } .menuBarExtraStyle(.menu) .menuBarExtraAccess(isPresented: self.$isMenuPresented) { item in @@ -50,6 +52,20 @@ struct ClawdisApp: App { private func applyStatusItemAppearance(paused: Bool) { 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 { diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index 6cc256323..ed82294ef 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -11,6 +11,7 @@ struct MenuContent: View { @ObservedObject private var healthStore = HealthStore.shared @ObservedObject private var heartbeatStore = HeartbeatStore.shared @ObservedObject private var controlChannel = ControlChannel.shared + @ObservedObject private var activityStore = WorkActivityStore.shared @Environment(\.openSettings) private var openSettings @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false @@ -64,44 +65,59 @@ struct MenuContent: View { } private var statusRow: some View { - let health = self.healthStore.state - let isRefreshing = self.healthStore.isRefreshing - let lastAge = self.healthStore.lastSuccess.map { age(from: $0) } - - let label: String - let color: Color - - if isRefreshing { - label = "Health check running…" - color = health.tint - } 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 + if let activity = self.activityStore.current { + let color: Color = activity.role == .main ? .accentColor : .gray + let roleLabel = activity.role == .main ? "Main" : "Other" + let text = "\(roleLabel) · \(activity.label)" + return HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) } - } + .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) { - Circle() - .fill(color) - .frame(width: 8, height: 8) - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(.primary) + let label: String + let color: Color + + if isRefreshing { + label = "Health check running…" + color = health.tint + } 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 { diff --git a/apps/macos/Sources/Clawdis/WorkActivityStore.swift b/apps/macos/Sources/Clawdis/WorkActivityStore.swift new file mode 100644 index 000000000..8782b8f2e --- /dev/null +++ b/apps/macos/Sources/Clawdis/WorkActivityStore.swift @@ -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 + } +} diff --git a/docs/mac/menu-bar.md b/docs/mac/menu-bar.md new file mode 100644 index 000000000..c7ce265a5 --- /dev/null +++ b/docs/mac/menu-bar.md @@ -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: ` · ` + - 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.