From ce630a6381403dcf19c2f608b8f2b857a03411d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 13 Dec 2025 16:45:18 +0000 Subject: [PATCH] feat(webchat): polish SwiftUI chat --- .../Sources/Clawdis/WebChatSwiftUI.swift | 760 +++++++++++++++--- src/commands/health.ts | 7 + 2 files changed, 669 insertions(+), 98 deletions(-) diff --git a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift index 329b1713e..b42f01316 100644 --- a/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift +++ b/apps/macos/Sources/Clawdis/WebChatSwiftUI.swift @@ -14,7 +14,7 @@ private enum WebChatSwiftUILayout { // MARK: - Models -struct GatewayChatMessageContent: Codable { +struct GatewayChatMessageContent: Codable, Hashable { let type: String? let text: String? let mimeType: String? @@ -25,18 +25,56 @@ struct GatewayChatMessageContent: Codable { struct GatewayChatMessage: Codable, Identifiable { var id: UUID = .init() let role: String - let content: [GatewayChatMessageContent]? + 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: [GatewayChatMessage]? + let messages: [ClawdisProtocol.AnyCodable]? let thinkingLevel: String? } @@ -49,10 +87,14 @@ struct ChatEventPayload: Codable { let runId: String? let sessionKey: String? let state: String? - let message: GatewayChatMessage? + let message: ClawdisProtocol.AnyCodable? let errorMessage: String? } +struct GatewayHealthOK: Codable { + let ok: Bool? +} + struct PendingAttachment: Identifiable { let id = UUID() let url: URL? @@ -60,6 +102,7 @@ struct PendingAttachment: Identifiable { let fileName: String let mimeType: String let type: String = "file" + let preview: NSImage? } // MARK: - View model @@ -74,10 +117,14 @@ final class WebChatViewModel: ObservableObject { @Published var errorText: String? @Published var attachments: [PendingAttachment] = [] @Published var healthOK: Bool = true + @Published var pendingRunCount: Int = 0 - private let sessionKey: String + let sessionKey: String private var eventTask: Task? - private var pendingRuns = Set() + private var pendingRuns = Set() { + didSet { self.pendingRunCount = self.pendingRuns.count } + } + private var lastHealthPollAt: Date? init(sessionKey: String) { self.sessionKey = sessionKey @@ -86,9 +133,8 @@ final class WebChatViewModel: ObservableObject { let stream = await GatewayConnection.shared.subscribe() for await push in stream { if Task.isCancelled { return } - guard case let .event(evt) = push else { continue } await MainActor.run { [weak self] in - self?.handleGatewayEvent(evt) + self?.handleGatewayPush(push) } } } @@ -102,6 +148,10 @@ final class WebChatViewModel: ObservableObject { Task { await self.bootstrap() } } + func refresh() { + Task { await self.bootstrap() } + } + func send() { Task { await self.performSend() } } @@ -109,19 +159,29 @@ final class WebChatViewModel: ObservableObject { 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 + 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 } } - 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) } } } } @@ -130,6 +190,11 @@ final class WebChatViewModel: ObservableObject { 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 { @@ -137,10 +202,11 @@ final class WebChatViewModel: ObservableObject { defer { self.isLoading = false } do { let payload = try await self.requestHistory() - self.messages = payload.messages ?? [] + 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)") @@ -152,22 +218,39 @@ final class WebChatViewModel: ObservableObject { 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: [ - GatewayChatMessageContent( - type: "text", - text: trimmed, - mimeType: nil, - fileName: nil, - content: nil), - ], + content: userContent, timestamp: Date().timeIntervalSince1970 * 1000) self.messages.append(userMessage) @@ -181,15 +264,16 @@ final class WebChatViewModel: ObservableObject { } do { - let attachmentsPayload: [[String: String]]? = encodedAttachments.isEmpty ? nil : encodedAttachments - let params: [String: AnyCodable] = [ + var params: [String: AnyCodable] = [ "sessionKey": AnyCodable(self.sessionKey), - "message": AnyCodable(trimmed), - "attachments": AnyCodable(attachmentsPayload as Any), + "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) @@ -203,14 +287,42 @@ final class WebChatViewModel: ObservableObject { self.isSending = false } - private func requestHistory() async throws -> ChatHistoryPayload { + private func requestHistory() async throws -> (messages: [GatewayChatMessage], thinkingLevel: String?) { let data = try await GatewayConnection.shared.request( method: "chat.history", params: ["sessionKey": AnyCodable(self.sessionKey)]) - return try JSONDecoder().decode(ChatHistoryPayload.self, from: data) + 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 } @@ -223,7 +335,9 @@ final class WebChatViewModel: ObservableObject { switch chat.state { case "final": - if let msg = chat.message { + if let raw = chat.message, + let msg = try? GatewayPayloadDecoding.decode(raw, as: GatewayChatMessage.self) + { self.messages.append(msg) } if let runId = chat.runId { @@ -238,12 +352,27 @@ final class WebChatViewModel: ObservableObject { 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) @@ -275,10 +404,10 @@ struct WebChatView: View { private var header: some View { HStack { VStack(alignment: .leading, spacing: 2) { - Text("Clawd Web Chat") + Text("Clawd Chat") .font(.title2.weight(.semibold)) Text( - "Session \(self.viewModel.thinkingLevel.uppercased()) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") + "Session \(self.viewModel.sessionKey) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")") .font(.caption) .foregroundStyle(.secondary) } @@ -290,6 +419,13 @@ struct WebChatView: View { .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( @@ -299,18 +435,61 @@ struct WebChatView: View { } private var messageList: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 12) { - ForEach(self.viewModel.messages) { msg in - MessageBubble(message: msg) + 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 + MessageBubble(message: msg) + .frame(maxWidth: .infinity, alignment: msg.role.lowercased() == "user" ? .trailing : .leading) + } + } + + 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) } } - .padding(.vertical, 8) } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .textBackgroundColor)) - .shadow(color: .black.opacity(0.05), radius: 12, y: 6)) } private var composer: some View { @@ -321,7 +500,7 @@ struct WebChatView: View { Button { self.pickFiles() } label: { - Label("Add File", systemImage: "paperclip") + Label("Add Image", systemImage: "paperclip") } .buttonStyle(.bordered) } @@ -330,7 +509,15 @@ struct WebChatView: View { HStack(spacing: 6) { ForEach(self.viewModel.attachments) { att in HStack(spacing: 6) { - Image(systemName: "doc") + 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 { @@ -355,12 +542,20 @@ struct WebChatView: View { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color(nsColor: .textBackgroundColor))) .overlay( - TextEditor(text: self.$viewModel.input) - .font(.body) - .background(Color.clear) - .frame(minHeight: 96, maxHeight: 168) + 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)) + .padding(.vertical, 8) + }) .frame(maxHeight: 180) HStack { @@ -377,7 +572,7 @@ struct WebChatView: View { .font(.headline) } .buttonStyle(.borderedProminent) - .disabled(self.viewModel.isSending) + .disabled(!self.viewModel.canSend) } } .padding(14) @@ -404,9 +599,10 @@ struct WebChatView: View { private func pickFiles() { let panel = NSOpenPanel() - panel.title = "Select attachments" + 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 @@ -433,55 +629,423 @@ 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) + VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) { + HStack(spacing: 8) { + if !self.isUser { + Label("Assistant", systemImage: "sparkles") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(.secondary) } - 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)) - } + Spacer(minLength: 0) + if self.isUser { + Label("You", systemImage: "person.fill") + .labelStyle(.titleAndIcon) + .font(.caption) + .foregroundStyle(.secondary) } } - .frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading) - .padding(12) - .background(self.isUser ? Color.accentColor.opacity(0.12) : Color(nsColor: .textBackgroundColor)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(self.isUser ? Color.accentColor.opacity(0.35) : Color.secondary.opacity(0.15))) - .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + + ChatMessageBody(message: self.message, isUser: self.isUser) + .frame(maxWidth: WebChatSwiftUITheme.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading) } - .padding(.horizontal, 6) + .padding(.horizontal, 2) } private var isUser: Bool { self.message.role.lowercased() == "user" } +} - private var primaryText: String? { - self.message.content? - .compactMap(\.text) - .joined(separator: "\n") +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 .code(let 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 attachments: [GatewayChatMessageContent]? { - self.message.content?.filter { ($0.type ?? "") != "text" } + 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(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) } } @@ -522,7 +1086,7 @@ final class WebChatSwiftUIWindowController { guard case .panel = self.presentation, let window else { return } self.reposition(using: anchorProvider) self.installDismissMonitor() - window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) self.onVisibilityChanged?(true) } @@ -596,9 +1160,9 @@ final class WebChatSwiftUIWindowController { window.minSize = NSSize(width: 880, height: 680) return window case .panel: - let panel = NSPanel( + let panel = WebChatPanel( contentRect: NSRect(origin: .zero, size: WebChatSwiftUILayout.panelSize), - styleMask: [.nonactivatingPanel, .borderless], + styleMask: [.borderless], backing: .buffered, defer: false) panel.level = .statusBar diff --git a/src/commands/health.ts b/src/commands/health.ts index 08e9469fd..44e3f3d54 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -21,6 +21,12 @@ type TelegramProbe = { }; export type HealthSummary = { + /** + * Convenience top-level flag for UIs (e.g. WebChat) that only need a binary + * "can talk to the gateway" signal. If this payload exists, the gateway RPC + * succeeded, so this is always `true`. + */ + ok: true; ts: number; durationMs: number; web: { @@ -169,6 +175,7 @@ export async function getHealthSnapshot( : undefined; const summary: HealthSummary = { + ok: true, ts: Date.now(), durationMs: Date.now() - start, web: { linked, authAgeMs },