From b8a6ea12cc2f9e04e85acbf7d00759a067f3133c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 13:10:27 -0700 Subject: [PATCH] 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. --- apps/.i18n/native-source.json | 50 +-- apps/macos/Sources/OpenClaw/HoverHUD.swift | 269 --------------- apps/macos/Sources/OpenClaw/MenuBar.swift | 66 +--- .../Sources/OpenClaw/WebChatManager.swift | 90 +---- .../Sources/OpenClaw/WebChatSwiftUI.swift | 314 +++--------------- .../Sources/OpenClaw/WindowPlacement.swift | 22 -- .../Sources/OpenClaw/WorkActivityStore.swift | 2 - .../HoverHUDControllerTests.swift | 26 -- .../StatusItemMouseRouterTests.swift | 55 +-- .../WebChatSwiftUISmokeTests.swift | 15 - 10 files changed, 128 insertions(+), 781 deletions(-) delete mode 100644 apps/macos/Sources/OpenClaw/HoverHUD.swift delete mode 100644 apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 51c06e624b8a..e90d4e1b7693 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -34355,7 +34355,7 @@ }, { "kind": "ui-call", - "line": 108, + "line": 102, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "New Gateway Window…", "surface": "apple", @@ -34363,7 +34363,7 @@ }, { "kind": "ui-call", - "line": 113, + "line": 107, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "New Thread", "surface": "apple", @@ -34371,7 +34371,7 @@ }, { "kind": "ui-call", - "line": 119, + "line": 113, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Settings...", "surface": "apple", @@ -34379,7 +34379,7 @@ }, { "kind": "ui-call", - "line": 127, + "line": 121, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Back", "surface": "apple", @@ -34387,7 +34387,7 @@ }, { "kind": "ui-call", - "line": 132, + "line": 126, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Forward", "surface": "apple", @@ -34395,7 +34395,7 @@ }, { "kind": "ui-call", - "line": 139, + "line": 133, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Command Palette…", "surface": "apple", @@ -34403,7 +34403,7 @@ }, { "kind": "conditional-branch", - "line": 157, + "line": 147, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw - Voice Wake live meter active", "surface": "apple", @@ -34411,7 +34411,7 @@ }, { "kind": "conditional-branch", - "line": 158, + "line": 148, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "OpenClaw", "surface": "apple", @@ -34419,7 +34419,7 @@ }, { "kind": "conditional-branch", - "line": 455, + "line": 405, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Close Canvas", "surface": "apple", @@ -34427,7 +34427,7 @@ }, { "kind": "conditional-branch", - "line": 455, + "line": 405, "path": "apps/macos/Sources/OpenClaw/MenuBar.swift", "source": "Open Canvas", "surface": "apple", @@ -39691,7 +39691,7 @@ }, { "kind": "ui-named-argument", - "line": 201, + "line": 179, "path": "apps/macos/Sources/OpenClaw/WebChatManager.swift", "source": "Could Not Open Gateway Window", "surface": "apple", @@ -39699,7 +39699,7 @@ }, { "kind": "ui-localized-call", - "line": 958, + "line": 934, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode uses the primary Gateway window", "surface": "apple", @@ -39707,7 +39707,7 @@ }, { "kind": "ui-localized-call", - "line": 960, + "line": 936, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode off", "surface": "apple", @@ -39715,7 +39715,7 @@ }, { "kind": "ui-localized-call", - "line": 962, + "line": 938, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode paused", "surface": "apple", @@ -39723,7 +39723,7 @@ }, { "kind": "ui-localized-call", - "line": 965, + "line": 941, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Talk mode ready", "surface": "apple", @@ -39731,7 +39731,7 @@ }, { "kind": "ui-localized-call", - "line": 966, + "line": 942, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Listening", "surface": "apple", @@ -39739,7 +39739,7 @@ }, { "kind": "ui-localized-call", - "line": 967, + "line": 943, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Thinking", "surface": "apple", @@ -39747,7 +39747,7 @@ }, { "kind": "ui-localized-call", - "line": 968, + "line": 944, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Speaking", "surface": "apple", @@ -39755,7 +39755,7 @@ }, { "kind": "ui-localized-call", - "line": 972, + "line": 948, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What would you like to work on?", "surface": "apple", @@ -39763,7 +39763,7 @@ }, { "kind": "ui-localized-call", - "line": 976, + "line": 952, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Check OpenClaw status", "surface": "apple", @@ -39771,7 +39771,7 @@ }, { "kind": "ui-localized-call", - "line": 977, + "line": 953, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize the current OpenClaw status and tell me what needs attention.", "surface": "apple", @@ -39779,7 +39779,7 @@ }, { "kind": "ui-localized-call", - "line": 980, + "line": 956, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "What can you do?", "surface": "apple", @@ -39787,7 +39787,7 @@ }, { "kind": "ui-localized-call", - "line": 981, + "line": 957, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Show me what you can help with on this Mac right now.", "surface": "apple", @@ -39795,7 +39795,7 @@ }, { "kind": "ui-localized-call", - "line": 984, + "line": 960, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Catch me up", "surface": "apple", @@ -39803,7 +39803,7 @@ }, { "kind": "ui-localized-call", - "line": 985, + "line": 961, "path": "apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift", "source": "Summarize what happened in my threads since yesterday.", "surface": "apple", diff --git a/apps/macos/Sources/OpenClaw/HoverHUD.swift b/apps/macos/Sources/OpenClaw/HoverHUD.swift deleted file mode 100644 index 5e182aa2f97a..000000000000 --- a/apps/macos/Sources/OpenClaw/HoverHUD.swift +++ /dev/null @@ -1,269 +0,0 @@ -import AppKit -import Observation -import QuartzCore -import SwiftUI - -/// Hover-only HUD anchored to the menu bar item. Click expands into full Web Chat. -@MainActor -@Observable -final class HoverHUDController { - static let shared = HoverHUDController() - - struct Model { - var isVisible: Bool = false - var isSuppressed: Bool = false - var hoveringStatusItem: Bool = false - var hoveringPanel: Bool = false - } - - private(set) var model = Model() - - private var window: NSPanel? - private var hostingView: NSHostingView? - private var dismissMonitor: Any? - private var dismissTask: Task? - private var showTask: Task? - private var anchorProvider: (() -> NSRect?)? - - private let width: CGFloat = 360 - private let height: CGFloat = 74 - private let padding: CGFloat = 8 - private let hoverShowDelay: TimeInterval = 0.18 - - func setSuppressed(_ suppressed: Bool) { - self.model.isSuppressed = suppressed - if suppressed { - self.showTask?.cancel() - self.showTask = nil - self.dismiss() - } - } - - func statusItemHoverChanged(inside: Bool, anchorProvider: @escaping () -> NSRect?) { - self.model.hoveringStatusItem = inside - self.anchorProvider = anchorProvider - - guard !self.model.isSuppressed else { return } - - if inside { - self.dismissTask?.cancel() - self.dismissTask = nil - self.showTask?.cancel() - self.showTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(nanoseconds: UInt64(self.hoverShowDelay * 1_000_000_000)) - await MainActor.run { [weak self] in - guard let self else { return } - guard !Task.isCancelled else { return } - guard self.model.hoveringStatusItem else { return } - guard !self.model.isSuppressed else { return } - self.present() - } - } - } else { - self.showTask?.cancel() - self.showTask = nil - self.scheduleDismiss() - } - } - - func panelHoverChanged(inside: Bool) { - self.model.hoveringPanel = inside - if inside { - self.dismissTask?.cancel() - self.dismissTask = nil - } else if !self.model.hoveringStatusItem { - self.scheduleDismiss() - } - } - - func openChat() { - guard let anchorProvider = self.anchorProvider else { return } - self.dismiss() - Task { @MainActor in - let sessionKey = await WebChatManager.shared.preferredSessionKey() - WebChatManager.shared.togglePanel(sessionKey: sessionKey, anchorProvider: anchorProvider) - } - } - - func dismiss() { - self.dismissTask?.cancel() - self.dismissTask = nil - self.removeDismissMonitor() - guard let window else { - self.model.isVisible = false - return - } - - if !self.model.isVisible { - window.orderOut(nil) - return - } - - OverlayPanelFactory.animateDismissAndHide(window: window, offsetX: 0, offsetY: 6, duration: 0.14) { - self.model.isVisible = false - } - } - - // MARK: - Private - - private func scheduleDismiss() { - self.dismissTask?.cancel() - self.dismissTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 250_000_000) - await MainActor.run { - guard let self else { return } - if self.model.hoveringStatusItem || self.model.hoveringPanel { return } - self.dismiss() - } - } - } - - private func present() { - guard !self.model.isSuppressed else { return } - self.ensureWindow() - self.hostingView?.rootView = HoverHUDView(controller: self) - let target = self.targetFrame() - - guard let window else { return } - self.installDismissMonitor() - - if !self.model.isVisible { - self.model.isVisible = true - let start = target.offsetBy(dx: 0, dy: 8) - OverlayPanelFactory.animatePresent(window: window, from: start, to: target) - } else { - window.orderFrontRegardless() - self.updateWindowFrame(animate: true) - } - } - - private func ensureWindow() { - if self.window != nil { return } - let panel = OverlayPanelFactory.makePanel( - contentRect: NSRect(x: 0, y: 0, width: self.width, height: self.height), - level: .statusBar, - hasShadow: true) - - let host = NSHostingView(rootView: HoverHUDView(controller: self)) - host.translatesAutoresizingMaskIntoConstraints = false - panel.contentView = host - self.hostingView = host - self.window = panel - } - - private func targetFrame() -> NSRect { - guard let anchor = self.anchorProvider?() else { - return WindowPlacement.topRightFrame( - size: NSSize(width: self.width, height: self.height), - padding: self.padding) - } - - let screen = NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) - } ?? NSScreen.main - - let bounds = (screen?.visibleFrame ?? .zero).insetBy(dx: self.padding, dy: self.padding) - return WindowPlacement.anchoredBelowFrame( - size: NSSize(width: self.width, height: self.height), - anchor: anchor, - padding: self.padding, - in: bounds) - } - - private func updateWindowFrame(animate: Bool = false) { - OverlayPanelFactory.applyFrame(window: self.window, target: self.targetFrame(), animate: animate) - } - - private func installDismissMonitor() { - if ProcessInfo.processInfo.isRunningTests { return } - guard self.dismissMonitor == nil, let window else { return } - self.dismissMonitor = NSEvent.addGlobalMonitorForEvents(matching: [ - .leftMouseDown, - .rightMouseDown, - .otherMouseDown, - ]) { [weak self] _ in - guard let self, self.model.isVisible else { return } - let pt = NSEvent.mouseLocation - if !window.frame.contains(pt) { - Task { @MainActor in self.dismiss() } - } - } - } - - private func removeDismissMonitor() { - OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor) - } -} - -private struct HoverHUDView: View { - var controller: HoverHUDController - private let activityStore = WorkActivityStore.shared - - private var statusTitle: String { - if self.activityStore.iconState.isWorking { return "Working" } - return "Idle" - } - - private var detail: String { - if let current = self.activityStore.current?.label, !current.isEmpty { return current } - if let last = self.activityStore.lastToolLabel, !last.isEmpty { return last } - return "No recent activity" - } - - private var symbolName: String { - if self.activityStore.iconState.isWorking { - return self.activityStore.iconState.badgeSymbolName - } - return "moon.zzz.fill" - } - - private var dotColor: Color { - if self.activityStore.iconState.isWorking { - return Color(nsColor: NSColor.systemGreen.withAlphaComponent(0.7)) - } - return .secondary - } - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(self.dotColor) - .frame(width: 7, height: 7) - .padding(.top, 5) - - VStack(alignment: .leading, spacing: 4) { - Text(self.statusTitle) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) - Text(self.detail) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - Image(systemName: self.symbolName) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.top, 1) - } - .padding(12) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.regularMaterial)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(Color.black.opacity(0.10), lineWidth: 1)) - .contentShape(Rectangle()) - .onHover { inside in - self.controller.panelHoverChanged(inside: inside) - } - .onTapGesture { - self.controller.openChat() - } - } -} diff --git a/apps/macos/Sources/OpenClaw/MenuBar.swift b/apps/macos/Sources/OpenClaw/MenuBar.swift index bbd09dc77612..4655c859751e 100644 --- a/apps/macos/Sources/OpenClaw/MenuBar.swift +++ b/apps/macos/Sources/OpenClaw/MenuBar.swift @@ -19,17 +19,12 @@ struct OpenClawApp: App { @State private var statusItem: NSStatusItem? @State private var statusItemMouseRouter = StatusItemMouseRouter() @State private var isMenuPresented = false - @State private var isPanelVisible = false + @State private var isChatWindowVisible = false @State private var tailscaleService = TailscaleService.shared @MainActor private func updateStatusHighlight() { - self.statusItem?.button?.highlight(self.isPanelVisible) - } - - @MainActor - private func updateHoverHUDSuppression() { - HoverHUDController.shared.setSuppressed(self.isMenuPresented || self.isPanelVisible) + self.statusItem?.button?.highlight(self.isChatWindowVisible) } init() { @@ -66,7 +61,6 @@ struct OpenClawApp: App { MenuSessionsInjector.shared.install(into: item) self.applyStatusItemAppearance(paused: self.state.isPaused, sleeping: self.isGatewaySleeping) self.installStatusItemMouseHandler(for: item) - self.updateHoverHUDSuppression() } .menuBarExtraStyle(.menu) .onChange(of: self.state.isPaused) { _, paused in @@ -142,10 +136,6 @@ struct OpenClawApp: App { .keyboardShortcut("k", modifiers: .command) } } - .onChange(of: self.isMenuPresented) { _, _ in - self.updateStatusHighlight() - self.updateHoverHUDSuppression() - } } private func applyStatusItemAppearance(paused _: Bool, sleeping _: Bool) { @@ -195,10 +185,9 @@ struct OpenClawApp: App { @MainActor private func installStatusItemMouseHandler(for item: NSStatusItem) { - WebChatManager.shared.onPanelVisibilityChanged = { [self] visible in - self.isPanelVisible = visible + WebChatManager.shared.onChatWindowVisibilityChanged = { [self] visible in + self.isChatWindowVisible = visible self.updateStatusHighlight() - self.updateHoverHUDSuppression() } CanvasManager.shared.onPanelVisibilityChanged = { [self] visible in self.state.canvasPanelVisible = visible @@ -208,25 +197,15 @@ struct OpenClawApp: App { self.statusItemMouseRouter.install( on: item, onLeftClick: { [self] in - HoverHUDController.shared.dismiss() self.openDashboardWindow() }, onRightClick: { [self] in - HoverHUDController.shared.dismiss() - WebChatManager.shared.closePanel() self.isMenuPresented = true - self.updateStatusHighlight() - }, - onHoverChanged: { [self] inside in - HoverHUDController.shared.statusItemHoverChanged( - inside: inside, - anchorProvider: { [self] in self.statusButtonScreenFrame() }) }) } @MainActor private func openDashboardWindow() { - HoverHUDController.shared.setSuppressed(true) self.isMenuPresented = false AppNavigationActions.openDashboard() } @@ -263,10 +242,8 @@ final class StatusItemMouseRouter: NSResponder { private weak var button: NSView? private var eventMonitor: Any? - private var trackingArea: NSTrackingArea? private var onLeftClick: (() -> Void)? private var onRightClick: (() -> Void)? - private var onHoverChanged: ((Bool) -> Void)? private let eventMonitorInstaller: EventMonitorInstaller private let eventMonitorRemover: EventMonitorRemover @@ -296,27 +273,23 @@ final class StatusItemMouseRouter: NSResponder { func install( on item: NSStatusItem, onLeftClick: @escaping () -> Void, - onRightClick: @escaping () -> Void, - onHoverChanged: @escaping (Bool) -> Void) + onRightClick: @escaping () -> Void) { guard let button = item.button else { return } self.install( on: button, onLeftClick: onLeftClick, - onRightClick: onRightClick, - onHoverChanged: onHoverChanged) + onRightClick: onRightClick) } func install( on button: NSView, onLeftClick: @escaping () -> Void, - onRightClick: @escaping () -> Void, - onHoverChanged: @escaping (Bool) -> Void) + onRightClick: @escaping () -> Void) { self.onLeftClick = onLeftClick self.onRightClick = onRightClick - self.onHoverChanged = onHoverChanged - self.track(button) + self.button = button guard self.eventMonitor == nil else { return } self.eventMonitor = Self.installMonitor(using: self.eventMonitorInstaller) { [weak self] event in @@ -361,35 +334,12 @@ final class StatusItemMouseRouter: NSResponder { } } - private func track(_ button: NSView) { - guard self.button !== button else { return } - if let previousButton = self.button, let trackingArea { - previousButton.removeTrackingArea(trackingArea) - } - let trackingArea = NSTrackingArea( - rect: button.bounds, - options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], - owner: self, - userInfo: nil) - button.addTrackingArea(trackingArea) - self.button = button - self.trackingArea = trackingArea - } - private static func contains(_ event: NSEvent, in button: NSView) -> Bool { guard let window = button.window, event.windowNumber == window.windowNumber else { return false } let point = button.convert(event.locationInWindow, from: nil) return button.bounds.contains(point) } - override func mouseEntered(with _: NSEvent) { - self.onHoverChanged?(true) - } - - override func mouseExited(with _: NSEvent) { - self.onHoverChanged?(false) - } - @MainActor deinit { if let eventMonitor { self.eventMonitorRemover(eventMonitor) diff --git a/apps/macos/Sources/OpenClaw/WebChatManager.swift b/apps/macos/Sources/OpenClaw/WebChatManager.swift index f5989768f80d..a2b3088f9787 100644 --- a/apps/macos/Sources/OpenClaw/WebChatManager.swift +++ b/apps/macos/Sources/OpenClaw/WebChatManager.swift @@ -2,22 +2,6 @@ import AppKit import Foundation import OpenClawChatUI -/// A borderless panel that can still accept key focus (needed for typing). -final class WebChatPanel: NSPanel { - override var canBecomeKey: Bool { - true - } - - override var canBecomeMain: Bool { - true - } -} - -enum WebChatPresentation { - case window - case panel(anchorProvider: () -> NSRect?) -} - struct WebChatRoute: Equatable, Sendable { let sessionKey: String let agentID: String? @@ -74,8 +58,6 @@ final class WebChatManager { private var windowController: WebChatSwiftUIWindowController? private var windowRoute: WebChatRoute? - private var panelController: WebChatSwiftUIWindowController? - private var panelRoute: WebChatRoute? private var currentChatRoute: WebChatRoute? private var cachedPreferredSessionKey: String? private var profileWindows: [UUID: ProfileWindowInstance] = [:] @@ -89,15 +71,14 @@ final class WebChatManager { private static let lastGatewayProfileIDKey = "openclaw.webchat.lastGatewayProfileID" - var onPanelVisibilityChanged: ((Bool) -> Void)? + var onChatWindowVisibilityChanged: ((Bool) -> Void)? var activeSessionKey: String? { - self.currentChatRoute?.sessionKey ?? self.panelRoute?.sessionKey ?? self.windowRoute?.sessionKey + self.currentChatRoute?.sessionKey ?? self.windowRoute?.sessionKey } func show(sessionKey: String, agentID: String? = nil, draft: String? = nil) { let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID) - self.closePanel() if let controller = windowController { // The window shell switches sessions in place (sidebar, /new); // full route identity tracks those switches and the global owner. @@ -114,19 +95,18 @@ final class WebChatManager { let controller = WebChatSwiftUIWindowController( sessionKey: route.sessionKey, agentID: route.agentID, - initialDraft: draft, - presentation: .window) + 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.onPanelVisibilityChanged?(visible) + 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 = self.panelRoute + self.currentChatRoute = nil } self.windowController = nil self.windowRoute = nil @@ -147,12 +127,10 @@ final class WebChatManager { #if DEBUG func showSwarmFixture() { - self.closePanel() self.windowController?.close() let transport = MacSwarmFixtureChatTransport() let controller = WebChatSwiftUIWindowController( sessionKey: transport.sessionKey, - presentation: .window, transport: transport, windowTitle: "OpenClaw Swarm Fixture", windowAutosaveName: "OpenClawSwarmFixture") @@ -224,7 +202,6 @@ final class WebChatManager { let controller = WebChatSwiftUIWindowController( sessionKey: route.sessionKey, agentID: route.agentID, - presentation: .window, connection: connection, gatewayID: profile.id, windowTitle: "\(profile.name) — OpenClaw", @@ -269,61 +246,14 @@ final class WebChatManager { self.unavailableProfileIDs.remove(profileID) } - func togglePanel( - sessionKey: String, - agentID: String? = nil, - anchorProvider: @escaping () -> NSRect?) - { - let route = WebChatRoute(sessionKey: sessionKey, agentID: agentID) - if let controller = panelController { - if !Self.shouldReuseController(currentRoute: self.panelRoute, requestedRoute: route) { - controller.close() - self.panelController = nil - self.panelRoute = nil - } else { - if controller.isVisible { - controller.close() - } else { - controller.presentAnchored(anchorProvider: anchorProvider) - } - return - } - } - - let controller = WebChatSwiftUIWindowController( - sessionKey: route.sessionKey, - agentID: route.agentID, - presentation: .panel(anchorProvider: anchorProvider)) - controller.onClosed = { [weak self] in - self?.panelHidden() - } - controller.onVisibilityChanged = { [weak self] visible in - self?.onPanelVisibilityChanged?(visible) - } - controller.onSessionKeyChanged = { [weak self, weak controller] key in - guard let self, let controller, self.panelController === controller else { return } - let updatedRoute = (self.panelRoute ?? route).replacingSessionKey(key) - self.panelRoute = updatedRoute - self.currentChatRoute = updatedRoute - } - self.panelController = controller - self.panelRoute = route - self.currentChatRoute = route - controller.presentAnchored(anchorProvider: anchorProvider) - } - func recordActiveSessionKey(_ sessionKey: String) { let trimmed = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } - let route = self.currentChatRoute ?? self.panelRoute ?? self.windowRoute + let route = self.currentChatRoute ?? self.windowRoute self.currentChatRoute = route?.replacingSessionKey(trimmed) ?? WebChatRoute(sessionKey: trimmed, agentID: nil) } - func closePanel() { - self.panelController?.close() - } - func preferredSessionKey() async -> String { if let cachedPreferredSessionKey { return cachedPreferredSessionKey @@ -337,9 +267,6 @@ final class WebChatManager { self.windowController?.close() self.windowController = nil self.windowRoute = nil - self.panelController?.close() - self.panelController = nil - self.panelRoute = nil self.currentChatRoute = nil self.cachedPreferredSessionKey = nil let profileControllers = self.profileWindows.values.map(\.controller) @@ -467,11 +394,6 @@ final class WebChatManager { self.sessionObserverRequests.removeValue(forKey: connection) } - private func panelHidden() { - self.onPanelVisibilityChanged?(false) - // Keep panel controller cached so reopening doesn't re-bootstrap. - } - static func shouldReuseController( currentRoute: WebChatRoute?, requestedRoute: WebChatRoute) -> Bool diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 75a2c398f33b..eea4f8d7b2c0 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -4,7 +4,6 @@ import OpenClawChatUI import OpenClawKit import OpenClawProtocol import OSLog -import QuartzCore import SwiftUI private let webChatSwiftLogger = Logger(subsystem: "ai.openclaw", category: "WebChatSwiftUI") @@ -13,10 +12,8 @@ private let webChatVerboseLevelDefaultsKey = "openclaw.webchat.verboseLevel" private enum WebChatSwiftUILayout { static let windowSize = NSSize(width: 960, height: 700) - static let panelSize = NSSize(width: 480, height: 640) static let windowMinSize = NSSize(width: 640, height: 420) static let windowFrameAutosaveName = "OpenClawChatWindow" - static let anchorPadding: CGFloat = 8 } enum WebChatTracePreferences { @@ -847,7 +844,6 @@ private struct MacChatSurface: View { @AppStorage(OpenClawChatWindowShell.assistantToolActivityDefaultsKey) private var showsToolActivity = WebChatTracePreferences.displayOptions().contains(.toolActivity) - private let isFullWindow: Bool private let userAccent: Color? private let usesPrimaryAppRuntime: Bool private let speech: OpenClawChatSpeechController @@ -855,14 +851,12 @@ private struct MacChatSurface: View { init( viewModel: OpenClawChatViewModel, - isFullWindow: Bool, userAccent: Color?, usesPrimaryAppRuntime: Bool, speech: OpenClawChatSpeechController, voiceNoteRecorder: OpenClawVoiceNoteRecorder) { _viewModel = State(initialValue: viewModel) - self.isFullWindow = isFullWindow self.userAccent = userAccent self.usesPrimaryAppRuntime = usesPrimaryAppRuntime self.speech = speech @@ -870,39 +864,21 @@ private struct MacChatSurface: View { } var body: some View { - Group { - if self.isFullWindow { - OpenClawChatWindowShell( - viewModel: self.viewModel, - userAccent: self.userAccent, - displayOptions: self.displayOptions, - emptyAssistantIntro: Self.emptyAssistantIntro, - emptyAssistantPrompts: Self.emptyAssistantPrompts, - talkControl: self.talkControl, - voiceNoteControl: self.voiceNoteControl, - speech: self.speech, - mediaPlaybackAllowed: { - !AppStateStore.shared.talkEnabled && - !self.voiceNoteRecorder.ownsPendingChatAttachment - }) - } else { - OpenClawChatView( - viewModel: self.viewModel, - showsSessionSwitcher: true, - userAccent: self.userAccent, - emptyAssistantIntro: Self.emptyAssistantIntro, - emptyAssistantPrompts: Self.emptyAssistantPrompts, - talkControl: self.talkControl, - voiceNoteControl: self.voiceNoteControl, - speech: self.speech, - mediaPlaybackAllowed: { - !AppStateStore.shared.talkEnabled && - !self.voiceNoteRecorder.ownsPendingChatAttachment - }) - } - } - .onAppear { self.audioInputCatalog.start() } - .onDisappear { self.audioInputCatalog.stop() } + OpenClawChatWindowShell( + viewModel: self.viewModel, + userAccent: self.userAccent, + displayOptions: self.displayOptions, + emptyAssistantIntro: Self.emptyAssistantIntro, + emptyAssistantPrompts: Self.emptyAssistantPrompts, + talkControl: self.talkControl, + voiceNoteControl: self.voiceNoteControl, + speech: self.speech, + mediaPlaybackAllowed: { + !AppStateStore.shared.talkEnabled && + !self.voiceNoteRecorder.ownsPendingChatAttachment + }) + .onAppear { self.audioInputCatalog.start() } + .onDisappear { self.audioInputCatalog.stop() } } private var talkControl: OpenClawChatTalkControl { @@ -991,7 +967,7 @@ private struct MacChatSurface: View { hasTalkControl: true, hasSpeech: true, hasVoiceNoteControl: true, - displayOptions: self.isFullWindow ? self.displayOptions : []) + displayOptions: self.displayOptions) } #endif } @@ -1015,7 +991,6 @@ private final class WebChatSessionKeyRelay { @MainActor final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { - private let presentation: WebChatPresentation private let sessionKey: String private let initialActiveAgentID: String? private let viewModel: OpenClawChatViewModel @@ -1024,7 +999,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { private let speech: OpenClawChatSpeechController private let voiceNoteRecorder: OpenClawVoiceNoteRecorder private var window: NSWindow? - private var dismissMonitor: Any? var onClosed: (() -> Void)? var onVisibilityChanged: ((Bool) -> Void)? /// Fires when the hosted chat switches sessions in place (sidebar, @@ -1035,7 +1009,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { sessionKey: String, agentID: String? = nil, initialDraft: String? = nil, - presentation: WebChatPresentation, connection: GatewayConnection = .shared, gatewayID: String? = nil, windowTitle: String = "OpenClaw Chat", @@ -1054,7 +1027,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { sessionKey: sessionKey, agentID: agentID, initialDraft: initialDraft, - presentation: presentation, connection: connection, cachedRoutingIdentity: context?.routingIdentity, store: context?.store, @@ -1066,7 +1038,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { sessionKey: String, agentID: String?, initialDraft: String? = nil, - presentation: WebChatPresentation, connection: GatewayConnection = .shared, cachedRoutingIdentity: OpenClawChatSessionRoutingIdentity?, store: OpenClawChatSQLiteTranscriptCache?, @@ -1080,7 +1051,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { self.init( sessionKey: sessionKey, initialDraft: initialDraft, - presentation: presentation, transport: MacGatewayChatTransport( connection: connection, outboxGatewayID: store?.gatewayID, @@ -1097,7 +1067,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { init( sessionKey: String, initialDraft: String? = nil, - presentation: WebChatPresentation, transport: any OpenClawChatTransport, initialActiveAgentID: String? = nil, explicitAgentID: String? = nil, @@ -1108,7 +1077,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { windowAutosaveName: String = WebChatSwiftUILayout.windowFrameAutosaveName) { self.sessionKey = sessionKey - self.presentation = presentation let initialActiveAgentID = WebChatRoute.normalizedAgentID(initialActiveAgentID) self.initialActiveAgentID = initialActiveAgentID let voiceNoteRecorder = OpenClawVoiceNoteRecorder() @@ -1191,32 +1159,17 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { } } let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) - switch presentation { - case .window: - // Full window: native split-view shell with sessions sidebar and - // toolbar pickers bridged into the NSToolbar. - let hosting = NSHostingController(rootView: MacChatSurface( - viewModel: vm, - isFullWindow: true, - userAccent: accent, - usesPrimaryAppRuntime: usesPrimaryAppRuntime, - speech: speech, - voiceNoteRecorder: voiceNoteRecorder)) - self.contentController = hosting - case .panel: - // Anchored compact chat panel: single-column chat. - let hosting = NSHostingController(rootView: MacChatSurface( - viewModel: vm, - isFullWindow: false, - userAccent: accent, - usesPrimaryAppRuntime: usesPrimaryAppRuntime, - speech: speech, - voiceNoteRecorder: voiceNoteRecorder)) - self.contentController = Self.makePanelContentController(hosting: hosting) - } + // Full window: native split-view shell with sessions sidebar and + // toolbar pickers bridged into the NSToolbar. + let hosting = NSHostingController(rootView: MacChatSurface( + viewModel: vm, + userAccent: accent, + usesPrimaryAppRuntime: usesPrimaryAppRuntime, + speech: speech, + voiceNoteRecorder: voiceNoteRecorder)) + self.contentController = hosting super.init() self.window = Self.makeWindow( - for: presentation, contentViewController: self.contentController, title: windowTitle, autosaveName: windowAutosaveName) @@ -1226,12 +1179,6 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { } } - deinit {} - - var isVisible: Bool { - self.window?.isVisible ?? false - } - func applyDraftIfEmpty(_ draft: String?) { guard self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, let draft, @@ -1249,8 +1196,7 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { } func cascade(from source: WebChatSwiftUIWindowController?) { - guard case .window = self.presentation, - let window, + guard let window, let sourceWindow = source?.window, sourceWindow !== window else { return } @@ -1260,100 +1206,19 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { display: false) } - func presentAnchored(anchorProvider: () -> NSRect?) { - guard case .panel = self.presentation, let window else { return } - self.installDismissMonitor() - let target = self.reposition(using: anchorProvider) - - if !self.isVisible { - let start = target.offsetBy(dx: 0, dy: 8) - window.setFrame(start, display: true) - window.alphaValue = 0 - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().setFrame(target, display: true) - window.animator().alphaValue = 1 - } - } else { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } - - self.onVisibilityChanged?(true) - } - func close() { - switch self.presentation { - case .window: - self.window?.close() - case .panel: - self.window?.orderOut(nil) - self.onVisibilityChanged?(false) - self.onClosed?() - self.removeDismissMonitor() - } + self.window?.close() } func windowWillClose(_ notification: Notification) { - guard case .window = self.presentation, - notification.object as? NSWindow === self.window - else { return } + guard notification.object as? NSWindow === self.window else { return } self.onVisibilityChanged?(false) - self.removeDismissMonitor() let onClosed = self.onClosed self.onClosed = nil self.window = nil onClosed?() } - @discardableResult - private func reposition(using anchorProvider: () -> NSRect?) -> NSRect { - guard let window else { return .zero } - guard let anchor = anchorProvider() else { - let frame = WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding) - window.setFrame(frame, display: false) - return frame - } - let screen = NSScreen.screens.first { screen in - screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) - } ?? NSScreen.main - let bounds = (screen?.visibleFrame ?? .zero).insetBy( - dx: WebChatSwiftUILayout.anchorPadding, - dy: WebChatSwiftUILayout.anchorPadding) - let frame = WindowPlacement.anchoredBelowFrame( - size: WebChatSwiftUILayout.panelSize, - anchor: anchor, - padding: WebChatSwiftUILayout.anchorPadding, - in: bounds) - window.setFrame(frame, display: false) - return frame - } - - private func installDismissMonitor() { - if ProcessInfo.processInfo.isRunningTests { - return - } - guard self.dismissMonitor == nil, self.window != nil else { return } - self.dismissMonitor = NSEvent.addGlobalMonitorForEvents( - matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) - { [weak self] _ in - guard let self, let win = self.window else { return } - let pt = NSEvent.mouseLocation - if !win.frame.contains(pt) { - self.close() - } - } - } - - private func removeDismissMonitor() { - OverlayPanelFactory.clearGlobalEventMonitor(&self.dismissMonitor) - } - static func persistedThinkingLevel(defaults: UserDefaults = .standard) -> String? { let stored = defaults.string(forKey: webChatThinkingLevelDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) @@ -1390,109 +1255,40 @@ final class WebChatSwiftUIWindowController: NSObject, NSWindowDelegate { } private static func makeWindow( - for presentation: WebChatPresentation, contentViewController: NSViewController, title: String, autosaveName: String) -> NSWindow { - switch presentation { - case .window: - let window = WebChatWindow( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), - styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView], - backing: .buffered, - defer: false) - window.title = title - window.pinnedTitle = title - window.contentViewController = contentViewController - // Attaching an NSHostingController resets scene bridging to `.all`; - // opt back into toolbar items only so SwiftUI cannot restore the title. - (contentViewController as? NSHostingController)? - .sceneBridgingOptions = [.toolbars] - window.isReleasedWhenClosed = false - window.isRestorable = false - // Keep the SwiftUI toolbar controls, but merge their unified row - // with the traffic lights instead of stacking it below a title band. - window.titleVisibility = .hidden - window.titlebarAppearsTransparent = true - window.toolbarStyle = .unified - window.titlebarSeparatorStyle = .none - window.isMovableByWindowBackground = true - window.center() - window.setFrameAutosaveName(autosaveName) - WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) - window.minSize = WebChatSwiftUILayout.windowMinSize - return window - case .panel: - let panel = WebChatPanel( - contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), - styleMask: [.borderless], - backing: .buffered, - defer: false) - panel.level = .statusBar - panel.hidesOnDeactivate = true - panel.hasShadow = true - panel.isMovable = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.backgroundColor = .clear - panel.isOpaque = false - panel.contentViewController = contentViewController - panel.becomesKeyOnlyIfNeeded = true - panel.contentView?.wantsLayer = true - panel.contentView?.layer?.backgroundColor = NSColor.clear.cgColor - panel.setFrame( - WindowPlacement.topRightFrame( - size: WebChatSwiftUILayout.panelSize, - padding: WebChatSwiftUILayout.anchorPadding), - display: false) - return panel - } - } - - private static func makePanelContentController( - hosting: NSHostingController) -> NSViewController - { - let controller = NSViewController() - let effectView = NSVisualEffectView() - effectView.material = .sidebar - effectView.blendingMode = .withinWindow - effectView.state = .active - effectView.wantsLayer = true - effectView.layer?.cornerCurve = .continuous - let cornerRadius: CGFloat = 16 - effectView.layer?.cornerRadius = cornerRadius - effectView.layer?.masksToBounds = true - effectView.layer?.backgroundColor = NSColor.clear.cgColor - - effectView.translatesAutoresizingMaskIntoConstraints = true - effectView.autoresizingMask = [.width, .height] - let rootView = effectView - - hosting.view.translatesAutoresizingMaskIntoConstraints = false - hosting.view.wantsLayer = true - hosting.view.layer?.cornerCurve = .continuous - hosting.view.layer?.cornerRadius = cornerRadius - hosting.view.layer?.masksToBounds = true - hosting.view.layer?.backgroundColor = NSColor.clear.cgColor - - controller.addChild(hosting) - effectView.addSubview(hosting.view) - controller.view = rootView - - NSLayoutConstraint.activate([ - hosting.view.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), - hosting.view.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), - hosting.view.topAnchor.constraint(equalTo: effectView.topAnchor), - hosting.view.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), - ]) - - return controller + let window = WebChatWindow( + contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), + styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false) + window.title = title + window.pinnedTitle = title + window.contentViewController = contentViewController + // Attaching an NSHostingController resets scene bridging to `.all`; + // opt back into toolbar items only so SwiftUI cannot restore the title. + (contentViewController as? NSHostingController)? + .sceneBridgingOptions = [.toolbars] + window.isReleasedWhenClosed = false + window.isRestorable = false + // Keep the SwiftUI toolbar controls, but merge their unified row + // with the traffic lights instead of stacking it below a title band. + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.toolbarStyle = .unified + window.titlebarSeparatorStyle = .none + window.isMovableByWindowBackground = true + window.center() + window.setFrameAutosaveName(autosaveName) + WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) + window.minSize = WebChatSwiftUILayout.windowMinSize + return window } private func ensureWindowSize() { - guard case .window = self.presentation, let window else { return } + guard let window else { return } let current = window.frame.size let min = WebChatSwiftUILayout.windowMinSize if current.width < min.width || current.height < min.height { diff --git a/apps/macos/Sources/OpenClaw/WindowPlacement.swift b/apps/macos/Sources/OpenClaw/WindowPlacement.swift index 18294a39ac21..c2997830803c 100644 --- a/apps/macos/Sources/OpenClaw/WindowPlacement.swift +++ b/apps/macos/Sources/OpenClaw/WindowPlacement.swift @@ -42,28 +42,6 @@ enum WindowPlacement { return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) } - static func anchoredBelowFrame(size: NSSize, anchor: NSRect, padding: CGFloat, in bounds: NSRect) -> NSRect { - if bounds == .zero { - let x = round(anchor.midX - size.width / 2) - let y = round(anchor.minY - size.height - padding) - return NSRect(x: x, y: y, width: size.width, height: size.height) - } - - let clampedWidth = min(size.width, bounds.width) - let clampedHeight = min(size.height, bounds.height) - - let desiredX = round(anchor.midX - clampedWidth / 2) - let desiredY = round(anchor.minY - clampedHeight - padding) - - let maxX = bounds.maxX - clampedWidth - let maxY = bounds.maxY - clampedHeight - - let x = maxX >= bounds.minX ? min(max(desiredX, bounds.minX), maxX) : bounds.minX - let y = maxY >= bounds.minY ? min(max(desiredY, bounds.minY), maxY) : bounds.minY - - return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) - } - static func cascadedFrame(from frame: NSRect, offset: CGFloat = 24, in bounds: NSRect) -> NSRect { guard bounds != .zero else { return frame.offsetBy(dx: offset, dy: -offset) diff --git a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift index ac339a25317a..08e1e623818a 100644 --- a/apps/macos/Sources/OpenClaw/WorkActivityStore.swift +++ b/apps/macos/Sources/OpenClaw/WorkActivityStore.swift @@ -20,7 +20,6 @@ final class WorkActivityStore { private(set) var current: Activity? private(set) var iconState: IconState = .idle - private(set) var lastToolLabel: String? private(set) var lastToolUpdatedAt: Date? private var jobs: [String: Activity] = [:] @@ -63,7 +62,6 @@ final class WorkActivityStore { let toolKind = Self.mapToolKind(name) let label = Self.buildLabel(name: name, meta: meta, args: args) if phase.lowercased() == "start" { - self.lastToolLabel = label self.lastToolUpdatedAt = Date() self.toolSeqBySession[sessionKey, default: 0] += 1 let activity = Activity( diff --git a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift deleted file mode 100644 index f746e078fc32..000000000000 --- a/apps/macos/Tests/OpenClawIPCTests/HoverHUDControllerTests.swift +++ /dev/null @@ -1,26 +0,0 @@ -import AppKit -import Testing -@testable import OpenClaw - -@Suite(.serialized) -@MainActor -struct HoverHUDControllerTests { - @Test func `hover HUD controller presents and dismisses`() async { - let controller = HoverHUDController() - controller.setSuppressed(false) - - controller.statusItemHoverChanged( - inside: true, - anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) - try? await Task.sleep(nanoseconds: 260_000_000) - - controller.panelHoverChanged(inside: true) - controller.panelHoverChanged(inside: false) - controller.statusItemHoverChanged( - inside: false, - anchorProvider: { NSRect(x: 10, y: 10, width: 24, height: 24) }) - - controller.dismiss() - controller.setSuppressed(true) - } -} diff --git a/apps/macos/Tests/OpenClawIPCTests/StatusItemMouseRouterTests.swift b/apps/macos/Tests/OpenClawIPCTests/StatusItemMouseRouterTests.swift index c136db1d2a7d..86cc19e613c6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/StatusItemMouseRouterTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/StatusItemMouseRouterTests.swift @@ -55,13 +55,18 @@ struct StatusItemMouseRouterTests { #expect(rightClicks == 1) } - @Test func `retargets hover tracking without reinstalling the monitor`() throws { + @Test func `retargets clicks without reinstalling the monitor`() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 80, height: 24), + styleMask: .borderless, + backing: .buffered, + defer: false) let firstButton = NSView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - let secondButton = NSView(frame: NSRect(x: 0, y: 0, width: 24, height: 24)) - let firstTrackingAreaCount = firstButton.trackingAreas.count - let secondTrackingAreaCount = secondButton.trackingAreas.count + let secondButton = NSView(frame: NSRect(x: 40, y: 0, width: 24, height: 24)) + window.contentView?.addSubview(firstButton) + window.contentView?.addSubview(secondButton) var monitorInstallCount = 0 - var hoverChanges: [Bool] = [] + var leftClicks = 0 let router = StatusItemMouseRouter( eventMonitorInstaller: { _, _ in monitorInstallCount += 1 @@ -71,23 +76,27 @@ struct StatusItemMouseRouterTests { router.install( on: firstButton, - onLeftClick: {}, - onRightClick: {}, - onHoverChanged: { hoverChanges.append($0) }) - #expect(firstButton.trackingAreas.count == firstTrackingAreaCount + 1) - try router.mouseEntered(with: Self.mouseEvent(.mouseMoved)) - try router.mouseExited(with: Self.mouseEvent(.mouseMoved)) + onLeftClick: { leftClicks += 1 }, + onRightClick: {}) + let firstClick = try Self.mouseEvent( + .leftMouseDown, + location: NSPoint(x: 12, y: 12), + windowNumber: window.windowNumber) + #expect(router.route(firstClick) == nil) + #expect(leftClicks == 1) router.install( on: secondButton, - onLeftClick: {}, - onRightClick: {}, - onHoverChanged: { hoverChanges.append($0) }) + onLeftClick: { leftClicks += 1 }, + onRightClick: {}) #expect(monitorInstallCount == 1) - #expect(firstButton.trackingAreas.count == firstTrackingAreaCount) - #expect(secondButton.trackingAreas.count == secondTrackingAreaCount + 1) - try router.mouseEntered(with: Self.mouseEvent(.mouseMoved)) - #expect(hoverChanges == [true, false, true]) + #expect(router.route(firstClick) === firstClick) + let secondClick = try Self.mouseEvent( + .leftMouseDown, + location: NSPoint(x: 52, y: 12), + windowNumber: window.windowNumber) + #expect(router.route(secondClick) == nil) + #expect(leftClicks == 2) } @Test func `non-target and unrelated events continue to native dispatch`() throws { @@ -113,13 +122,17 @@ struct StatusItemMouseRouterTests { #expect(rightClicks == 0) } - private static func mouseEvent(_ type: NSEvent.EventType) throws -> NSEvent { + private static func mouseEvent( + _ type: NSEvent.EventType, + location: NSPoint = .zero, + windowNumber: Int = 0) throws -> NSEvent + { try #require(NSEvent.mouseEvent( with: type, - location: .zero, + location: location, modifierFlags: [], timestamp: 0, - windowNumber: 0, + windowNumber: windowNumber, context: nil, eventNumber: 1, clickCount: 1, diff --git a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift index f7540f673e5a..00516dc65a23 100644 --- a/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/WebChatSwiftUISmokeTests.swift @@ -143,7 +143,6 @@ struct WebChatSwiftUISmokeTests { } let controller = WebChatSwiftUIWindowController( sessionKey: "main", - presentation: .window, transport: TestTransport(), windowTitle: "Studio — OpenClaw") let window = try #require(controller._testWindow) @@ -172,20 +171,9 @@ struct WebChatSwiftUISmokeTests { controller.close() } - @Test func `panel controller present and close`() { - let anchor = { NSRect(x: 200, y: 400, width: 40, height: 40) } - let controller = WebChatSwiftUIWindowController( - sessionKey: "main", - presentation: .panel(anchorProvider: anchor), - transport: TestTransport()) - controller.presentAnchored(anchorProvider: anchor) - controller.close() - } - @Test func `closing a full window releases it and notifies its owner once`() { let controller = WebChatSwiftUIWindowController( sessionKey: "main", - presentation: .window, transport: TestTransport()) var closeCount = 0 var visibilityChanges: [Bool] = [] @@ -222,7 +210,6 @@ struct WebChatSwiftUISmokeTests { let controller = WebChatSwiftUIWindowController( sessionKey: "main", initialDraft: "Wake up, my friend!", - presentation: .window, transport: TestTransport()) #expect(controller._testDraft == "Wake up, my friend!") @@ -239,13 +226,11 @@ struct WebChatSwiftUISmokeTests { let explicit = WebChatSwiftUIWindowController( sessionKey: "global", agentID: " Work ", - presentation: .window, cachedRoutingIdentity: cachedIdentity, store: nil) let fallback = WebChatSwiftUIWindowController( sessionKey: "global", agentID: nil, - presentation: .window, cachedRoutingIdentity: cachedIdentity, store: nil)