import AppKit import ClawdbotChatUI import ClawdbotProtocol import Foundation import OSLog import QuartzCore import SwiftUI private let webChatSwiftLogger = Logger(subsystem: "com.clawdbot", category: "WebChatSwiftUI") private enum WebChatSwiftUILayout { static let windowSize = NSSize(width: 500, height: 840) static let panelSize = NSSize(width: 480, height: 640) static let windowMinSize = NSSize(width: 480, height: 360) static let anchorPadding: CGFloat = 8 } struct MacGatewayChatTransport: ClawdbotChatTransport, Sendable { func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload { try await GatewayConnection.shared.chatHistory(sessionKey: sessionKey) } func abortRun(sessionKey: String, runId: String) async throws { _ = try await GatewayConnection.shared.request( method: "chat.abort", params: [ "sessionKey": AnyCodable(sessionKey), "runId": AnyCodable(runId), ], timeoutMs: 10000) } func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse { var params: [String: AnyCodable] = [ "includeGlobal": AnyCodable(true), "includeUnknown": AnyCodable(false), ] if let limit { params["limit"] = AnyCodable(limit) } let data = try await GatewayConnection.shared.request( method: "sessions.list", params: params, timeoutMs: 15000) return try JSONDecoder().decode(ClawdbotChatSessionsListResponse.self, from: data) } func sendMessage( sessionKey: String, message: String, thinking: String, idempotencyKey: String, attachments: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse { try await GatewayConnection.shared.chatSend( sessionKey: sessionKey, message: message, thinking: thinking, idempotencyKey: idempotencyKey, attachments: attachments) } func requestHealth(timeoutMs: Int) async throws -> Bool { try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } func events() -> AsyncStream { AsyncStream { continuation in let task = Task { do { try await GatewayConnection.shared.refresh() } catch { webChatSwiftLogger.error("gateway refresh failed \(error.localizedDescription, privacy: .public)") } let stream = await GatewayConnection.shared.subscribe() for await push in stream { if Task.isCancelled { return } if let evt = Self.mapPushToTransportEvent(push) { continuation.yield(evt) } } } continuation.onTermination = { @Sendable _ in task.cancel() } } } static func mapPushToTransportEvent(_ push: GatewayPush) -> ClawdbotChatTransportEvent? { switch push { case let .snapshot(hello): let ok = (try? JSONDecoder().decode( ClawdbotGatewayHealthOK.self, from: JSONEncoder().encode(hello.snapshot.health)))?.ok ?? true return .health(ok: ok) case let .event(evt): switch evt.event { case "health": guard let payload = evt.payload else { return nil } let ok = (try? JSONDecoder().decode( ClawdbotGatewayHealthOK.self, from: JSONEncoder().encode(payload)))?.ok ?? true return .health(ok: ok) case "tick": return .tick case "chat": guard let payload = evt.payload else { return nil } guard let chat = try? JSONDecoder().decode( ClawdbotChatEventPayload.self, from: JSONEncoder().encode(payload)) else { return nil } return .chat(chat) case "agent": guard let payload = evt.payload else { return nil } guard let agent = try? JSONDecoder().decode( ClawdbotAgentEventPayload.self, from: JSONEncoder().encode(payload)) else { return nil } return .agent(agent) default: return nil } case .seqGap: return .seqGap } } } // MARK: - Window controller @MainActor final class WebChatSwiftUIWindowController { private let presentation: WebChatPresentation private let sessionKey: String private let hosting: NSHostingController private let contentController: NSViewController private var window: NSWindow? private var dismissMonitor: Any? var onClosed: (() -> Void)? var onVisibilityChanged: ((Bool) -> Void)? convenience init(sessionKey: String, presentation: WebChatPresentation) { self.init(sessionKey: sessionKey, presentation: presentation, transport: MacGatewayChatTransport()) } init(sessionKey: String, presentation: WebChatPresentation, transport: any ClawdbotChatTransport) { self.sessionKey = sessionKey self.presentation = presentation let vm = ClawdbotChatViewModel(sessionKey: sessionKey, transport: transport) let accent = Self.color(fromHex: AppStateStore.shared.seamColorHex) self.hosting = NSHostingController(rootView: ClawdbotChatView( viewModel: vm, showsSessionSwitcher: true, userAccent: accent)) self.contentController = Self.makeContentController(for: presentation, hosting: self.hosting) self.window = Self.makeWindow(for: presentation, contentViewController: self.contentController) } deinit {} var isVisible: Bool { self.window?.isVisible ?? false } func show() { guard let window else { return } self.ensureWindowSize() window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) self.onVisibilityChanged?(true) } 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() { self.window?.orderOut(nil) self.onVisibilityChanged?(false) self.onClosed?() self.removeDismissMonitor() } @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() { if let monitor = self.dismissMonitor { NSEvent.removeMonitor(monitor) self.dismissMonitor = nil } } private static func makeWindow( for presentation: WebChatPresentation, contentViewController: NSViewController) -> NSWindow { switch presentation { case .window: let window = NSWindow( contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) window.title = "Clawdbot Chat" window.contentViewController = contentViewController window.isReleasedWhenClosed = false window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.backgroundColor = .clear window.isOpaque = false window.center() WindowPlacement.ensureOnScreen(window: window, defaultSize: WebChatSwiftUILayout.windowSize) window.minSize = WebChatSwiftUILayout.windowMinSize window.contentView?.wantsLayer = true window.contentView?.layer?.backgroundColor = NSColor.clear.cgColor 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 makeContentController( for presentation: WebChatPresentation, hosting: NSHostingController) -> NSViewController { let controller = NSViewController() let effectView = NSVisualEffectView() effectView.material = .sidebar effectView.blendingMode = .behindWindow effectView.state = .active effectView.wantsLayer = true effectView.layer?.cornerCurve = .continuous let cornerRadius: CGFloat = switch presentation { case .panel: 16 case .window: 0 } effectView.layer?.cornerRadius = cornerRadius effectView.layer?.masksToBounds = true effectView.translatesAutoresizingMaskIntoConstraints = true effectView.autoresizingMask = [.width, .height] let rootView = effectView hosting.view.translatesAutoresizingMaskIntoConstraints = false hosting.view.wantsLayer = 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 } private func ensureWindowSize() { guard case .window = self.presentation, let window else { return } let current = window.frame.size let min = WebChatSwiftUILayout.windowMinSize if current.width < min.width || current.height < min.height { let frame = WindowPlacement.centeredFrame(size: WebChatSwiftUILayout.windowSize) window.setFrame(frame, display: false) } } private static func color(fromHex raw: String?) -> Color? { let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil } let r = Double((value >> 16) & 0xFF) / 255.0 let g = Double((value >> 8) & 0xFF) / 255.0 let b = Double(value & 0xFF) / 255.0 return Color(red: r, green: g, blue: b) } }