Files
Peter Steinberger b8a6ea12cc refactor(macos): remove the custom menu bar hover card (#118116)
* refactor(macos): remove the custom menu bar hover card

The status item showed two hover affordances at once: the native AppKit
tooltip and a custom SwiftUI panel rendering an "Idle / No recent activity"
card. Keep the native tooltip and drop the custom HUD.

The HUD was the only consumer of the status item's hover tracking, so the
NSTrackingArea and onHoverChanged plumbing in StatusItemMouseRouter goes
with it; click routing is unchanged. WorkActivityStore.lastToolLabel had no
other reader either.

* chore(i18n): refresh native i18n inventory after hover card removal

Deleting HoverHUD.swift shifted the MenuBar.swift line numbers that
apps/.i18n/native-source.json records. Regenerated with
`pnpm native:i18n:baseline`; only line numbers change, no strings added
or removed.

* refactor(macos): drop the orphaned anchored chat panel

Removing the hover card left WebChatManager.togglePanel() without a caller,
and it was the only thing that created the menu-bar-anchored chat popover.
Delete that presentation path: togglePanel/panelHidden, the panel controller
state, WebChatPresentation, WebChatPanel, presentAnchored and its dismiss
monitor, and WindowPlacement.anchoredBelowFrame.

Chat is unaffected as a window - WebChatManager.show() still backs the Dock
menu, --chat and deep links. The status item highlight that tracked chat
window visibility is preserved, renamed to onChatWindowVisibilityChanged
now that no panel exists to confuse it with.

Periphery reports no unused code.

* chore: drop CHANGELOG edit from this PR

AGENTS.md: CHANGELOG.md is release-only and release generation derives it
from merged PRs. Release-note context lives in the PR body instead.

* chore: restore CHANGELOG to the branch base

Correct the previous commit, which restored CHANGELOG.md from current
origin/main and so pulled in an unrelated entry added after this branch
forked. Reset to the merge-base content: this PR now touches no changelog.
2026-08-02 13:10:27 -07:00

259 lines
8.9 KiB
Swift

import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import SwiftUI
@MainActor
@Observable
final class WorkActivityStore {
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
}
private(set) var current: Activity?
private(set) var iconState: IconState = .idle
private(set) var lastToolUpdatedAt: Date?
private var jobs: [String: Activity] = [:]
private var tools: [String: Activity] = [:]
private var currentSessionKey: String?
private var toolSeqBySession: [String: Int] = [:]
private var mainSessionKeyStorage = "main"
private let toolResultGrace: TimeInterval = 2.0
var mainSessionKey: String {
self.mainSessionKeyStorage
}
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.setJobActive(activity)
} else {
// Job ended (done/error/aborted/etc). Clear everything for this session.
self.clearTool(sessionKey: sessionKey)
self.clearJob(sessionKey: sessionKey)
}
}
func handleTool(
sessionKey: String,
phase: String,
name: String?,
meta: String?,
args: [String: OpenClawProtocol.AnyCodable]?)
{
let toolKind = Self.mapToolKind(name)
let label = Self.buildLabel(name: name, meta: meta, args: args)
if phase.lowercased() == "start" {
self.lastToolUpdatedAt = Date()
self.toolSeqBySession[sessionKey, default: 0] += 1
let activity = Activity(
sessionKey: sessionKey,
role: self.role(for: sessionKey),
kind: .tool(toolKind),
label: label,
startedAt: Date(),
lastUpdate: Date())
self.setToolActive(activity)
} else {
// Delay removal slightly to avoid flicker on rapid result/start bursts.
let key = sessionKey
let seq = self.toolSeqBySession[key, default: 0]
Task { [weak self] in
let nsDelay = UInt64((self?.toolResultGrace ?? 0) * 1_000_000_000)
try? await Task.sleep(nanoseconds: nsDelay)
await MainActor.run {
guard let self else { return }
guard self.toolSeqBySession[key, default: 0] == seq else { return }
self.lastToolUpdatedAt = Date()
self.clearTool(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 setJobActive(_ activity: Activity) {
self.jobs[activity.sessionKey] = activity
self.updateCurrentSession(with: activity)
}
private func setToolActive(_ activity: Activity) {
self.tools[activity.sessionKey] = activity
self.updateCurrentSession(with: activity)
}
private func updateCurrentSession(with activity: Activity) {
// Main session preempts immediately.
if activity.role == .main {
self.currentSessionKey = activity.sessionKey
} else if self.currentSessionKey == nil || !self.isActive(sessionKey: self.currentSessionKey!) {
self.currentSessionKey = activity.sessionKey
}
self.refreshDerivedState()
}
func setMainSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed != self.mainSessionKeyStorage else { return }
self.mainSessionKeyStorage = trimmed
if let current = self.currentSessionKey, !self.isActive(sessionKey: current) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func clearJob(sessionKey: String) {
guard self.jobs[sessionKey] != nil else { return }
self.jobs.removeValue(forKey: sessionKey)
if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func clearTool(sessionKey: String) {
guard self.tools[sessionKey] != nil else { return }
self.tools.removeValue(forKey: sessionKey)
if self.currentSessionKey == sessionKey, !self.isActive(sessionKey: sessionKey) {
self.pickNextSession()
}
self.refreshDerivedState()
}
private func pickNextSession() {
// Prefer main if present.
if self.isActive(sessionKey: self.mainSessionKeyStorage) {
self.currentSessionKey = self.mainSessionKeyStorage
return
}
// Otherwise, pick most recent by lastUpdate across job/tool.
let keys = Set(self.jobs.keys).union(self.tools.keys)
let next = keys.max(by: { self.lastUpdate(for: $0) < self.lastUpdate(for: $1) })
self.currentSessionKey = next
}
private func role(for sessionKey: String) -> SessionRole {
sessionKey == self.mainSessionKeyStorage ? .main : .other
}
private func isActive(sessionKey: String) -> Bool {
self.jobs[sessionKey] != nil || self.tools[sessionKey] != nil
}
private func lastUpdate(for sessionKey: String) -> Date {
max(self.jobs[sessionKey]?.lastUpdate ?? .distantPast, self.tools[sessionKey]?.lastUpdate ?? .distantPast)
}
private func currentActivity(for sessionKey: String) -> Activity? {
// Prefer tool overlay if present, otherwise job.
self.tools[sessionKey] ?? self.jobs[sessionKey]
}
private func refreshDerivedState() {
if let key = self.currentSessionKey, !self.isActive(sessionKey: key) {
self.currentSessionKey = nil
}
self.current = self.currentSessionKey.flatMap { self.currentActivity(for: $0) }
self.iconState = self.deriveIconState()
}
private func deriveIconState() -> IconState {
guard let sessionKey = self.currentSessionKey,
let activity = self.currentActivity(for: sessionKey)
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": .bash
case "read": .read
case "write": .write
case "edit": .edit
case "attach": .attach
default: .other
}
}
private static func buildLabel(
name: String?,
meta: String?,
args: [String: OpenClawProtocol.AnyCodable]?) -> String
{
let wrappedArgs = self.wrapToolArgs(args)
let display = ToolDisplayRegistry.resolve(name: name ?? "tool", args: wrappedArgs, meta: meta)
if let detail = display.detailLine, !detail.isEmpty {
return "\(display.label): \(detail)"
}
return display.label
}
private static func wrapToolArgs(_ args: [String: OpenClawProtocol.AnyCodable]?) -> OpenClawKit.AnyCodable? {
guard let args else { return nil }
let converted: [String: Any] = args.mapValues { self.unwrapJSONValue($0.value) }
return OpenClawKit.AnyCodable(converted)
}
private static func unwrapJSONValue(_ value: Any) -> Any {
if let dict = value as? [String: OpenClawProtocol.AnyCodable] {
return dict.mapValues { self.unwrapJSONValue($0.value) }
}
if let array = value as? [OpenClawProtocol.AnyCodable] {
return array.map { self.unwrapJSONValue($0.value) }
}
if let dict = value as? [String: Any] {
return dict.mapValues { self.unwrapJSONValue($0) }
}
if let array = value as? [Any] {
return array.map { self.unwrapJSONValue($0) }
}
return value
}
}