Files
openclaw/apps/macos/Sources/OpenClaw/WebChatManager.swift
T
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

459 lines
19 KiB
Swift

import AppKit
import Foundation
import OpenClawChatUI
struct WebChatRoute: Equatable, Sendable {
let sessionKey: String
let agentID: String?
init(sessionKey: String, agentID: String?) {
self.sessionKey = sessionKey
self.agentID = Self.normalizedAgentID(agentID)
}
func replacingSessionKey(_ sessionKey: String) -> Self {
Self(sessionKey: sessionKey, agentID: self.agentID)
}
static func normalizedAgentID(_ agentID: String?) -> String? {
let normalized = agentID?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return normalized?.isEmpty == false ? normalized : nil
}
}
struct WebChatSessionObserverVisibilityOwners {
private var ownersByConnection: [ObjectIdentifier: Set<ObjectIdentifier>] = [:]
mutating func setVisible(
_ visible: Bool,
owner: ObjectIdentifier,
connection: ObjectIdentifier) -> Bool?
{
let wasVisible = self.isVisible(connection: connection)
if visible {
self.ownersByConnection[connection, default: []].insert(owner)
} else {
self.ownersByConnection[connection]?.remove(owner)
if self.ownersByConnection[connection]?.isEmpty == true {
self.ownersByConnection.removeValue(forKey: connection)
}
}
let isVisible = self.isVisible(connection: connection)
return wasVisible == isVisible ? nil : isVisible
}
func isVisible(connection: ObjectIdentifier) -> Bool {
self.ownersByConnection[connection]?.isEmpty == false
}
}
@MainActor
final class WebChatManager {
static let shared = WebChatManager()
private struct ProfileWindowInstance {
let profileID: String
let controller: WebChatSwiftUIWindowController
}
private var windowController: WebChatSwiftUIWindowController?
private var windowRoute: WebChatRoute?
private var currentChatRoute: WebChatRoute?
private var cachedPreferredSessionKey: String?
private var profileWindows: [UUID: ProfileWindowInstance] = [:]
private var profileWindowOrder: [UUID] = []
private var unavailableProfileIDs: Set<String> = []
private var sessionObserverOwners = WebChatSessionObserverVisibilityOwners()
private var sessionObserverMonitors: [ObjectIdentifier: Task<Void, Never>] = [:]
private var sessionObserverRequests: [ObjectIdentifier: (id: UUID, task: Task<Void, Never>)] = [:]
private var sessionObserverDeclarations:
[ObjectIdentifier: (lease: GatewayConnection.ServerLease, visible: Bool)] = [:]
private static let lastGatewayProfileIDKey = "openclaw.webchat.lastGatewayProfileID"
var onChatWindowVisibilityChanged: ((Bool) -> Void)?
var activeSessionKey: String? {
self.currentChatRoute?.sessionKey ?? self.windowRoute?.sessionKey
}
func show(sessionKey: String, agentID: String? = nil, draft: String? = nil) {
let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID)
if let controller = windowController {
// The window shell switches sessions in place (sidebar, /new);
// full route identity tracks those switches and the global owner.
if Self.shouldReuseController(currentRoute: self.windowRoute, requestedRoute: route) {
controller.applyDraftIfEmpty(draft)
controller.show()
return
}
controller.close()
self.windowController = nil
self.windowRoute = nil
}
let controller = WebChatSwiftUIWindowController(
sessionKey: route.sessionKey,
agentID: route.agentID,
initialDraft: draft)
controller.onVisibilityChanged = { [weak self, weak controller] visible in
guard let self, let controller else { return }
self.setSessionObserverVisible(visible, owner: controller, connection: .shared)
self.onChatWindowVisibilityChanged?(visible)
}
controller.onClosed = { [weak self, weak controller] in
guard let self, let controller else { return }
self.setSessionObserverVisible(false, owner: controller, connection: .shared)
guard self.windowController === controller else { return }
if self.currentChatRoute == self.windowRoute {
self.currentChatRoute = nil
}
self.windowController = nil
self.windowRoute = nil
}
controller.onSessionKeyChanged = { [weak self, weak controller] key in
guard let self, let controller, self.windowController === controller else { return }
// Retaining the agent is safe: this surface has no in-window agent switcher,
// and the controller pins explicit agents against gateway-default changes.
let updatedRoute = (self.windowRoute ?? route).replacingSessionKey(key)
self.windowRoute = updatedRoute
self.currentChatRoute = updatedRoute
}
self.windowController = controller
self.windowRoute = route
self.currentChatRoute = route
controller.show()
}
#if DEBUG
func showSwarmFixture() {
self.windowController?.close()
let transport = MacSwarmFixtureChatTransport()
let controller = WebChatSwiftUIWindowController(
sessionKey: transport.sessionKey,
transport: transport,
windowTitle: "OpenClaw Swarm Fixture",
windowAutosaveName: "OpenClawSwarmFixture")
controller.onClosed = { [weak self, weak controller] in
guard let self, let controller, self.windowController === controller else { return }
self.windowController = nil
self.windowRoute = nil
}
self.windowController = controller
self.windowRoute = WebChatRoute(sessionKey: transport.sessionKey, agentID: nil)
controller.show()
}
#endif
func newGatewayWindow() {
Task { @MainActor [weak self] in
guard let self else { return }
do {
let profiles = try await MacGatewayProfileStore.shared.profiles()
guard !profiles.isEmpty else {
AppNavigationActions.openSettings(tab: .gateways)
return
}
let preferredID = UserDefaults.standard.string(forKey: Self.lastGatewayProfileIDKey)
switch Self.promptForGatewayProfile(profiles: profiles, preferredID: preferredID) {
case let .profile(profile):
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
try await self.show(profile: profile)
case .manage:
AppNavigationActions.openSettings(tab: .gateways)
case nil:
break
}
} catch {
Self.showProfileError(error, message: "Could Not Open Gateway Window")
}
}
}
func openGatewayWindow(profile: MacGatewayProfile) {
Task { @MainActor [weak self] in
do {
UserDefaults.standard.set(profile.id, forKey: Self.lastGatewayProfileIDKey)
try await self?.show(profile: profile)
} catch {
Self.showProfileError(error, message: "Could Not Open Gateway Window")
}
}
}
func show(profile: MacGatewayProfile) async throws {
guard !self.unavailableProfileIDs.contains(profile.id) else {
throw MacGatewayProfileError.profileNotFound
}
let connection = await MacGatewayConnectionFleet.shared.connection(profileID: profile.id)
guard !self.unavailableProfileIDs.contains(profile.id) else {
throw MacGatewayProfileError.profileNotFound
}
let sessionKey = await connection.mainSessionKey()
guard !self.unavailableProfileIDs.contains(profile.id) else {
throw MacGatewayProfileError.profileNotFound
}
let windowID = UUID()
let route = WebChatRoute(sessionKey: sessionKey, agentID: nil)
let previousController = self.profileWindowOrder.reversed().lazy
.compactMap { self.profileWindows[$0] }
.first { $0.profileID == profile.id }?
.controller
let controller = WebChatSwiftUIWindowController(
sessionKey: route.sessionKey,
agentID: route.agentID,
connection: connection,
gatewayID: profile.id,
windowTitle: "\(profile.name) — OpenClaw",
windowAutosaveName: "OpenClawChatWindow-\(profile.id)")
controller.onVisibilityChanged = { [weak self, weak controller] visible in
guard let self, let controller else { return }
self.setSessionObserverVisible(visible, owner: controller, connection: connection)
}
controller.onClosed = { [weak self, weak controller] in
guard let self, let controller else { return }
self.setSessionObserverVisible(false, owner: controller, connection: connection)
guard self.profileWindows[windowID]?.controller === controller else { return }
self.profileWindows.removeValue(forKey: windowID)
self.profileWindowOrder.removeAll { $0 == windowID }
}
self.profileWindows[windowID] = ProfileWindowInstance(
profileID: profile.id,
controller: controller)
self.profileWindowOrder.append(windowID)
controller.cascade(from: previousController)
controller.show()
Task {
try? await connection.refresh()
}
}
func closeGatewayWindows(profileID: String) async {
// Removal fences in-flight window creation before awaiting connection
// shutdown, so an old picker selection cannot resurrect this profile.
self.unavailableProfileIDs.insert(profileID)
let windowIDs = self.profileWindowOrder.filter { self.profileWindows[$0]?.profileID == profileID }
let controllers = windowIDs.compactMap { self.profileWindows.removeValue(forKey: $0)?.controller }
let windowIDSet = Set(windowIDs)
self.profileWindowOrder.removeAll { windowIDSet.contains($0) }
for controller in controllers {
controller.close()
}
await MacGatewayConnectionFleet.shared.remove(profileID: profileID)
}
func gatewayProfileDidSave(profileID: String) {
self.unavailableProfileIDs.remove(profileID)
}
func recordActiveSessionKey(_ sessionKey: String) {
let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let route = self.currentChatRoute ?? self.windowRoute
self.currentChatRoute = route?.replacingSessionKey(trimmed)
?? WebChatRoute(sessionKey: trimmed, agentID: nil)
}
func preferredSessionKey() async -> String {
if let cachedPreferredSessionKey {
return cachedPreferredSessionKey
}
let key = await GatewayConnection.shared.mainSessionKey()
cachedPreferredSessionKey = key
return key
}
func resetTunnels() {
self.windowController?.close()
self.windowController = nil
self.windowRoute = nil
self.currentChatRoute = nil
self.cachedPreferredSessionKey = nil
let profileControllers = self.profileWindows.values.map(\.controller)
self.profileWindows.removeAll()
self.profileWindowOrder.removeAll()
self.unavailableProfileIDs.removeAll()
for controller in profileControllers {
controller.close()
}
Task { await MacGatewayConnectionFleet.shared.shutdown() }
}
func close() {
self.resetTunnels()
}
private func setSessionObserverVisible(
_ visible: Bool,
owner: WebChatSwiftUIWindowController,
connection: GatewayConnection)
{
let connectionID = ObjectIdentifier(connection)
guard let aggregateVisibility = self.sessionObserverOwners.setVisible(
visible,
owner: ObjectIdentifier(owner),
connection: connectionID)
else { return }
if aggregateVisibility, self.sessionObserverMonitors[connectionID] == nil {
// Visibility and subscriptions belong to a physical socket; a reconnect
// must redeclare both while any window on that connection remains open.
self.sessionObserverMonitors[connectionID] = Task { @MainActor [weak self] in
let pushes = await connection.subscribe(bufferingNewest: 1)
for await push in pushes {
guard !Task.isCancelled else { return }
guard case .snapshot = push else { continue }
guard let self else { return }
self.scheduleSessionObserverVisibility(
self.sessionObserverOwners.isVisible(connection: connectionID),
connection: connection)
}
}
}
self.scheduleSessionObserverVisibility(aggregateVisibility, connection: connection)
}
private func scheduleSessionObserverVisibility(
_ visible: Bool,
connection: GatewayConnection,
remainingHiddenRetries: Int = 1)
{
let connectionID = ObjectIdentifier(connection)
let previous = self.sessionObserverRequests[connectionID]?.task
let requestID = UUID()
let task = Task { @MainActor [weak self] in
await previous?.value
guard let self,
self.sessionObserverOwners.isVisible(connection: connectionID) == visible,
let lease = await connection.captureServerLease(),
self.sessionObserverOwners.isVisible(connection: connectionID) == visible
else {
self?.finishSessionObserverRequest(connection: connectionID, id: requestID)
return
}
if let declaration = self.sessionObserverDeclarations[connectionID],
declaration.visible == visible,
await connection.isCurrentServerLease(declaration.lease)
{
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
return
}
// A timed-out mutation may already have changed the Gateway. Clear
// the old confirmation before dispatch so reopening retries truthfully.
self.sessionObserverDeclarations.removeValue(forKey: connectionID)
do {
if visible {
let subscribe = OpenClawChatGatewayRequests.subscribeSessions()
_ = try await connection.request(
method: subscribe.method,
params: subscribe.params,
timeoutMs: subscribe.timeoutMs,
ifCurrentServerLease: lease)
}
guard self.sessionObserverOwners.isVisible(connection: connectionID) == visible else {
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
return
}
let request = OpenClawChatGatewayRequests.setSessionObserverVisibility(visible)
_ = try await connection.request(
method: request.method,
params: request.params,
timeoutMs: request.timeoutMs,
ifCurrentServerLease: lease)
if visible {
self.sessionObserverDeclarations[connectionID] = (lease: lease, visible: true)
} else {
self.sessionObserverDeclarations.removeValue(forKey: connectionID)
if !self.sessionObserverOwners.isVisible(connection: connectionID) {
self.sessionObserverMonitors.removeValue(forKey: connectionID)?.cancel()
}
}
} catch {
// A hidden mutation can time out after dispatch. Retry once on its
// original socket; keep the snapshot monitor for a replaced socket.
if !visible,
remainingHiddenRetries > 0,
await connection.isCurrentServerLease(lease),
!self.sessionObserverOwners.isVisible(connection: connectionID)
{
self.scheduleSessionObserverVisibility(
false,
connection: connection,
remainingHiddenRetries: remainingHiddenRetries - 1)
}
}
self.finishSessionObserverRequest(connection: connectionID, id: requestID)
}
self.sessionObserverRequests[connectionID] = (id: requestID, task: task)
}
private func finishSessionObserverRequest(connection: ObjectIdentifier, id: UUID) {
guard self.sessionObserverRequests[connection]?.id == id else { return }
self.sessionObserverRequests.removeValue(forKey: connection)
}
static func shouldReuseController(
currentRoute: WebChatRoute?,
requestedRoute: WebChatRoute) -> Bool
{
currentRoute == requestedRoute
}
private enum GatewayProfileSelection {
case profile(MacGatewayProfile)
case manage
}
private static func promptForGatewayProfile(
profiles: [MacGatewayProfile],
preferredID: String?) -> GatewayProfileSelection?
{
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 360, height: 28), pullsDown: false)
popup.addItems(withTitles: profiles.map(Self.profilePickerTitle))
popup.selectItem(at: Self.preferredProfileIndex(profiles: profiles, preferredID: preferredID))
let alert = NSAlert()
alert.messageText = "New Gateway Window"
alert.informativeText = "Choose a saved Gateway. You can open more than one window for the same Gateway."
alert.accessoryView = popup
alert.addButton(withTitle: "Open Window")
alert.addButton(withTitle: "Manage Gateways…")
alert.addButton(withTitle: "Cancel")
switch alert.runModal() {
case .alertFirstButtonReturn:
guard profiles.indices.contains(popup.indexOfSelectedItem) else { return nil }
return .profile(profiles[popup.indexOfSelectedItem])
case .alertSecondButtonReturn:
return .manage
default:
return nil
}
}
nonisolated static func preferredProfileIndex(profiles: [MacGatewayProfile], preferredID: String?) -> Int {
profiles.firstIndex { $0.id == preferredID } ?? 0
}
private static func profilePickerTitle(_ profile: MacGatewayProfile) -> String {
"\(profile.name)\(profile.url.absoluteString)"
}
private static func showProfileError(_ error: Error, message: String) {
let alert = NSAlert(error: error)
alert.messageText = message
alert.runModal()
}
#if DEBUG
func _testSessionObserverVisible(connection: GatewayConnection) -> Bool {
self.sessionObserverOwners.isVisible(connection: ObjectIdentifier(connection))
}
func _testProfileWindowCount(profileID: String) -> Int {
self.profileWindows.values.count { $0.profileID == profileID }
}
#endif
}