diff --git a/apps/macos/Sources/Clawdis/AppState.swift b/apps/macos/Sources/Clawdis/AppState.swift index 90ac6f746..9619089f6 100644 --- a/apps/macos/Sources/Clawdis/AppState.swift +++ b/apps/macos/Sources/Clawdis/AppState.swift @@ -153,6 +153,10 @@ final class AppState: ObservableObject { didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatEnabled, forKey: webChatEnabledKey) } } } + @Published var webChatSwiftUIEnabled: Bool { + didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatSwiftUIEnabled, forKey: webChatSwiftUIEnabledKey) } } + } + @Published var webChatPort: Int { didSet { self.ifNotPreview { UserDefaults.standard.set(self.webChatPort, forKey: webChatPortKey) } } } @@ -231,6 +235,7 @@ final class AppState: ObservableObject { self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? "" self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? "" self.webChatEnabled = UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true + self.webChatSwiftUIEnabled = UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false let storedPort = UserDefaults.standard.integer(forKey: webChatPortKey) self.webChatPort = storedPort > 0 ? storedPort : 18788 @@ -343,6 +348,7 @@ extension AppState { state.heartbeatsEnabled = true state.connectionMode = .local state.webChatEnabled = true + state.webChatSwiftUIEnabled = false state.webChatPort = 18788 state.remoteTarget = "user@example.com" state.remoteIdentity = "~/.ssh/id_ed25519" @@ -366,6 +372,10 @@ enum AppStateStore { UserDefaults.standard.object(forKey: webChatEnabledKey) as? Bool ?? true } + static var webChatSwiftUIEnabled: Bool { + UserDefaults.standard.object(forKey: webChatSwiftUIEnabledKey) as? Bool ?? false + } + static var webChatPort: Int { let stored = UserDefaults.standard.integer(forKey: webChatPortKey) return stored > 0 ? stored : 18788 diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index 8de1bdabf..da2849c9f 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -24,6 +24,7 @@ let remoteTargetKey = "clawdis.remoteTarget" let remoteIdentityKey = "clawdis.remoteIdentity" let remoteProjectRootKey = "clawdis.remoteProjectRoot" let webChatEnabledKey = "clawdis.webChatEnabled" +let webChatSwiftUIEnabledKey = "clawdis.webChatSwiftUIEnabled" let webChatPortKey = "clawdis.webChatPort" let modelCatalogPathKey = "clawdis.modelCatalogPath" let modelCatalogReloadKey = "clawdis.modelCatalogReload" diff --git a/apps/macos/Sources/Clawdis/DebugSettings.swift b/apps/macos/Sources/Clawdis/DebugSettings.swift index e63ec85fd..a639c9cb6 100644 --- a/apps/macos/Sources/Clawdis/DebugSettings.swift +++ b/apps/macos/Sources/Clawdis/DebugSettings.swift @@ -22,6 +22,7 @@ struct DebugSettings: View { @State private var portReports: [DebugActions.PortReport] = [] @State private var portKillStatus: String? @State private var pendingKill: DebugActions.PortListener? + @AppStorage(webChatSwiftUIEnabledKey) private var webChatSwiftUIEnabled: Bool = false var body: some View { ScrollView(.vertical) { @@ -217,6 +218,9 @@ struct DebugSettings: View { .foregroundStyle(.tertiary) } } + Toggle("Use SwiftUI web chat (glass, gateway WS)", isOn: self.$webChatSwiftUIEnabled) + .toggleStyle(.switch) + .help("When enabled, the menu bar chat window/panel uses the native SwiftUI UI instead of the bundled WKWebView.") Button("Send Test Notification") { Task { await DebugActions.sendTestNotification() } } diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift new file mode 100644 index 000000000..e9fe9ec3b --- /dev/null +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -0,0 +1,602 @@ +import AppKit +import ClawdisProtocol +import OSLog +import SwiftUI +import UniformTypeIdentifiers + +extension GatewayFrame: @unchecked Sendable {} +extension EventFrame: @unchecked Sendable {} + +private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI") + +// MARK: - Models + +struct GatewayChatMessageContent: Codable { + let type: String? + let text: String? + let mimeType: String? + let fileName: String? + let content: String? +} + +struct GatewayChatMessage: Codable, Identifiable { + var id: UUID = .init() + let role: String + let content: [GatewayChatMessageContent]? + let timestamp: Double? + + enum CodingKeys: String, CodingKey { + case role, content, timestamp + } +} + +struct ChatHistoryPayload: Codable { + let sessionKey: String + let sessionId: String? + let messages: [GatewayChatMessage]? + let thinkingLevel: String? +} + +struct ChatSendResponse: Codable { + let runId: String + let status: String +} + +struct ChatEventPayload: Codable { + let runId: String? + let sessionKey: String? + let state: String? + let message: GatewayChatMessage? + let errorMessage: String? +} + +struct PendingAttachment: Identifiable { + let id = UUID() + let url: URL? + let data: Data + let fileName: String + let mimeType: String + let type: String = "file" +} + +// MARK: - View model + +@MainActor +final class WebChatViewModel: ObservableObject { + @Published var messages: [GatewayChatMessage] = [] + @Published var input: String = "" + @Published var thinkingLevel: String = "off" + @Published var isLoading = false + @Published var isSending = false + @Published var errorText: String? + @Published var attachments: [PendingAttachment] = [] + @Published var healthOK: Bool = true + + private let sessionKey: String + private let gateway = GatewayChannel() + private var gatewayConfigured = false + private var eventToken: NSObjectProtocol? + private var pendingRuns = Set() + private var currentPort: Int? + + init(sessionKey: String) { + self.sessionKey = sessionKey + self.eventToken = NotificationCenter.default.addObserver( + forName: .gatewayEvent, + object: nil, + queue: .main) + { [weak self] note in + guard let frame = note.object as? GatewayFrame else { return } + Task { @MainActor in + self?.handleGatewayFrame(frame) + } + } + } + + deinit { + // Intentionally no cleanup; NotificationCenter observer is weakly captured and drops with this instance. + } + + func load() { + Task { await self.bootstrap() } + } + + func send() { + Task { await self.performSend() } + } + + func addAttachments(urls: [URL]) { + Task { + for url in urls { + guard let data = try? Data(contentsOf: url) else { continue } + guard data.count <= 5_000_000 else { + await MainActor.run { self.errorText = "Attachment \(url.lastPathComponent) exceeds 5 MB limit" } + continue + } + let uti = UTType(filenameExtension: url.pathExtension) ?? .data + let mime = uti.preferredMIMEType ?? "application/octet-stream" + let att = PendingAttachment( + url: url, + data: data, + fileName: url.lastPathComponent, + mimeType: mime) + await MainActor.run { self.attachments.append(att) } + } + } + } + + func removeAttachment(_ id: PendingAttachment.ID) { + self.attachments.removeAll { $0.id == id } + } + + // MARK: Internals + + private func bootstrap() async { + self.isLoading = true + defer { self.isLoading = false } + do { + try await self.ensureGatewayConfigured() + let payload = try await self.requestHistory() + self.messages = payload.messages ?? [] + if let level = payload.thinkingLevel, !level.isEmpty { + self.thinkingLevel = level + } + } catch { + self.errorText = error.localizedDescription + webChatSwiftLogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)") + } + } + + private func performSend() async { + guard !self.isSending else { return } + let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } + do { + try await self.ensureGatewayConfigured() + } catch { + self.errorText = error.localizedDescription + return + } + + self.isSending = true + self.errorText = nil + let runId = UUID().uuidString + + // Optimistically append user message to UI + let userMessage = GatewayChatMessage( + id: UUID(), + role: "user", + content: [ + GatewayChatMessageContent( + type: "text", + text: trimmed, + mimeType: nil, + fileName: nil, + content: nil) + ], + timestamp: Date().timeIntervalSince1970 * 1000) + self.messages.append(userMessage) + + let encodedAttachments = self.attachments.map { att in + [ + "type": att.type, + "mimeType": att.mimeType, + "fileName": att.fileName, + "content": att.data.base64EncodedString() + ] + } + + do { + let attachmentsPayload: [[String: String]]? = encodedAttachments.isEmpty ? nil : encodedAttachments + let params: [String: AnyCodable] = [ + "sessionKey": AnyCodable(self.sessionKey), + "message": AnyCodable(trimmed), + "attachments": AnyCodable(attachmentsPayload as Any), + "thinking": AnyCodable(self.thinkingLevel), + "idempotencyKey": AnyCodable(runId), + "timeoutMs": AnyCodable(30_000) + ] + let data = try await self.gateway.request(method: "chat.send", params: params) + let response = try JSONDecoder().decode(ChatSendResponse.self, from: data) + self.pendingRuns.insert(response.runId) + } catch { + self.errorText = error.localizedDescription + webChatSwiftLogger.error("chat.send failed \(error.localizedDescription, privacy: .public)") + } + + self.input = "" + self.attachments = [] + self.isSending = false + } + + private func ensureGatewayConfigured() async throws { + guard !self.gatewayConfigured else { return } + let port = try await self.resolveGatewayPort() + self.currentPort = port + let url = URL(string: "ws://127.0.0.1:\(port)")! + let token = ProcessInfo.processInfo.environment["CLAWDIS_GATEWAY_TOKEN"] + await self.gateway.configure(url: url, token: token) + self.gatewayConfigured = true + } + + private func resolveGatewayPort() async throws -> Int { + if CommandResolver.connectionModeIsRemote() { + let forwarded = try await RemoteTunnelManager.shared.ensureControlTunnel() + return Int(forwarded) + } + return GatewayEnvironment.gatewayPort() + } + + private func requestHistory() async throws -> ChatHistoryPayload { + let data = try await self.gateway.request( + method: "chat.history", + params: ["sessionKey": AnyCodable(self.sessionKey)]) + return try JSONDecoder().decode(ChatHistoryPayload.self, from: data) + } + + private func handleGatewayFrame(_ frame: GatewayFrame) { + guard case let .event(evt) = frame, evt.event == "chat" else { return } + guard let payload = evt.payload else { return } + guard let data = try? JSONEncoder().encode(payload) else { return } + guard let chat = try? JSONDecoder().decode(ChatEventPayload.self, from: data) else { return } + guard chat.sessionKey == nil || chat.sessionKey == self.sessionKey else { return } + + if let runId = chat.runId, !self.pendingRuns.contains(runId) { + // Ignore events for other runs + return + } + + switch chat.state { + case "final": + if let msg = chat.message { + self.messages.append(msg) + } + if let runId = chat.runId { + self.pendingRuns.remove(runId) + } + case "error": + self.errorText = chat.errorMessage ?? "Chat failed" + if let runId = chat.runId { + self.pendingRuns.remove(runId) + } + default: + break + } + } +} + +// MARK: - View + +struct WebChatView: View { + @StateObject var viewModel: WebChatViewModel + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.12, green: 0.17, blue: 0.28), + Color(red: 0.06, green: 0.07, blue: 0.11) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing) + .overlay(.ultraThinMaterial) + .ignoresSafeArea() + + VStack(spacing: 10) { + header + messageList + composer + } + .padding(12) + } + .onAppear { viewModel.load() } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Clawdis Chat") + .font(.title3.weight(.semibold)) + Text("Session \(self.viewModel.thinkingLevel.uppercased()) · Gateway") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if self.viewModel.isLoading { + ProgressView().controlSize(.small) + } else { + Circle() + .fill(self.viewModel.healthOK ? Color.green.opacity(0.7) : Color.orange) + .frame(width: 10, height: 10) + } + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var messageList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(self.viewModel.messages) { msg in + MessageBubble(message: msg) + } + } + .padding(.vertical, 8) + } + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + + private var composer: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + thinkingPicker + Spacer() + Button { + self.pickFiles() + } label: { + Label("Add File", systemImage: "paperclip") + } + .buttonStyle(.bordered) + } + if !self.viewModel.attachments.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 6) { + ForEach(self.viewModel.attachments) { att in + HStack(spacing: 6) { + Image(systemName: "doc") + Text(att.fileName) + .lineLimit(1) + Button { + self.viewModel.removeAttachment(att.id) + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + } + } + } + } + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + TextEditor(text: self.$viewModel.input) + .background(Color.clear) + .frame(minHeight: 80, maxHeight: 140) + .padding(6) + ) + .frame(maxHeight: 160) + + HStack { + if let error = self.viewModel.errorText { + Text(error) + .font(.footnote) + .foregroundStyle(.red) + } + Spacer() + Button { + self.viewModel.send() + } label: { + Label(self.viewModel.isSending ? "Sending…" : "Send", systemImage: "arrow.up.circle.fill") + .font(.headline) + } + .buttonStyle(.borderedProminent) + .disabled(self.viewModel.isSending) + } + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + self.handleDrop(providers) + } + } + + private var thinkingPicker: some View { + Picker("Thinking", selection: self.$viewModel.thinkingLevel) { + Text("Off").tag("off") + Text("Low").tag("low") + Text("Medium").tag("medium") + Text("High").tag("high") + } + .pickerStyle(.segmented) + .frame(maxWidth: 260) + } + + private func pickFiles() { + let panel = NSOpenPanel() + panel.title = "Select attachments" + panel.allowsMultipleSelection = true + panel.canChooseDirectories = false + panel.begin { resp in + guard resp == .OK else { return } + let urls = panel.urls + self.viewModel.addAttachments(urls: urls) + } + } + + private func handleDrop(_ providers: [NSItemProvider]) -> Bool { + let fileProviders = providers.filter { $0.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) } + guard !fileProviders.isEmpty else { return false } + for item in fileProviders { + item.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { return } + Task { await self.viewModel.addAttachments(urls: [url]) } + } + } + return true + } +} + +private struct MessageBubble: View { + let message: GatewayChatMessage + + var body: some View { + VStack(alignment: self.isUser ? .trailing : .leading, spacing: 6) { + HStack { + if !self.isUser { Text("Assistant").font(.caption).foregroundStyle(.secondary) } + Spacer() + if self.isUser { Text("You").font(.caption).foregroundStyle(.secondary) } + } + VStack(alignment: .leading, spacing: 6) { + if let text = self.primaryText { + Text(text) + .font(.body) + .foregroundColor(.primary) + .multilineTextAlignment(self.isUser ? .trailing : .leading) + } + if let attachments = self.attachments { + ForEach(attachments.indices, id: \.self) { idx in + let att = attachments[idx] + HStack(spacing: 6) { + Image(systemName: "paperclip") + Text(att.fileName ?? "Attachment") + .font(.footnote) + .lineLimit(1) + } + .padding(8) + .background(Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } + .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) + .padding(12) + .background(self.isUser ? Color.white.opacity(0.12) : Color.white.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + } + .padding(.horizontal, 6) + } + + private var isUser: Bool { self.message.role.lowercased() == "user" } + + private var primaryText: String? { + self.message.content? + .compactMap { $0.text } + .joined(separator: "\n") + } + + private var attachments: [GatewayChatMessageContent]? { + self.message.content?.filter { ($0.type ?? "") != "text" } + } +} + +// MARK: - Window controller + +@MainActor +final class WebChatSwiftUIWindowController { + private let presentation: WebChatPresentation + private let sessionKey: String + private let hosting: NSHostingController + private var window: NSWindow? + private var dismissMonitor: Any? + var onClosed: (() -> Void)? + var onVisibilityChanged: ((Bool) -> Void)? + + init(sessionKey: String, presentation: WebChatPresentation) { + self.sessionKey = sessionKey + self.presentation = presentation + let vm = WebChatViewModel(sessionKey: sessionKey) + self.hosting = NSHostingController(rootView: WebChatView(viewModel: vm)) + self.window = Self.makeWindow(for: presentation, contentViewController: hosting) + } + + deinit {} + + var isVisible: Bool { + self.window?.isVisible ?? false + } + + func show() { + guard let window else { return } + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.onVisibilityChanged?(true) + } + + func presentAnchored(anchorProvider: () -> NSRect?) { + guard case .panel = self.presentation, let window else { return } + self.reposition(using: anchorProvider) + self.installDismissMonitor() + window.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + self.onVisibilityChanged?(true) + } + + func close() { + self.window?.orderOut(nil) + self.onVisibilityChanged?(false) + self.onClosed?() + self.removeDismissMonitor() + } + + private func reposition(using anchorProvider: () -> NSRect?) { + guard let window else { return } + guard let anchor = anchorProvider() else { return } + var frame = window.frame + frame.origin.x = round(anchor.midX - frame.width / 2) + frame.origin.y = anchor.minY - frame.height + window.setFrame(frame, display: false) + } + + private func installDismissMonitor() { + 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(x: 0, y: 0, width: 960, height: 720), + styleMask: [.titled, .closable, .resizable, .miniaturizable], + backing: .buffered, + defer: false) + window.title = "Clawdis Chat (SwiftUI)" + window.contentViewController = contentViewController + window.isReleasedWhenClosed = false + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.backgroundColor = .clear + window.isOpaque = false + return window + case .panel: + let panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 440, height: 580), + styleMask: [.nonactivatingPanel, .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 + return panel + } + } +} diff --git a/apps/macos/Sources/Clawdis/WebChatWindow.swift b/apps/macos/Sources/Clawdis/WebChatWindow.swift index 990c50f22..baeb10081 100644 --- a/apps/macos/Sources/Clawdis/WebChatWindow.swift +++ b/apps/macos/Sources/Clawdis/WebChatWindow.swift @@ -473,53 +473,94 @@ final class WebChatManager { static let shared = WebChatManager() private var windowController: WebChatWindowController? private var panelController: WebChatWindowController? + private var swiftWindowController: WebChatSwiftUIWindowController? + private var swiftPanelController: WebChatSwiftUIWindowController? private var browserTunnel: WebChatTunnel? var onPanelVisibilityChanged: ((Bool) -> Void)? func show(sessionKey: String) { self.closePanel() - if let controller = self.windowController { + if AppStateStore.webChatSwiftUIEnabled { + if let controller = self.swiftWindowController { + controller.show() + return + } + let controller = WebChatSwiftUIWindowController(sessionKey: sessionKey, presentation: .window) + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.swiftWindowController = controller + controller.show() + } else { + if let controller = self.windowController { + controller.showWindow(nil) + controller.window?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let controller = WebChatWindowController(sessionKey: sessionKey) + self.windowController = controller controller.showWindow(nil) controller.window?.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) - return } - - let controller = WebChatWindowController(sessionKey: sessionKey) - self.windowController = controller - controller.showWindow(nil) - controller.window?.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) } func togglePanel(sessionKey: String, anchorProvider: @escaping () -> NSRect?) { - if let controller = self.panelController { - if controller.window?.isVisible == true { - controller.closePanel() - } else { - controller.presentAnchoredPanel(anchorProvider: anchorProvider) + if AppStateStore.webChatSwiftUIEnabled { + if let controller = self.swiftPanelController { + if controller.isVisible { + controller.close() + } else { + controller.presentAnchored(anchorProvider: anchorProvider) + } + return + } + let controller = WebChatSwiftUIWindowController( + sessionKey: sessionKey, + presentation: .panel(anchorProvider: anchorProvider)) + controller.onClosed = { [weak self] in + self?.panelHidden() + } + controller.onVisibilityChanged = { [weak self] visible in + self?.onPanelVisibilityChanged?(visible) + } + self.swiftPanelController = controller + controller.presentAnchored(anchorProvider: anchorProvider) + } else { + if let controller = self.panelController { + if controller.window?.isVisible == true { + controller.closePanel() + } else { + controller.presentAnchoredPanel(anchorProvider: anchorProvider) + } + return } - return - } - let controller = WebChatWindowController( - sessionKey: sessionKey, - presentation: .panel(anchorProvider: anchorProvider)) - self.panelController = controller - controller.onPanelClosed = { [weak self] in - self?.panelHidden() + let controller = WebChatWindowController( + sessionKey: sessionKey, + presentation: .panel(anchorProvider: anchorProvider)) + self.panelController = controller + controller.onPanelClosed = { [weak self] in + self?.panelHidden() + } + controller.onVisibilityChanged = { [weak self] visible in + guard let self else { return } + self.onPanelVisibilityChanged?(visible) + } + controller.presentAnchoredPanel(anchorProvider: anchorProvider) + // visibility will be reported by the controller callback } - controller.onVisibilityChanged = { [weak self] visible in - guard let self else { return } - self.onPanelVisibilityChanged?(visible) - } - controller.presentAnchoredPanel(anchorProvider: anchorProvider) - // visibility will be reported by the controller callback } func closePanel() { - guard let controller = self.panelController else { return } - controller.closePanel() + if let controller = self.panelController { + controller.closePanel() + } + if let controller = self.swiftPanelController { + controller.close() + } } func preferredSessionKey() -> String { @@ -550,6 +591,10 @@ final class WebChatManager { self.panelController?.shutdown() self.panelController?.close() self.panelController = nil + self.swiftWindowController?.close() + self.swiftWindowController = nil + self.swiftPanelController?.close() + self.swiftPanelController = nil } @MainActor @@ -594,11 +639,17 @@ final class WebChatManager { self.panelController?.shutdown() self.panelController?.close() self.panelController = nil + + self.swiftWindowController?.close() + self.swiftWindowController = nil + self.swiftPanelController?.close() + self.swiftPanelController = nil } private func panelHidden() { self.onPanelVisibilityChanged?(false) self.panelController = nil + self.swiftPanelController = nil } } diff --git a/docs/mac/webchat.md b/docs/mac/webchat.md index 9189d1325..cb84fefa4 100644 --- a/docs/mac/webchat.md +++ b/docs/mac/webchat.md @@ -17,6 +17,7 @@ The macOS menu bar app opens the gateway’s loopback web chat server in a WKWeb - Assets: `apps/macos/Sources/Clawdis/Resources/WebChat/` contains the `pi-web-ui` dist plus a local import map pointing at bundled vendor modules and a tiny `pi-ai` stub. Everything is served from the static host at `/` (legacy `/webchat/*` still works). - Bridge: none. The web UI connects directly to the Gateway WebSocket (default 18789) and uses `chat.history`/`chat.send` plus `chat/presence/tick/health` events. No `/rpc` or file-watcher socket path remains. - Session: always primary; multiple transports (WhatsApp/Telegram/Desktop) share the same session key so context is unified. +- Debug-only: a native SwiftUI “glass” chat UI (same WS transport, attachments + thinking selector) can replace the WKWebView. Enable it via Debug → “Use SwiftUI web chat (glass, gateway WS)” (default off). ## Security / surface area - Loopback server only; remote mode uses SSH port-forwarding from the gateway host to the Mac. CSP is set to `default-src 'self' 'unsafe-inline' data: blob:`.