mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-15 15:13:48 -06:00
b8a6ea12cc
* 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.
259 lines
8.9 KiB
Swift
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
|
|
}
|
|
}
|