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