feat(webchat): polish SwiftUI chat
This commit is contained in:
@@ -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<Void, Never>?
|
||||
private var pendingRuns = Set<String>()
|
||||
private var pendingRuns = Set<String>() {
|
||||
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..<end, with: "")
|
||||
}
|
||||
|
||||
let normalized = cleaned
|
||||
.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (normalized, images.reversed())
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComposerTextView: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
var onSend: () -> 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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user