Files
clawdbot/apps/macos/Sources/Clawdbot/WebChatSwiftUI.swift
2026-01-20 17:27:45 +00:00

375 lines
14 KiB
Swift

import AppKit
import ClawdbotChatUI
import ClawdbotKit
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<ClawdbotChatTransportEvent> {
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<ClawdbotChatView>
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<ClawdbotChatView>) -> 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)
}
}