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)