import AppKit import ClawdisProtocol import OSLog import SwiftUI import UniformTypeIdentifiers private let webChatSwiftLogger = Logger(subsystem: "com.steipete.clawdis", category: "WebChatSwiftUI") private enum WebChatSwiftUILayout { static let windowSize = NSSize(width: 1120, height: 840) static let panelSize = NSSize(width: 480, height: 640) static let anchorPadding: CGFloat = 8 } // MARK: - Models struct GatewayChatMessageContent: Codable, Hashable { 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 } init( id: UUID = .init(), role: String, content: [GatewayChatMessageContent], timestamp: Double?) { self.id = id self.role = role self.content = content self.timestamp = timestamp } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.role = try container.decode(String.self, forKey: .role) self.timestamp = try container.decodeIfPresent(Double.self, forKey: .timestamp) if let decoded = try? container.decode([GatewayChatMessageContent].self, forKey: .content) { self.content = decoded return } // Some session log formats store `content` as a plain string. if let text = try? container.decode(String.self, forKey: .content) { self.content = [ GatewayChatMessageContent( type: "text", text: text, mimeType: nil, fileName: nil, content: nil), ] return } self.content = [] } } struct ChatHistoryPayload: Codable { let sessionKey: String let sessionId: String? let messages: [ClawdisProtocol.AnyCodable]? 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: ClawdisProtocol.AnyCodable? let errorMessage: String? } struct GatewayHealthOK: Codable { let ok: Bool? } struct PendingAttachment: Identifiable { let id = UUID() let url: URL? let data: Data let fileName: String let mimeType: String let type: String = "file" let preview: NSImage? } // 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 @Published var pendingRunCount: Int = 0 let sessionKey: String private var eventTask: Task? private var pendingRuns = Set() { didSet { self.pendingRunCount = self.pendingRuns.count } } private var lastHealthPollAt: Date? init(sessionKey: String) { self.sessionKey = sessionKey self.eventTask = Task { [weak self] in guard let self else { return } let stream = await GatewayConnection.shared.subscribe() for await push in stream { if Task.isCancelled { return } await MainActor.run { [weak self] in self?.handleGatewayPush(push) } } } } deinit { self.eventTask?.cancel() } func load() { Task { await self.bootstrap() } } func refresh() { Task { await self.bootstrap() } } func send() { Task { await self.performSend() } } func addAttachments(urls: [URL]) { Task { for url in urls { do { let data = try await Task.detached { try Data(contentsOf: url) }.value 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 guard uti.conforms(to: .image) else { await MainActor.run { self.errorText = "Only image attachments are supported right now" } continue } let mime = uti.preferredMIMEType ?? "application/octet-stream" let preview = NSImage(data: data) let att = PendingAttachment( url: url, data: data, fileName: url.lastPathComponent, mimeType: mime, preview: preview) await MainActor.run { self.attachments.append(att) } } catch { await MainActor.run { self.errorText = error.localizedDescription } } } } } func removeAttachment(_ id: PendingAttachment.ID) { self.attachments.removeAll { $0.id == id } } var canSend: Bool { let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty) } // MARK: Internals private func bootstrap() async { self.isLoading = true defer { self.isLoading = false } do { let payload = try await self.requestHistory() self.messages = payload.messages if let level = payload.thinkingLevel, !level.isEmpty { self.thinkingLevel = level } await self.pollHealthIfNeeded(force: true) } 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 } guard self.healthOK else { self.errorText = "Gateway health not OK; cannot send" return } self.isSending = true self.errorText = nil let runId = UUID().uuidString let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed // Optimistically append user message to UI var userContent: [GatewayChatMessageContent] = [ GatewayChatMessageContent( type: "text", text: messageText, mimeType: nil, fileName: nil, content: nil), ] for att in self.attachments { userContent.append( GatewayChatMessageContent( type: att.type, text: nil, mimeType: att.mimeType, fileName: att.fileName, content: att.data.base64EncodedString())) } let userMessage = GatewayChatMessage( id: UUID(), role: "user", content: userContent, 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 { var params: [String: AnyCodable] = [ "sessionKey": AnyCodable(self.sessionKey), "message": AnyCodable(messageText), "thinking": AnyCodable(self.thinkingLevel), "idempotencyKey": AnyCodable(runId), "timeoutMs": AnyCodable(30000), ] if !encodedAttachments.isEmpty { params["attachments"] = AnyCodable(encodedAttachments) } let data = try await GatewayConnection.shared.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 requestHistory() async throws -> (messages: [GatewayChatMessage], thinkingLevel: String?) { let data = try await GatewayConnection.shared.request( method: "chat.history", params: ["sessionKey": AnyCodable(self.sessionKey)]) let payload = try JSONDecoder().decode(ChatHistoryPayload.self, from: data) let messages: [GatewayChatMessage] = (payload.messages ?? []).compactMap { raw in (try? GatewayPayloadDecoding.decode(raw, as: GatewayChatMessage.self)) } return (messages, payload.thinkingLevel) } private func handleGatewayPush(_ push: GatewayPush) { switch push { case let .snapshot(hello): let health = try? GatewayPayloadDecoding.decode(hello.snapshot.health, as: GatewayHealthOK.self) self.healthOK = health?.ok ?? true case let .event(evt): self.handleGatewayEvent(evt) case .seqGap: self.errorText = "Event stream interrupted; try refreshing." } } private func handleGatewayEvent(_ evt: EventFrame) { if evt.event == "health", let payload = evt.payload, let ok = (try? GatewayPayloadDecoding.decode(payload, as: GatewayHealthOK.self))?.ok { self.healthOK = ok return } if evt.event == "tick" { Task { await self.pollHealthIfNeeded(force: false) } return } guard evt.event == "chat" else { return } guard let payload = evt.payload else { return } guard let chat = try? GatewayPayloadDecoding.decode(payload, as: ChatEventPayload.self) 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 raw = chat.message, let msg = try? GatewayPayloadDecoding.decode(raw, as: GatewayChatMessage.self) { 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 } } private func pollHealthIfNeeded(force: Bool) async { if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 { return } self.lastHealthPollAt = Date() do { let data = try await GatewayConnection.shared.request(method: "health", params: nil, timeoutMs: 5000) let ok = (try? JSONDecoder().decode(GatewayHealthOK.self, from: data))?.ok ?? true self.healthOK = ok } catch { self.healthOK = false } } } // MARK: - View struct WebChatView: View { @StateObject var viewModel: WebChatViewModel @State private var scrollerBottomID = UUID() var body: some View { ZStack { Color(nsColor: .windowBackgroundColor) .ignoresSafeArea() VStack(spacing: 14) { self.header self.messageList self.composer } .padding(.horizontal, 18) .padding(.vertical, 16) .frame(maxWidth: 1040) } .background( LinearGradient( colors: [ Color(red: 0.96, green: 0.97, blue: 1.0), Color(red: 0.93, green: 0.94, blue: 0.98), ], startPoint: .top, endPoint: .bottom) .opacity(0.35) .ignoresSafeArea()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .onAppear { self.viewModel.load() } } private var header: some View { HStack { VStack(alignment: .leading, spacing: 2) { Text("Clawd Chat") .font(.title2.weight(.semibold)) Text( "Session \(self.viewModel.sessionKey) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") .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) } Button { self.viewModel.refresh() } label: { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .help("Refresh history") } .padding(14) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .shadow(color: .black.opacity(0.06), radius: 10, y: 4)) } private var messageList: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 14) { if self.viewModel.messages.isEmpty { VStack(spacing: 10) { Image(systemName: "bubble.left.and.bubble.right.fill") .font(.system(size: 34, weight: .semibold)) .foregroundStyle(Color.accentColor.opacity(0.9)) Text("Say hi to Clawd") .font(.headline) Text( self.viewModel.healthOK ? "This is the native SwiftUI debug chat." : "Connecting to the gateway…" ) .font(.subheadline) .foregroundStyle(.secondary) } .padding(18) .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) .fill(Color.white.opacity(0.06))) .padding(.vertical, 34) } else { ForEach(self.viewModel.messages) { msg in let alignment: Alignment = msg.role.lowercased() == "user" ? .trailing : .leading MessageBubble(message: msg) .frame(maxWidth: .infinity, alignment: alignment) } } if self.viewModel.pendingRunCount > 0 { TypingIndicatorBubble() .frame(maxWidth: .infinity, alignment: .leading) .transition(.opacity) } Color.clear .frame(height: 1) .id(self.scrollerBottomID) } .padding(.vertical, 10) .padding(.horizontal, 12) } .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) .onChange(of: self.viewModel.messages.count) { _, _ in withAnimation(.snappy(duration: 0.22)) { proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) } } .onChange(of: self.viewModel.pendingRunCount) { _, _ in withAnimation(.snappy(duration: 0.22)) { proxy.scrollTo(self.scrollerBottomID, anchor: .bottom) } } } } private var composer: some View { VStack(alignment: .leading, spacing: 8) { HStack { self.thinkingPicker Spacer() Button { self.pickFiles() } label: { Label("Add Image", 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) { if let img = att.preview { Image(nsImage: img) .resizable() .scaledToFill() .frame(width: 22, height: 22) .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) } else { Image(systemName: "photo") } 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) .padding(.horizontal, 10) .background(Color.accentColor.opacity(0.08)) .clipShape(Capsule()) } } } } RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.secondary.opacity(0.2)) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(nsColor: .textBackgroundColor))) .overlay( ZStack(alignment: .topLeading) { if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { Text("Message Clawd…") .foregroundStyle(.tertiary) .padding(.horizontal, 12) .padding(.vertical, 10) } ComposerTextView(text: self.$viewModel.input) { self.viewModel.send() } .frame(minHeight: 54, maxHeight: 160) .padding(.horizontal, 10) .padding(.vertical, 8) }) .frame(maxHeight: 180) 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.canSend) } } .padding(14) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(nsColor: .textBackgroundColor)) .shadow(color: .black.opacity(0.06), radius: 12, y: 6)) .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") } .labelsHidden() .pickerStyle(.menu) .frame(maxWidth: 200) } private func pickFiles() { let panel = NSOpenPanel() panel.title = "Select image attachments" panel.allowsMultipleSelection = true panel.canChooseDirectories = false panel.allowedContentTypes = [.image] 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: 8) { HStack(spacing: 8) { if !self.isUser { Label("Assistant", systemImage: "sparkles") .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } Spacer(minLength: 0) if self.isUser { Label("You", systemImage: "person.fill") .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(.secondary) } } ChatMessageBody(message: self.message, isUser: self.isUser) .frame(maxWidth: WebChatSwiftUITheme.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) } .padding(.horizontal, 2) } private var isUser: Bool { self.message.role.lowercased() == "user" } } private enum WebChatSwiftUITheme { static let bubbleMaxWidth: CGFloat = 760 static let bubbleCorner: CGFloat = 16 } private struct ChatMessageBody: View { let message: GatewayChatMessage let isUser: Bool var body: some View { let text = self.primaryText let split = MarkdownSplitter.split(markdown: text) VStack(alignment: .leading, spacing: 10) { ForEach(split.blocks) { block in switch block.kind { case .text: MarkdownTextView(text: block.text) case let .code(language): CodeBlockView(code: block.text, language: language) } } if !split.images.isEmpty { ForEach(split.images) { item in if let img = item.image { Image(nsImage: img) .resizable() .scaledToFit() .frame(maxHeight: 260) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) } else { Text(item.label.isEmpty ? "Image" : item.label) .font(.footnote) .foregroundStyle(.secondary) } } } if !self.inlineAttachments.isEmpty { ForEach(self.inlineAttachments.indices, id: \.self) { idx in AttachmentRow(att: self.inlineAttachments[idx]) } } } .textSelection(.enabled) .padding(12) .background(self.bubbleBackground) .overlay(self.bubbleBorder) .clipShape(RoundedRectangle(cornerRadius: WebChatSwiftUITheme.bubbleCorner, style: .continuous)) } private var primaryText: String { let parts = self.message.content.compactMap(\.text) return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } private var inlineAttachments: [GatewayChatMessageContent] { self.message.content.filter { ($0.type ?? "text") != "text" } } private var bubbleBackground: AnyShapeStyle { if self.isUser { return AnyShapeStyle( LinearGradient( colors: [ Color.orange.opacity(0.22), Color.accentColor.opacity(0.18), ], startPoint: .topLeading, endPoint: .bottomTrailing)) } return AnyShapeStyle(Color(nsColor: .textBackgroundColor).opacity(0.55)) } private var bubbleBorder: some View { RoundedRectangle(cornerRadius: WebChatSwiftUITheme.bubbleCorner, style: .continuous) .strokeBorder( self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), lineWidth: 1) } } private struct AttachmentRow: View { let att: GatewayChatMessageContent var body: some View { HStack(spacing: 8) { Image(systemName: "paperclip") Text(self.att.fileName ?? "Attachment") .font(.footnote) .lineLimit(1) Spacer() } .padding(10) .background(Color.white.opacity(0.06)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } private struct TypingIndicatorBubble: View { var body: some View { HStack(spacing: 10) { TypingDots() Text("Clawd is thinking…") .font(.subheadline) .foregroundStyle(.secondary) Spacer() } .padding(12) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(nsColor: .textBackgroundColor).opacity(0.55))) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) .frame(maxWidth: WebChatSwiftUITheme.bubbleMaxWidth, alignment: .leading) } } private struct TypingDots: View { @State private var phase: Double = 0 var body: some View { HStack(spacing: 5) { ForEach(0..<3, id: \.self) { idx in Circle() .fill(Color.secondary.opacity(0.55)) .frame(width: 7, height: 7) .scaleEffect(self.dotScale(idx)) } } .onAppear { withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) { self.phase = 1 } } } private func dotScale(_ idx: Int) -> CGFloat { let base = 0.85 + (self.phase * 0.35) let offset = Double(idx) * 0.15 return CGFloat(base - offset) } } private struct MarkdownTextView: View { let text: String var body: some View { if let attributed = try? AttributedString(markdown: self.text) { Text(attributed) .font(.system(size: 14)) .foregroundStyle(.primary) } else { Text(self.text) .font(.system(size: 14)) .foregroundStyle(.primary) } } } private struct CodeBlockView: View { let code: String let language: String? var body: some View { VStack(alignment: .leading, spacing: 8) { if let language, !language.isEmpty { Text(language) .font(.caption2.monospaced()) .foregroundStyle(.secondary) } Text(self.code) .font(.system(size: 13, weight: .regular, design: .monospaced)) .foregroundStyle(.primary) .textSelection(.enabled) } .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .strokeBorder(Color.white.opacity(0.10), lineWidth: 1)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } private enum MarkdownSplitter { struct InlineImage: Identifiable { let id = UUID() let label: String let image: NSImage? } struct Block: Identifiable { enum Kind: Equatable { case text case code(language: String?) } let id = UUID() let kind: Kind let text: String } struct SplitResult { let blocks: [Block] let images: [InlineImage] } static func split(markdown raw: String) -> SplitResult { let extracted = self.extractInlineImages(from: raw) let blocks = self.splitCodeBlocks(from: extracted.cleaned) return SplitResult(blocks: blocks, images: extracted.images) } private static func splitCodeBlocks(from raw: String) -> [Block] { var blocks: [Block] = [] var buffer: [String] = [] var inCode = false var codeLang: String? var codeLines: [String] = [] for line in raw.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) { if line.hasPrefix("```") { if inCode { blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) codeLines.removeAll(keepingCapacity: true) inCode = false codeLang = nil } else { let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { blocks.append(Block(kind: .text, text: text)) } buffer.removeAll(keepingCapacity: true) inCode = true codeLang = line.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines) if codeLang?.isEmpty == true { codeLang = nil } } continue } if inCode { codeLines.append(line) } else { buffer.append(line) } } if inCode { blocks.append(Block(kind: .code(language: codeLang), text: codeLines.joined(separator: "\n"))) } else { let text = buffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) if !text.isEmpty { blocks.append(Block(kind: .text, text: text)) } } return blocks.isEmpty ? [Block(kind: .text, text: raw)] : blocks } private static func extractInlineImages(from raw: String) -> (cleaned: String, images: [InlineImage]) { let pattern = #"!\[([^\]]*)\]\((data:image\/[^;]+;base64,[^)]+)\)"# guard let re = try? NSRegularExpression(pattern: pattern) else { return (raw, []) } let ns = raw as NSString let matches = re.matches(in: raw, range: NSRange(location: 0, length: ns.length)) if matches.isEmpty { return (raw, []) } var images: [InlineImage] = [] var cleaned = raw for match in matches.reversed() { guard match.numberOfRanges >= 3 else { continue } let label = ns.substring(with: match.range(at: 1)) let dataURL = ns.substring(with: match.range(at: 2)) let image: NSImage? = { guard let comma = dataURL.firstIndex(of: ",") else { return nil } let b64 = String(dataURL[dataURL.index(after: comma)...]) guard let data = Data(base64Encoded: b64) else { return nil } return NSImage(data: data) }() images.append(InlineImage(label: label, image: image)) let start = cleaned.index(cleaned.startIndex, offsetBy: match.range.location) let end = cleaned.index(start, offsetBy: match.range.length) cleaned.replaceSubrange(start.. Void func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSScrollView { let textView = ComposerNSTextView() textView.delegate = context.coordinator textView.drawsBackground = false textView.isRichText = false textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.font = .systemFont(ofSize: 14, weight: .regular) textView.textContainer?.lineBreakMode = .byWordWrapping textView.textContainer?.lineFragmentPadding = 0 textView.textContainerInset = NSSize(width: 2, height: 8) textView.focusRingType = .none textView.minSize = .zero textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) textView.isHorizontallyResizable = false textView.isVerticallyResizable = true textView.autoresizingMask = [.width] textView.textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) textView.textContainer?.widthTracksTextView = true textView.string = self.text textView.onSend = { [weak textView] in textView?.window?.makeFirstResponder(nil) self.onSend() } let scroll = NSScrollView() scroll.drawsBackground = false scroll.borderType = .noBorder scroll.hasVerticalScroller = true scroll.autohidesScrollers = true scroll.scrollerStyle = .overlay scroll.hasHorizontalScroller = false scroll.documentView = textView return scroll } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? ComposerNSTextView else { return } let isEditing = scrollView.window?.firstResponder == textView if isEditing { return } if textView.string != self.text { context.coordinator.isProgrammaticUpdate = true defer { context.coordinator.isProgrammaticUpdate = false } textView.string = self.text } } final class Coordinator: NSObject, NSTextViewDelegate { var parent: ComposerTextView var isProgrammaticUpdate = false init(_ parent: ComposerTextView) { self.parent = parent } func textDidChange(_ notification: Notification) { guard !self.isProgrammaticUpdate else { return } guard let view = notification.object as? NSTextView else { return } guard view.window?.firstResponder === view else { return } self.parent.text = view.string } } } private final class ComposerNSTextView: NSTextView { var onSend: (() -> Void)? override func keyDown(with event: NSEvent) { let isReturn = event.keyCode == 36 if isReturn { if event.modifierFlags.contains(.shift) { super.insertNewline(nil) return } self.onSend?() return } super.keyDown(with: event) } } // 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: self.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.makeKeyAndOrderFront(nil) 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 } let screen = NSScreen.screens.first { screen in screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(x: anchor.midX, y: anchor.midY)) } ?? NSScreen.main var frame = window.frame if let screen { let minX = screen.frame.minX + WebChatSwiftUILayout.anchorPadding let maxX = screen.frame.maxX - frame.width - WebChatSwiftUILayout.anchorPadding frame.origin.x = min(max(round(anchor.midX - frame.width / 2), minX), maxX) let desiredY = anchor.minY - frame.height - WebChatSwiftUILayout.anchorPadding frame.origin.y = max(desiredY, screen.frame.minY + WebChatSwiftUILayout.anchorPadding) } else { 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(origin: .zero, size: WebChatSwiftUILayout.windowSize), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false) window.title = "Clawdis Chat (SwiftUI)" window.contentViewController = contentViewController window.isReleasedWhenClosed = false window.titleVisibility = .visible window.titlebarAppearsTransparent = false window.backgroundColor = .windowBackgroundColor window.isOpaque = true window.center() window.minSize = NSSize(width: 880, height: 680) 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 return panel } } }