chore: rename project to clawdbot

This commit is contained in:
Peter Steinberger
2026-01-04 14:32:47 +00:00
parent d48dc71fa4
commit 246adaa119
841 changed files with 4590 additions and 4328 deletions

View File

@@ -0,0 +1,139 @@
import Foundation
struct AssistantTextSegment: Identifiable {
enum Kind {
case thinking
case response
}
let id = UUID()
let kind: Kind
let text: String
}
enum AssistantTextParser {
static func segments(from raw: String) -> [AssistantTextSegment] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
guard raw.contains("<") else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
var segments: [AssistantTextSegment] = []
var cursor = raw.startIndex
var currentKind: AssistantTextSegment.Kind = .response
var matchedTag = false
while let match = self.nextTag(in: raw, from: cursor) {
matchedTag = true
if match.range.lowerBound > cursor {
self.appendSegment(kind: currentKind, text: raw[cursor..<match.range.lowerBound], to: &segments)
}
guard let tagEnd = raw.range(of: ">", range: match.range.upperBound..<raw.endIndex) else {
cursor = raw.endIndex
break
}
let isSelfClosing = self.isSelfClosingTag(in: raw, tagEnd: tagEnd)
cursor = tagEnd.upperBound
if isSelfClosing { continue }
if match.closing {
currentKind = .response
} else {
currentKind = match.kind == .think ? .thinking : .response
}
}
if cursor < raw.endIndex {
self.appendSegment(kind: currentKind, text: raw[cursor..<raw.endIndex], to: &segments)
}
guard matchedTag else {
return [AssistantTextSegment(kind: .response, text: trimmed)]
}
return segments
}
static func hasVisibleContent(in raw: String) -> Bool {
!self.segments(from: raw).isEmpty
}
private enum TagKind {
case think
case final
}
private struct TagMatch {
let kind: TagKind
let closing: Bool
let range: Range<String.Index>
}
private static func nextTag(in text: String, from start: String.Index) -> TagMatch? {
let candidates: [TagMatch] = [
self.findTagStart(tag: "think", closing: false, in: text, from: start).map {
TagMatch(kind: .think, closing: false, range: $0)
},
self.findTagStart(tag: "think", closing: true, in: text, from: start).map {
TagMatch(kind: .think, closing: true, range: $0)
},
self.findTagStart(tag: "final", closing: false, in: text, from: start).map {
TagMatch(kind: .final, closing: false, range: $0)
},
self.findTagStart(tag: "final", closing: true, in: text, from: start).map {
TagMatch(kind: .final, closing: true, range: $0)
},
].compactMap { $0 }
return candidates.min { $0.range.lowerBound < $1.range.lowerBound }
}
private static func findTagStart(
tag: String,
closing: Bool,
in text: String,
from start: String.Index) -> Range<String.Index>?
{
let token = closing ? "</\(tag)" : "<\(tag)"
var searchRange = start..<text.endIndex
while let range = text.range(
of: token,
options: [.caseInsensitive, .diacriticInsensitive],
range: searchRange)
{
let boundaryIndex = range.upperBound
guard boundaryIndex < text.endIndex else { return range }
let boundary = text[boundaryIndex]
let isBoundary = boundary == ">" || boundary.isWhitespace || (!closing && boundary == "/")
if isBoundary {
return range
}
searchRange = boundaryIndex..<text.endIndex
}
return nil
}
private static func isSelfClosingTag(in text: String, tagEnd: Range<String.Index>) -> Bool {
var cursor = tagEnd.lowerBound
while cursor > text.startIndex {
cursor = text.index(before: cursor)
let char = text[cursor]
if char.isWhitespace { continue }
return char == "/"
}
return false
}
private static func appendSegment(
kind: AssistantTextSegment.Kind,
text: Substring,
to segments: inout [AssistantTextSegment])
{
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
segments.append(AssistantTextSegment(kind: kind, text: trimmed))
}
}

View File

@@ -0,0 +1,489 @@
import Foundation
import Observation
import SwiftUI
#if !os(macOS)
import PhotosUI
import UniformTypeIdentifiers
#endif
@MainActor
struct ClawdbotChatComposer: View {
@Bindable var viewModel: ClawdbotChatViewModel
let style: ClawdbotChatView.Style
let showsSessionSwitcher: Bool
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@FocusState private var isFocused: Bool
#else
@State private var shouldFocusTextView = false
#endif
var body: some View {
VStack(alignment: .leading, spacing: 4) {
if self.showsToolbar {
HStack(spacing: 6) {
if self.showsSessionSwitcher {
self.sessionPicker
}
self.thinkingPicker
Spacer()
self.refreshButton
self.attachmentPicker
}
}
if self.showsAttachments, !self.viewModel.attachments.isEmpty {
self.attachmentsStrip
}
self.editor
}
.padding(self.composerPadding)
.background {
let cornerRadius: CGFloat = 18
#if os(macOS)
if self.style == .standard {
let shape = UnevenRoundedRectangle(
cornerRadii: RectangleCornerRadii(
topLeading: 0,
bottomLeading: cornerRadius,
bottomTrailing: cornerRadius,
topTrailing: 0),
style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
} else {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
}
#else
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
shape
.fill(ClawdbotChatTheme.composerBackground)
.overlay(shape.strokeBorder(ClawdbotChatTheme.composerBorder, lineWidth: 1))
.shadow(color: .black.opacity(0.12), radius: 12, y: 6)
#endif
}
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
}
.onAppear {
self.shouldFocusTextView = true
}
#endif
}
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)
.controlSize(.small)
.frame(maxWidth: 140, alignment: .leading)
}
private var sessionPicker: some View {
Picker(
"Session",
selection: Binding(
get: { self.viewModel.sessionKey },
set: { next in self.viewModel.switchSession(to: next) }))
{
ForEach(self.viewModel.sessionChoices, id: \.key) { session in
Text(session.displayName ?? session.key)
.font(.system(.caption, design: .monospaced))
.tag(session.key)
}
}
.labelsHidden()
.pickerStyle(.menu)
.controlSize(.small)
.frame(maxWidth: 160, alignment: .leading)
.help("Session")
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
Button {
self.pickFilesMac()
} label: {
Image(systemName: "paperclip")
}
.help("Add Image")
.buttonStyle(.bordered)
.controlSize(.small)
#else
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
Image(systemName: "paperclip")
}
.help("Add Image")
.buttonStyle(.bordered)
.controlSize(.small)
.onChange(of: self.pickerItems) { _, newItems in
Task { await self.loadPhotosPickerItems(newItems) }
}
#endif
}
private var attachmentsStrip: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(
self.viewModel.attachments,
id: \ClawdbotPendingAttachment.id)
{ (att: ClawdbotPendingAttachment) in
HStack(spacing: 6) {
if let img = att.preview {
ClawdbotPlatformImageFactory.image(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, 8)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
private var editor: some View {
VStack(alignment: .leading, spacing: 8) {
self.editorOverlay
Rectangle()
.fill(ClawdbotChatTheme.divider)
.frame(height: 1)
.padding(.horizontal, 2)
HStack(alignment: .center, spacing: 8) {
if self.showsConnectionPill {
self.connectionPill
}
Spacer(minLength: 0)
self.sendButton
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.composerField)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(ClawdbotChatTheme.composerBorder)))
.padding(self.editorPadding)
}
private var connectionPill: some View {
HStack(spacing: 6) {
Circle()
.fill(self.viewModel.healthOK ? .green : .orange)
.frame(width: 7, height: 7)
Text(self.activeSessionLabel)
.font(.caption2.weight(.semibold))
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(ClawdbotChatTheme.subtleCard)
.clipShape(Capsule())
}
private var activeSessionLabel: String {
let match = self.viewModel.sessions.first { $0.key == self.viewModel.sessionKey }
let trimmed = match?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? self.viewModel.sessionKey : trimmed
}
private var editorOverlay: some View {
ZStack(alignment: .topLeading) {
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Message Clawd…")
.foregroundStyle(.tertiary)
.padding(.horizontal, 4)
.padding(.vertical, 4)
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input, shouldFocus: self.$shouldFocusTextView) {
self.viewModel.send()
}
.frame(minHeight: self.textMinHeight, idealHeight: self.textMinHeight, maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 3)
#else
TextEditor(text: self.$viewModel.input)
.font(.system(size: 15))
.scrollContentBackground(.hidden)
.frame(
minHeight: self.textMinHeight,
idealHeight: self.textMinHeight,
maxHeight: self.textMaxHeight)
.padding(.horizontal, 4)
.padding(.vertical, 4)
.focused(self.$isFocused)
#endif
}
}
private var sendButton: some View {
Group {
if self.viewModel.pendingRunCount > 0 {
Button {
self.viewModel.abort()
} label: {
if self.viewModel.isAborting {
ProgressView().controlSize(.mini)
} else {
Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(Color.red))
.disabled(self.viewModel.isAborting)
} else {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.mini)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.padding(6)
.background(Circle().fill(Color.accentColor))
.disabled(!self.viewModel.canSend)
}
}
}
private var refreshButton: some View {
Button {
self.viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh")
}
private var showsToolbar: Bool {
self.style == .standard
}
private var showsAttachments: Bool {
self.style == .standard
}
private var showsConnectionPill: Bool {
self.style == .standard
}
private var composerPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var editorPadding: CGFloat {
self.style == .onboarding ? 5 : 6
}
private var textMinHeight: CGFloat {
self.style == .onboarding ? 24 : 28
}
private var textMaxHeight: CGFloat {
self.style == .onboarding ? 52 : 64
}
#if os(macOS)
private func pickFilesMac() {
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 }
self.viewModel.addAttachments(urls: panel.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 { @MainActor in
self.viewModel.addAttachments(urls: [url])
}
}
}
return true
}
#else
private func loadPhotosPickerItems(_ items: [PhotosPickerItem]) async {
for item in items {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
let type = item.supportedContentTypes.first ?? .image
let ext = type.preferredFilenameExtension ?? "jpg"
let mime = type.preferredMIMEType ?? "image/jpeg"
let name = "photo-\(UUID().uuidString.prefix(8)).\(ext)"
self.viewModel.addImageAttachment(data: data, fileName: name, mimeType: mime)
} catch {
self.viewModel.errorText = error.localizedDescription
}
}
self.pickerItems = []
}
#endif
}
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
private struct ChatComposerTextView: NSViewRepresentable {
@Binding var text: String
@Binding var shouldFocus: Bool
var onSend: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeNSView(context: Context) -> NSScrollView {
let textView = ChatComposerNSTextView()
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: 4)
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? ChatComposerNSTextView else { return }
if self.shouldFocus, let window = scrollView.window {
window.makeFirstResponder(textView)
self.shouldFocus = false
}
let isEditing = scrollView.window?.firstResponder == textView
// Always allow clearing the text (e.g. after send), even while editing.
// Only skip other updates while editing to avoid cursor jumps.
let shouldClear = self.text.isEmpty && !textView.string.isEmpty
if isEditing, !shouldClear { 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: ChatComposerTextView
var isProgrammaticUpdate = false
init(_ parent: ChatComposerTextView) { 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 ChatComposerNSTextView: 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)
}
}
#endif

View File

@@ -0,0 +1,114 @@
import Foundation
enum ChatMarkdownSplitter {
struct InlineImage: Identifiable {
let id = UUID()
let label: String
let image: ClawdbotPlatformImage?
}
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: ClawdbotPlatformImage? = {
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 ClawdbotPlatformImage(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())
}
}

View File

@@ -0,0 +1,725 @@
import ClawdbotKit
import Foundation
import SwiftUI
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 560
static let bubbleCorner: CGFloat = 18
}
private struct ChatBubbleShape: InsettableShape {
enum Tail {
case left
case right
case none
}
let cornerRadius: CGFloat
let tail: Tail
var insetAmount: CGFloat = 0
private let tailWidth: CGFloat = 7
private let tailBaseHeight: CGFloat = 9
func inset(by amount: CGFloat) -> ChatBubbleShape {
var copy = self
copy.insetAmount += amount
return copy
}
func path(in rect: CGRect) -> Path {
let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
switch self.tail {
case .left:
return self.leftTailPath(in: rect, radius: self.cornerRadius)
case .right:
return self.rightTailPath(in: rect, radius: self.cornerRadius)
case .none:
return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
}
}
private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX
let bubbleMaxX = rect.maxX - self.tailWidth
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: baseTop)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
path.addCurve(
to: baseBottom,
control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
var path = Path()
let bubbleMinX = rect.minX + self.tailWidth
let bubbleMaxX = rect.maxX
let bubbleMinY = rect.minY
let bubbleMaxY = rect.maxY
let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
let baseH = min(tailBaseHeight, available)
let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
let baseTopY = baseBottomY - baseH
let midY = (baseTopY + baseBottomY) / 2
let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)
path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
path.addQuadCurve(
to: CGPoint(x: bubbleMaxX - r, y: bubbleMaxY),
control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
path.addLine(to: CGPoint(x: bubbleMinX + r, y: bubbleMaxY))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX, y: bubbleMaxY - r),
control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
path.addLine(to: baseBottom)
path.addCurve(
to: tip,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
path.addCurve(
to: baseTop,
control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
path.addQuadCurve(
to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
control: CGPoint(x: bubbleMinX, y: bubbleMinY))
return path
}
}
@MainActor
struct ChatMessageBubble: View {
let message: ClawdbotChatMessage
let style: ClawdbotChatView.Style
let userAccent: Color?
var body: some View {
ChatMessageBody(message: self.message, isUser: self.isUser, style: self.style, userAccent: self.userAccent)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
.frame(maxWidth: .infinity, alignment: self.isUser ? .trailing : .leading)
.padding(.horizontal, 2)
}
private var isUser: Bool { self.message.role.lowercased() == "user" }
}
@MainActor
private struct ChatMessageBody: View {
let message: ClawdbotChatMessage
let isUser: Bool
let style: ClawdbotChatView.Style
let userAccent: Color?
var body: some View {
let text = self.primaryText
let textColor = self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText
VStack(alignment: .leading, spacing: 10) {
if self.isToolResultMessage {
if !text.isEmpty {
ToolResultCard(
title: self.toolResultTitle,
text: text,
isUser: self.isUser)
}
} else if self.isUser {
let split = ChatMarkdownSplitter.split(markdown: text)
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: textColor, font: .system(size: 14))
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: self.isUser)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(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)
}
}
}
} else {
ChatAssistantTextBody(text: text)
}
if !self.inlineAttachments.isEmpty {
ForEach(self.inlineAttachments.indices, id: \.self) { idx in
AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
}
}
if !self.toolCalls.isEmpty {
ForEach(self.toolCalls.indices, id: \.self) { idx in
ToolCallCard(
content: self.toolCalls[idx],
isUser: self.isUser)
}
}
if !self.inlineToolResults.isEmpty {
ForEach(self.inlineToolResults.indices, id: \.self) { idx in
let toolResult = self.inlineToolResults[idx]
let display = ToolDisplayRegistry.resolve(name: toolResult.name ?? "tool", args: nil)
ToolResultCard(
title: "\(display.emoji) \(display.title)",
text: toolResult.text ?? "",
isUser: self.isUser)
}
}
}
.textSelection(.enabled)
.padding(.vertical, 10)
.padding(.horizontal, 12)
.foregroundStyle(textColor)
.background(self.bubbleBackground)
.clipShape(self.bubbleShape)
.overlay(self.bubbleBorder)
.shadow(color: self.bubbleShadowColor, radius: self.bubbleShadowRadius, y: self.bubbleShadowYOffset)
.padding(.leading, self.tailPaddingLeading)
.padding(.trailing, self.tailPaddingTrailing)
}
private var primaryText: String {
let parts = self.message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
private var inlineAttachments: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
switch content.type ?? "text" {
case "file", "attachment":
true
default:
false
}
}
}
private var toolCalls: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
let kind = (content.type ?? "").lowercased()
if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) {
return true
}
return content.name != nil && content.arguments != nil
}
}
private var inlineToolResults: [ClawdbotChatMessageContent] {
self.message.content.filter { content in
let kind = (content.type ?? "").lowercased()
return kind == "toolresult" || kind == "tool_result"
}
}
private var isToolResultMessage: Bool {
let role = self.message.role.lowercased()
return role == "toolresult" || role == "tool_result"
}
private var toolResultTitle: String {
if let name = self.message.toolName, !name.isEmpty {
let display = ToolDisplayRegistry.resolve(name: name, args: nil)
return "\(display.emoji) \(display.title)"
}
let display = ToolDisplayRegistry.resolve(name: "tool", args: nil)
return "\(display.emoji) \(display.title)"
}
private var bubbleFillColor: Color {
if self.isUser {
return self.userAccent ?? ClawdbotChatTheme.userBubble
}
if self.style == .onboarding {
return ClawdbotChatTheme.onboardingAssistantBubble
}
return ClawdbotChatTheme.assistantBubble
}
private var bubbleBackground: AnyShapeStyle {
AnyShapeStyle(self.bubbleFillColor)
}
private var bubbleBorderColor: Color {
if self.isUser {
return Color.white.opacity(0.12)
}
if self.style == .onboarding {
return ClawdbotChatTheme.onboardingAssistantBorder
}
return Color.white.opacity(0.08)
}
private var bubbleBorderWidth: CGFloat {
if self.isUser { return 0.5 }
if self.style == .onboarding { return 0.8 }
return 1
}
private var bubbleBorder: some View {
self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
}
private var bubbleShape: ChatBubbleShape {
ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
}
private var bubbleTail: ChatBubbleShape.Tail {
guard self.style == .onboarding else { return .none }
return self.isUser ? .right : .left
}
private var tailPaddingLeading: CGFloat {
self.style == .onboarding && !self.isUser ? 8 : 0
}
private var tailPaddingTrailing: CGFloat {
self.style == .onboarding && self.isUser ? 8 : 0
}
private var bubbleShadowColor: Color {
self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
}
private var bubbleShadowRadius: CGFloat {
self.style == .onboarding && !self.isUser ? 6 : 0
}
private var bubbleShadowYOffset: CGFloat {
self.style == .onboarding && !self.isUser ? 2 : 0
}
}
private struct AttachmentRow: View {
let att: ClawdbotChatMessageContent
let isUser: Bool
var body: some View {
HStack(spacing: 8) {
Image(systemName: "paperclip")
Text(self.att.fileName ?? "Attachment")
.font(.footnote)
.lineLimit(1)
.foregroundStyle(self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText)
Spacer()
}
.padding(10)
.background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
private struct ToolCallCard: View {
let content: ClawdbotChatMessageContent
let isUser: Bool
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Text(self.toolName)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
if let summary = self.summary, !summary.isEmpty {
Text(summary)
.font(.footnote.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private var toolName: String {
"\(self.display.emoji) \(self.display.title)"
}
private var summary: String? {
self.display.detailLine
}
private var display: ToolDisplaySummary {
ToolDisplayRegistry.resolve(name: self.content.name ?? "tool", args: self.content.arguments)
}
}
private struct ToolResultCard: View {
let title: String
let text: String
let isUser: Bool
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text(self.title)
.font(.footnote.weight(.semibold))
Spacer(minLength: 0)
}
Text(self.displayText)
.font(.footnote.monospaced())
.foregroundStyle(self.isUser ? ClawdbotChatTheme.userText : ClawdbotChatTheme.assistantText)
.lineLimit(self.expanded ? nil : Self.previewLineLimit)
if self.shouldShowToggle {
Button(self.expanded ? "Show less" : "Show full output") {
self.expanded.toggle()
}
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(10)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)))
}
private static let previewLineLimit = 8
private var lines: [Substring] {
self.text.components(separatedBy: .newlines).map { Substring($0) }
}
private var displayText: String {
guard !self.expanded, self.lines.count > Self.previewLineLimit else { return self.text }
return self.lines.prefix(Self.previewLineLimit).joined(separator: "\n") + "\n"
}
private var shouldShowToggle: Bool {
self.lines.count > Self.previewLineLimit
}
}
@MainActor
struct ChatTypingIndicatorBubble: View {
let style: ClawdbotChatView.Style
var body: some View {
HStack(spacing: 10) {
TypingDots()
if self.style == .standard {
Text("Clawd is thinking…")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
}
}
.padding(.vertical, self.style == .standard ? 12 : 10)
.padding(.horizontal, self.style == .standard ? 12 : 14)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
extension ChatTypingIndicatorBubble: @MainActor Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.style == rhs.style
}
}
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ChatAssistantTextBody(text: self.text)
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
@MainActor
struct ChatPendingToolsBubble: View {
let toolCalls: [ClawdbotChatPendingToolCall]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Running tools…", systemImage: "hammer")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.toolCalls) { call in
let display = ToolDisplayRegistry.resolve(name: call.name, args: call.args)
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("\(display.emoji) \(display.label)")
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
}
if let detail = display.detailLine, !detail.isEmpty {
Text(detail)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.padding(10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdbotChatTheme.assistantBubble))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
.focusable(false)
}
}
extension ChatPendingToolsBubble: @MainActor Equatable {
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.toolCalls == rhs.toolCalls
}
}
@MainActor
private struct TypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.scenePhase) private var scenePhase
@State private var animate = false
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.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
.animation(
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
.repeatForever(autoreverses: true)
.delay(Double(idx) * 0.16),
value: self.animate)
}
}
.onAppear { self.updateAnimationState() }
.onDisappear { self.animate = false }
.onChange(of: self.scenePhase) { _, _ in
self.updateAnimationState()
}
.onChange(of: self.reduceMotion) { _, _ in
self.updateAnimationState()
}
}
private func updateAnimationState() {
guard !self.reduceMotion, self.scenePhase == .active else {
self.animate = false
return
}
self.animate = true
}
}
@MainActor
private struct MarkdownTextView: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let normalized = self.text.replacingOccurrences(
of: "(?<!\\n)\\n(?!\\n)",
with: " ",
options: .regularExpression)
let options = AttributedString.MarkdownParsingOptions(
interpretedSyntax: .inlineOnlyPreservingWhitespace)
if let attributed = try? AttributedString(markdown: normalized, options: options) {
Text(attributed)
.font(self.font)
.foregroundStyle(self.textColor)
} else {
Text(normalized)
.font(self.font)
.foregroundStyle(self.textColor)
}
}
}
@MainActor
private struct ChatAssistantTextBody: View {
let text: String
var body: some View {
let segments = AssistantTextParser.segments(from: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(segments) { segment in
let font = segment.kind == .thinking ? Font.system(size: 14).italic() : Font.system(size: 14)
ChatMarkdownBody(text: segment.text, textColor: ClawdbotChatTheme.assistantText, font: font)
}
}
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
let textColor: Color
let font: Font
var body: some View {
let split = ChatMarkdownSplitter.split(markdown: self.text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text, textColor: self.textColor, font: self.font)
case let .code(language):
CodeBlockView(code: block.text, language: language, isUser: false)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdbotPlatformImageFactory.image(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)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String
let language: String?
let isUser: Bool
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(self.isUser ? .white : .primary)
.textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.isUser ? Color.white.opacity(0.16) : Color.black.opacity(0.06))
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(Color.black.opacity(0.08), lineWidth: 1))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}

View File

@@ -0,0 +1,311 @@
import ClawdbotKit
import Foundation
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
#if canImport(AppKit)
import AppKit
public typealias ClawdbotPlatformImage = NSImage
#elseif canImport(UIKit)
import UIKit
public typealias ClawdbotPlatformImage = UIImage
#endif
public struct ClawdbotChatUsageCost: Codable, Hashable, Sendable {
public let input: Double?
public let output: Double?
public let cacheRead: Double?
public let cacheWrite: Double?
public let total: Double?
}
public struct ClawdbotChatUsage: Codable, Hashable, Sendable {
public let input: Int?
public let output: Int?
public let cacheRead: Int?
public let cacheWrite: Int?
public let cost: ClawdbotChatUsageCost?
public let total: Int?
enum CodingKeys: String, CodingKey {
case input
case output
case cacheRead
case cacheWrite
case cost
case total
case totalTokens
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.input = try container.decodeIfPresent(Int.self, forKey: .input)
self.output = try container.decodeIfPresent(Int.self, forKey: .output)
self.cacheRead = try container.decodeIfPresent(Int.self, forKey: .cacheRead)
self.cacheWrite = try container.decodeIfPresent(Int.self, forKey: .cacheWrite)
self.cost = try container.decodeIfPresent(ClawdbotChatUsageCost.self, forKey: .cost)
self.total =
try container.decodeIfPresent(Int.self, forKey: .total) ??
container.decodeIfPresent(Int.self, forKey: .totalTokens)
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encodeIfPresent(self.input, forKey: .input)
try container.encodeIfPresent(self.output, forKey: .output)
try container.encodeIfPresent(self.cacheRead, forKey: .cacheRead)
try container.encodeIfPresent(self.cacheWrite, forKey: .cacheWrite)
try container.encodeIfPresent(self.cost, forKey: .cost)
try container.encodeIfPresent(self.total, forKey: .total)
}
}
public struct ClawdbotChatMessageContent: Codable, Hashable, Sendable {
public let type: String?
public let text: String?
public let thinking: String?
public let thinkingSignature: String?
public let mimeType: String?
public let fileName: String?
public let content: AnyCodable?
// Tool-call fields (when `type == "toolCall"` or similar)
public let id: String?
public let name: String?
public let arguments: AnyCodable?
public init(
type: String?,
text: String?,
thinking: String? = nil,
thinkingSignature: String? = nil,
mimeType: String?,
fileName: String?,
content: AnyCodable?,
id: String? = nil,
name: String? = nil,
arguments: AnyCodable? = nil)
{
self.type = type
self.text = text
self.thinking = thinking
self.thinkingSignature = thinkingSignature
self.mimeType = mimeType
self.fileName = fileName
self.content = content
self.id = id
self.name = name
self.arguments = arguments
}
enum CodingKeys: String, CodingKey {
case type
case text
case thinking
case thinkingSignature
case mimeType
case fileName
case content
case id
case name
case arguments
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.type = try container.decodeIfPresent(String.self, forKey: .type)
self.text = try container.decodeIfPresent(String.self, forKey: .text)
self.thinking = try container.decodeIfPresent(String.self, forKey: .thinking)
self.thinkingSignature = try container.decodeIfPresent(String.self, forKey: .thinkingSignature)
self.mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
self.fileName = try container.decodeIfPresent(String.self, forKey: .fileName)
self.id = try container.decodeIfPresent(String.self, forKey: .id)
self.name = try container.decodeIfPresent(String.self, forKey: .name)
self.arguments = try container.decodeIfPresent(AnyCodable.self, forKey: .arguments)
if let any = try container.decodeIfPresent(AnyCodable.self, forKey: .content) {
self.content = any
} else if let str = try container.decodeIfPresent(String.self, forKey: .content) {
self.content = AnyCodable(str)
} else {
self.content = nil
}
}
}
public struct ClawdbotChatMessage: Codable, Identifiable, Sendable {
public var id: UUID = .init()
public let role: String
public let content: [ClawdbotChatMessageContent]
public let timestamp: Double?
public let toolCallId: String?
public let toolName: String?
public let usage: ClawdbotChatUsage?
public let stopReason: String?
enum CodingKeys: String, CodingKey {
case role
case content
case timestamp
case toolCallId
case tool_call_id
case toolName
case tool_name
case usage
case stopReason
}
public init(
id: UUID = .init(),
role: String,
content: [ClawdbotChatMessageContent],
timestamp: Double?,
toolCallId: String? = nil,
toolName: String? = nil,
usage: ClawdbotChatUsage? = nil,
stopReason: String? = nil)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.toolCallId = toolCallId
self.toolName = toolName
self.usage = usage
self.stopReason = stopReason
}
public 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)
self.toolCallId =
try container.decodeIfPresent(String.self, forKey: .toolCallId) ??
container.decodeIfPresent(String.self, forKey: .tool_call_id)
self.toolName =
try container.decodeIfPresent(String.self, forKey: .toolName) ??
container.decodeIfPresent(String.self, forKey: .tool_name)
self.usage = try container.decodeIfPresent(ClawdbotChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
if let decoded = try? container.decode([ClawdbotChatMessageContent].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 = [
ClawdbotChatMessageContent(
type: "text",
text: text,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: nil,
name: nil,
arguments: nil),
]
return
}
self.content = []
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.role, forKey: .role)
try container.encodeIfPresent(self.timestamp, forKey: .timestamp)
try container.encodeIfPresent(self.toolCallId, forKey: .toolCallId)
try container.encodeIfPresent(self.toolName, forKey: .toolName)
try container.encodeIfPresent(self.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encode(self.content, forKey: .content)
}
}
public struct ClawdbotChatHistoryPayload: Codable, Sendable {
public let sessionKey: String
public let sessionId: String?
public let messages: [AnyCodable]?
public let thinkingLevel: String?
}
public struct ClawdbotChatSendResponse: Codable, Sendable {
public let runId: String
public let status: String
}
public struct ClawdbotChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
public let state: String?
public let message: AnyCodable?
public let errorMessage: String?
}
public struct ClawdbotAgentEventPayload: Codable, Sendable, Identifiable {
public var id: String { "\(self.runId)-\(self.seq ?? -1)" }
public let runId: String
public let seq: Int?
public let stream: String
public let ts: Int?
public let data: [String: AnyCodable]
}
public struct ClawdbotChatPendingToolCall: Identifiable, Hashable, Sendable {
public var id: String { self.toolCallId }
public let toolCallId: String
public let name: String
public let args: AnyCodable?
public let startedAt: Double?
public let isError: Bool?
}
public struct ClawdbotGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}
public struct ClawdbotPendingAttachment: Identifiable {
public let id = UUID()
public let url: URL?
public let data: Data
public let fileName: String
public let mimeType: String
public let type: String
public let preview: ClawdbotPlatformImage?
public init(
url: URL?,
data: Data,
fileName: String,
mimeType: String,
type: String = "file",
preview: ClawdbotPlatformImage?)
{
self.url = url
self.data = data
self.fileName = fileName
self.mimeType = mimeType
self.type = type
self.preview = preview
}
}
public struct ClawdbotChatAttachmentPayload: Codable, Sendable, Hashable {
public let type: String
public let mimeType: String
public let fileName: String
public let content: String
public init(type: String, mimeType: String, fileName: String, content: String) {
self.type = type
self.mimeType = mimeType
self.fileName = fileName
self.content = content
}
}

View File

@@ -0,0 +1,9 @@
import ClawdbotKit
import Foundation
enum ChatPayloadDecoding {
static func decode<T: Decodable>(_ payload: AnyCodable, as _: T.Type = T.self) throws -> T {
let data = try JSONEncoder().encode(payload)
return try JSONDecoder().decode(T.self, from: data)
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
public struct ClawdbotChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
}
public struct ClawdbotChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
public let key: String
public let kind: String?
public let displayName: String?
public let surface: String?
public let subject: String?
public let room: String?
public let space: String?
public let updatedAt: Double?
public let sessionId: String?
public let systemSent: Bool?
public let abortedLastRun: Bool?
public let thinkingLevel: String?
public let verboseLevel: String?
public let inputTokens: Int?
public let outputTokens: Int?
public let totalTokens: Int?
public let model: String?
public let contextTokens: Int?
}
public struct ClawdbotChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let count: Int?
public let defaults: ClawdbotChatSessionsDefaults?
public let sessions: [ClawdbotChatSessionEntry]
}

View File

@@ -0,0 +1,69 @@
import Observation
import SwiftUI
@MainActor
struct ChatSessionsSheet: View {
@Bindable var viewModel: ClawdbotChatViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(self.viewModel.sessions) { session in
Button {
self.viewModel.switchSession(to: session.key)
self.dismiss()
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(session.displayName ?? session.key)
.font(.system(.body, design: .monospaced))
.lineLimit(1)
if let updatedAt = session.updatedAt, updatedAt > 0 {
Text(Date(timeIntervalSince1970: updatedAt / 1000).formatted(
date: .abbreviated,
time: .shortened))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Sessions")
.toolbar {
#if os(macOS)
ToolbarItem(placement: .automatic) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .primaryAction) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#else
ToolbarItem(placement: .topBarLeading) {
Button {
self.viewModel.refreshSessions(limit: 200)
} label: {
Image(systemName: "arrow.clockwise")
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
}
#endif
}
.onAppear {
self.viewModel.refreshSessions(limit: 200)
}
}
}
}

View File

@@ -0,0 +1,174 @@
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
#if os(macOS)
extension NSAppearance {
fileprivate var isDarkAqua: Bool {
self.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
}
}
#endif
enum ClawdbotChatTheme {
#if os(macOS)
static func resolvedAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
// NSColor semantic colors don't reliably resolve for arbitrary NSAppearance in SwiftPM.
// Use explicit light/dark values so the bubble updates when the system appearance flips.
appearance.isDarkAqua
? NSColor(calibratedWhite: 0.18, alpha: 0.88)
: NSColor(calibratedWhite: 0.94, alpha: 0.92)
}
static func resolvedOnboardingAssistantBubbleColor(for appearance: NSAppearance) -> NSColor {
appearance.isDarkAqua
? NSColor(calibratedWhite: 0.20, alpha: 0.94)
: NSColor(calibratedWhite: 0.97, alpha: 0.98)
}
static let assistantBubbleDynamicNSColor = NSColor(
name: NSColor.Name("ClawdbotChatTheme.assistantBubble"),
dynamicProvider: resolvedAssistantBubbleColor(for:))
static let onboardingAssistantBubbleDynamicNSColor = NSColor(
name: NSColor.Name("ClawdbotChatTheme.onboardingAssistantBubble"),
dynamicProvider: resolvedOnboardingAssistantBubbleColor(for:))
#endif
static var surface: Color {
#if os(macOS)
Color(nsColor: .windowBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}
@ViewBuilder
static var background: some View {
#if os(macOS)
ZStack {
Rectangle()
.fill(.ultraThinMaterial)
LinearGradient(
colors: [
Color.white.opacity(0.12),
Color(nsColor: .windowBackgroundColor).opacity(0.35),
Color.black.opacity(0.35),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
RadialGradient(
colors: [
Color(nsColor: .systemOrange).opacity(0.14),
.clear,
],
center: .topLeading,
startRadius: 40,
endRadius: 320)
RadialGradient(
colors: [
Color(nsColor: .systemTeal).opacity(0.12),
.clear,
],
center: .topTrailing,
startRadius: 40,
endRadius: 280)
Color.black.opacity(0.08)
}
#else
Color(uiColor: .systemBackground)
#endif
}
static var card: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var subtleCard: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground).opacity(0.9))
#endif
}
static var userBubble: Color {
Color(red: 127 / 255.0, green: 184 / 255.0, blue: 212 / 255.0)
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: self.assistantBubbleDynamicNSColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBubble: Color {
#if os(macOS)
Color(nsColor: self.onboardingAssistantBubbleDynamicNSColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var onboardingAssistantBorder: Color {
#if os(macOS)
Color.white.opacity(0.12)
#else
Color.white.opacity(0.12)
#endif
}
static var userText: Color { .white }
static var assistantText: Color {
#if os(macOS)
Color(nsColor: .labelColor)
#else
Color(uiColor: .label)
#endif
}
static var composerBackground: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.ultraThinMaterial)
#else
AnyShapeStyle(Color(uiColor: .systemBackground))
#endif
}
static var composerField: AnyShapeStyle {
#if os(macOS)
AnyShapeStyle(.thinMaterial)
#else
AnyShapeStyle(Color(uiColor: .secondarySystemBackground))
#endif
}
static var composerBorder: Color {
Color.white.opacity(0.12)
}
static var divider: Color {
Color.secondary.opacity(0.2)
}
}
enum ClawdbotPlatformImageFactory {
static func image(_ image: ClawdbotPlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
#else
Image(uiImage: image)
#endif
}
}

View File

@@ -0,0 +1,45 @@
import Foundation
public enum ClawdbotChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case chat(ClawdbotChatEventPayload)
case agent(ClawdbotAgentEventPayload)
case seqGap
}
public protocol ClawdbotChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> ClawdbotChatHistoryPayload
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdbotChatAttachmentPayload]) async throws -> ClawdbotChatSendResponse
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> ClawdbotChatSessionsListResponse
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<ClawdbotChatTransportEvent>
func setActiveSessionKey(_ sessionKey: String) async throws
}
extension ClawdbotChatTransport {
public func setActiveSessionKey(_: String) async throws {}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "ClawdbotChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"])
}
public func listSessions(limit _: Int?) async throws -> ClawdbotChatSessionsListResponse {
throw NSError(
domain: "ClawdbotChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
}

View File

@@ -0,0 +1,476 @@
import SwiftUI
@MainActor
public struct ClawdbotChatView: View {
public enum Style {
case standard
case onboarding
}
@State private var viewModel: ClawdbotChatViewModel
@State private var scrollerBottomID = UUID()
@State private var scrollPosition: UUID?
@State private var showSessions = false
@State private var hasPerformedInitialScroll = false
private let showsSessionSwitcher: Bool
private let style: Style
private let userAccent: Color?
private enum Layout {
#if os(macOS)
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 0
static let composerPaddingHorizontal: CGFloat = 0
static let stackSpacing: CGFloat = 0
static let messageSpacing: CGFloat = 6
static let messageListPaddingTop: CGFloat = 12
static let messageListPaddingBottom: CGFloat = 16
static let messageListPaddingHorizontal: CGFloat = 6
#else
static let outerPaddingHorizontal: CGFloat = 6
static let outerPaddingVertical: CGFloat = 6
static let composerPaddingHorizontal: CGFloat = 6
static let stackSpacing: CGFloat = 6
static let messageSpacing: CGFloat = 12
static let messageListPaddingTop: CGFloat = 10
static let messageListPaddingBottom: CGFloat = 6
static let messageListPaddingHorizontal: CGFloat = 8
#endif
}
public init(
viewModel: ClawdbotChatViewModel,
showsSessionSwitcher: Bool = false,
style: Style = .standard,
userAccent: Color? = nil)
{
self._viewModel = State(initialValue: viewModel)
self.showsSessionSwitcher = showsSessionSwitcher
self.style = style
self.userAccent = userAccent
}
public var body: some View {
ZStack {
ClawdbotChatTheme.background
.ignoresSafeArea()
VStack(spacing: Layout.stackSpacing) {
self.messageList
.padding(.horizontal, Layout.outerPaddingHorizontal)
ClawdbotChatComposer(
viewModel: self.viewModel,
style: self.style,
showsSessionSwitcher: self.showsSessionSwitcher)
.padding(.horizontal, Layout.composerPaddingHorizontal)
}
.padding(.vertical, Layout.outerPaddingVertical)
.frame(maxWidth: .infinity)
.frame(maxHeight: .infinity, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }
.sheet(isPresented: self.$showSessions) {
if self.showsSessionSwitcher {
ChatSessionsSheet(viewModel: self.viewModel)
} else {
EmptyView()
}
}
}
private var messageList: some View {
ZStack {
ScrollView {
#if os(macOS)
VStack(spacing: 0) {
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
}
Color.clear
.frame(height: Layout.messageListPaddingBottom)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#else
LazyVStack(spacing: Layout.messageSpacing) {
self.messageListRows
Color.clear
.frame(height: Layout.messageListPaddingBottom + 1)
.id(self.scrollerBottomID)
}
// Use scroll targets for stable auto-scroll without ScrollViewReader relayout glitches.
.scrollTargetLayout()
.padding(.top, Layout.messageListPaddingTop)
.padding(.horizontal, Layout.messageListPaddingHorizontal)
#endif
}
// Keep the scroll pinned to the bottom for new messages.
.scrollPosition(id: self.$scrollPosition, anchor: .bottom)
if self.viewModel.isLoading {
ProgressView()
.controlSize(.large)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
self.messageListOverlay
}
// Ensure the message list claims vertical space on the first layout pass.
.frame(maxHeight: .infinity, alignment: .top)
.layoutPriority(1)
.onChange(of: self.viewModel.isLoading) { _, isLoading in
guard !isLoading, !self.hasPerformedInitialScroll else { return }
self.scrollPosition = self.scrollerBottomID
self.hasPerformedInitialScroll = true
}
.onChange(of: self.viewModel.sessionKey) { _, _ in
self.hasPerformedInitialScroll = false
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.pendingRunCount) { _, _ in
guard self.hasPerformedInitialScroll else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
}
@ViewBuilder
private var messageListRows: some View {
ForEach(self.visibleMessages) { msg in
ChatMessageBubble(message: msg, style: self.style, userAccent: self.userAccent)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
if self.viewModel.pendingRunCount > 0 {
HStack {
ChatTypingIndicatorBubble(style: self.style)
.equatable()
Spacer(minLength: 0)
}
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.equatable()
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, AssistantTextParser.hasVisibleContent(in: text) {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private var visibleMessages: [ClawdbotChatMessage] {
let base: [ClawdbotChatMessage]
if self.style == .onboarding {
guard let first = self.viewModel.messages.first else { return [] }
base = first.role.lowercased() == "user" ? Array(self.viewModel.messages.dropFirst()) : self.viewModel
.messages
} else {
base = self.viewModel.messages
}
return self.mergeToolResults(in: base)
}
@ViewBuilder
private var messageListOverlay: some View {
if self.viewModel.isLoading {
EmptyView()
} else if let error = self.activeErrorText {
let presentation = self.errorPresentation(for: error)
if self.hasVisibleMessageListContent {
VStack(spacing: 0) {
ChatNoticeBanner(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
dismiss: { self.viewModel.errorText = nil },
refresh: { self.viewModel.refresh() })
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.top, 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} else {
ChatNoticeCard(
systemImage: presentation.systemImage,
title: presentation.title,
message: error,
tint: presentation.tint,
actionTitle: "Refresh",
action: { self.viewModel.refresh() })
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
} else if self.showsEmptyState {
ChatNoticeCard(
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private var activeErrorText: String? {
guard let text = self.viewModel.errorText?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty
else {
return nil
}
return text
}
private var hasVisibleMessageListContent: Bool {
if !self.visibleMessages.isEmpty {
return true
}
if let text = self.viewModel.streamingAssistantText,
AssistantTextParser.hasVisibleContent(in: text)
{
return true
}
if self.viewModel.pendingRunCount > 0 {
return true
}
if !self.viewModel.pendingToolCalls.isEmpty {
return true
}
return false
}
private var showsEmptyState: Bool {
self.viewModel.messages.isEmpty &&
!(self.viewModel.streamingAssistantText.map { AssistantTextParser.hasVisibleContent(in: $0) } ?? false) &&
self.viewModel.pendingRunCount == 0 &&
self.viewModel.pendingToolCalls.isEmpty
}
private var emptyStateTitle: String {
#if os(macOS)
"Web Chat"
#else
"Chat"
#endif
}
private var emptyStateMessage: String {
#if os(macOS)
"Type a message below to start.\nReturn sends • Shift-Return adds a line break."
#else
"Type a message below to start."
#endif
}
private func errorPresentation(for error: String) -> (title: String, systemImage: String, tint: Color) {
let lower = error.lowercased()
if lower.contains("not connected") || lower.contains("socket") {
return ("Disconnected", "wifi.slash", .orange)
}
if lower.contains("timed out") {
return ("Timed out", "clock.badge.exclamationmark", .orange)
}
return ("Error", "exclamationmark.triangle.fill", .orange)
}
private func mergeToolResults(in messages: [ClawdbotChatMessage]) -> [ClawdbotChatMessage] {
var result: [ClawdbotChatMessage] = []
result.reserveCapacity(messages.count)
for message in messages {
guard self.isToolResultMessage(message) else {
result.append(message)
continue
}
guard let toolCallId = message.toolCallId,
let last = result.last,
self.toolCallIds(in: last).contains(toolCallId)
else {
result.append(message)
continue
}
let toolText = self.toolResultText(from: message)
if toolText.isEmpty {
continue
}
var content = last.content
content.append(
ClawdbotChatMessageContent(
type: "tool_result",
text: toolText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: toolCallId,
name: message.toolName,
arguments: nil))
let merged = ClawdbotChatMessage(
id: last.id,
role: last.role,
content: content,
timestamp: last.timestamp,
toolCallId: last.toolCallId,
toolName: last.toolName,
usage: last.usage,
stopReason: last.stopReason)
result[result.count - 1] = merged
}
return result
}
private func isToolResultMessage(_ message: ClawdbotChatMessage) -> Bool {
let role = message.role.lowercased()
return role == "toolresult" || role == "tool_result"
}
private func toolCallIds(in message: ClawdbotChatMessage) -> Set<String> {
var ids = Set<String>()
for content in message.content {
let kind = (content.type ?? "").lowercased()
let isTool =
["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) ||
(content.name != nil && content.arguments != nil)
if isTool, let id = content.id {
ids.insert(id)
}
}
if let toolCallId = message.toolCallId {
ids.insert(toolCallId)
}
return ids
}
private func toolResultText(from message: ClawdbotChatMessage) -> String {
let parts = message.content.compactMap { content -> String? in
let kind = (content.type ?? "text").lowercased()
guard kind == "text" || kind.isEmpty else { return nil }
return content.text
}
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private struct ChatNoticeCard: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let actionTitle: String?
let action: (() -> Void)?
var body: some View {
VStack(spacing: 12) {
ZStack {
Circle()
.fill(self.tint.opacity(0.16))
Image(systemName: self.systemImage)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(self.tint)
}
.frame(width: 52, height: 52)
Text(self.title)
.font(.headline)
Text(self.message)
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineLimit(4)
.frame(maxWidth: 360)
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(18)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
.shadow(color: .black.opacity(0.14), radius: 18, y: 8)
}
}
private struct ChatNoticeBanner: View {
let systemImage: String
let title: String
let message: String
let tint: Color
let dismiss: () -> Void
let refresh: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: self.systemImage)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(self.tint)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.caption.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer(minLength: 0)
Button(action: self.refresh) {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.bordered)
.controlSize(.small)
.help("Refresh")
Button(action: self.dismiss) {
Image(systemName: "xmark")
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
.help("Dismiss")
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(ClawdbotChatTheme.subtleCard)
.overlay(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1)))
}
}

View File

@@ -0,0 +1,559 @@
import ClawdbotKit
import Foundation
import Observation
import OSLog
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private let chatUILogger = Logger(subsystem: "com.clawdbot", category: "ClawdbotChatUI")
@MainActor
@Observable
public final class ClawdbotChatViewModel {
public private(set) var messages: [ClawdbotChatMessage] = []
public var input: String = ""
public var thinkingLevel: String = "off"
public private(set) var isLoading = false
public private(set) var isSending = false
public private(set) var isAborting = false
public var errorText: String?
public var attachments: [ClawdbotPendingAttachment] = []
public private(set) var healthOK: Bool = false
public private(set) var pendingRunCount: Int = 0
public private(set) var sessionKey: String
public private(set) var sessionId: String?
public private(set) var streamingAssistantText: String?
public private(set) var pendingToolCalls: [ClawdbotChatPendingToolCall] = []
public private(set) var sessions: [ClawdbotChatSessionEntry] = []
private let transport: any ClawdbotChatTransport
@ObservationIgnored
private nonisolated(unsafe) var eventTask: Task<Void, Never>?
private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count }
}
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var pendingToolCallsById: [String: ClawdbotChatPendingToolCall] = [:] {
didSet {
self.pendingToolCalls = self.pendingToolCallsById.values
.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
}
}
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdbotChatTransport) {
self.sessionKey = sessionKey
self.transport = transport
self.eventTask = Task { [weak self] in
guard let self else { return }
let stream = self.transport.events()
for await evt in stream {
if Task.isCancelled { return }
await MainActor.run { [weak self] in
self?.handleTransportEvent(evt)
}
}
}
}
deinit {
self.eventTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
}
public func load() {
Task { await self.bootstrap() }
}
public func refresh() {
Task { await self.bootstrap() }
}
public func send() {
Task { await self.performSend() }
}
public func abort() {
Task { await self.performAbort() }
}
public func refreshSessions(limit: Int? = nil) {
Task { await self.fetchSessions(limit: limit) }
}
public func switchSession(to sessionKey: String) {
Task { await self.performSwitchSession(to: sessionKey) }
}
public var sessionChoices: [ClawdbotChatSessionEntry] {
let now = Date().timeIntervalSince1970 * 1000
let cutoff = now - (24 * 60 * 60 * 1000)
let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) }
var seen = Set<String>()
var recent: [ClawdbotChatSessionEntry] = []
for entry in sorted {
guard !seen.contains(entry.key) else { continue }
seen.insert(entry.key)
guard (entry.updatedAt ?? 0) >= cutoff else { continue }
recent.append(entry)
}
let mainKey = "main"
var result: [ClawdbotChatSessionEntry] = []
var included = Set<String>()
if let main = sorted.first(where: { $0.key == mainKey }) {
result.append(main)
included.insert(mainKey)
} else if self.sessionKey == mainKey {
result.append(self.placeholderSession(key: mainKey))
included.insert(mainKey)
}
for entry in recent where !included.contains(entry.key) {
result.append(entry)
included.insert(entry.key)
}
if !included.contains(self.sessionKey) {
result.append(self.placeholderSession(key: self.sessionKey))
}
return result
}
public func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
public func addImageAttachment(data: Data, fileName: String, mimeType: String) {
Task { await self.addImageAttachment(url: nil, data: data, fileName: fileName, mimeType: mimeType) }
}
public func removeAttachment(_ id: ClawdbotPendingAttachment.ID) {
self.attachments.removeAll { $0.id == id }
}
public var canSend: Bool {
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty)
}
// MARK: - Internals
private func bootstrap() async {
self.isLoading = true
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.sessionId = nil
defer { self.isLoading = false }
do {
do {
try await self.transport.setActiveSessionKey(self.sessionKey)
} catch {
// Best-effort only; history/send/health still work without push events.
}
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
await self.fetchSessions(limit: 50)
self.errorText = nil
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)")
}
}
private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdbotChatMessage] {
let decoded = raw.compactMap { item in
(try? ChatPayloadDecoding.decode(item, as: ClawdbotChatMessage.self))
}
return Self.dedupeMessages(decoded)
}
private static func dedupeMessages(_ messages: [ClawdbotChatMessage]) -> [ClawdbotChatMessage] {
var result: [ClawdbotChatMessage] = []
result.reserveCapacity(messages.count)
var seen = Set<String>()
for message in messages {
guard let key = Self.dedupeKey(for: message) else {
result.append(message)
continue
}
if seen.contains(key) { continue }
seen.insert(key)
result.append(message)
}
return result
}
private static func dedupeKey(for message: ClawdbotChatMessage) -> String? {
guard let timestamp = message.timestamp else { return nil }
let text = message.content.compactMap(\.text).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return nil }
return "\(message.role)|\(timestamp)|\(text)"
}
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
self.pendingRuns.insert(runId)
self.armPendingRunTimeout(runId: runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
// Optimistically append user message to UI.
var userContent: [ClawdbotChatMessageContent] = [
ClawdbotChatMessageContent(
type: "text",
text: messageText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil,
id: nil,
name: nil,
arguments: nil),
]
let encodedAttachments = self.attachments.map { att -> ClawdbotChatAttachmentPayload in
ClawdbotChatAttachmentPayload(
type: att.type,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.data.base64EncodedString())
}
for att in encodedAttachments {
userContent.append(
ClawdbotChatMessageContent(
type: att.type,
text: nil,
thinking: nil,
thinkingSignature: nil,
mimeType: att.mimeType,
fileName: att.fileName,
content: AnyCodable(att.content),
id: nil,
name: nil,
arguments: nil))
}
self.messages.append(
ClawdbotChatMessage(
id: UUID(),
role: "user",
content: userContent,
timestamp: Date().timeIntervalSince1970 * 1000))
// Clear input immediately for responsive UX (before network await)
self.input = ""
self.attachments = []
do {
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
message: messageText,
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
if response.runId != runId {
self.clearPendingRun(runId)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
}
} catch {
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
}
self.isSending = false
}
private func performAbort() async {
guard !self.pendingRuns.isEmpty else { return }
guard !self.isAborting else { return }
self.isAborting = true
defer { self.isAborting = false }
let runIds = Array(self.pendingRuns)
for runId in runIds {
do {
try await self.transport.abortRun(sessionKey: self.sessionKey, runId: runId)
} catch {
// Best-effort.
}
}
}
private func fetchSessions(limit: Int?) async {
do {
let res = try await self.transport.listSessions(limit: limit)
self.sessions = res.sessions
} catch {
// Best-effort.
}
}
private func performSwitchSession(to sessionKey: String) async {
let next = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !next.isEmpty else { return }
guard next != self.sessionKey else { return }
self.sessionKey = next
await self.bootstrap()
}
private func placeholderSession(key: String) -> ClawdbotChatSessionEntry {
ClawdbotChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: nil,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
model: nil,
contextTokens: nil)
}
private func handleTransportEvent(_ evt: ClawdbotChatTransportEvent) {
switch evt {
case let .health(ok):
self.healthOK = ok
case .tick:
Task { await self.pollHealthIfNeeded(force: false) }
case let .chat(chat):
self.handleChatEvent(chat)
case let .agent(agent):
self.handleAgentEvent(agent)
case .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
}
}
private func handleChatEvent(_ chat: ClawdbotChatEventPayload) {
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
return
}
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
if !isOurRun {
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
switch chat.state {
case "final", "aborted", "error":
self.streamingAssistantText = nil
self.pendingToolCallsById = [:]
Task { await self.refreshHistoryAfterRun() }
default:
break
}
return
}
switch chat.state {
case "final", "aborted", "error":
if chat.state == "error" {
self.errorText = chat.errorMessage ?? "Chat failed"
}
if let runId = chat.runId {
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
Task { await self.refreshHistoryAfterRun() }
default:
break
}
}
private func handleAgentEvent(_ evt: ClawdbotAgentEventPayload) {
if let sessionId, evt.runId != sessionId {
return
}
switch evt.stream {
case "assistant":
if let text = evt.data["text"]?.value as? String {
self.streamingAssistantText = text
}
case "tool":
guard let phase = evt.data["phase"]?.value as? String else { return }
guard let name = evt.data["name"]?.value as? String else { return }
guard let toolCallId = evt.data["toolCallId"]?.value as? String else { return }
if phase == "start" {
let args = evt.data["args"]
self.pendingToolCallsById[toolCallId] = ClawdbotChatPendingToolCall(
toolCallId: toolCallId,
name: name,
args: args,
startedAt: evt.ts.map(Double.init) ?? Date().timeIntervalSince1970 * 1000,
isError: nil)
} else if phase == "result" {
self.pendingToolCallsById[toolCallId] = nil
}
default:
break
}
}
private func refreshHistoryAfterRun() async {
do {
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
self.messages = Self.decodeMessages(payload.messages ?? [])
self.sessionId = payload.sessionId
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
} catch {
chatUILogger.error("refresh history failed \(error.localizedDescription, privacy: .public)")
}
}
private func armPendingRunTimeout(runId: String) {
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in
let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 }
try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.pendingRuns.contains(runId) else { return }
self.clearPendingRun(runId)
self.errorText = "Timed out waiting for a reply; try again or refresh."
}
}
}
private func clearPendingRun(_ runId: String) {
self.pendingRuns.remove(runId)
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = nil
}
private func clearPendingRuns(reason: String?) {
for runId in self.pendingRuns {
self.pendingRunTimeoutTasks[runId]?.cancel()
}
self.pendingRunTimeoutTasks.removeAll()
self.pendingRuns.removeAll()
if let reason, !reason.isEmpty {
self.errorText = reason
}
}
private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return
}
self.lastHealthPollAt = Date()
do {
let ok = try await self.transport.requestHealth(timeoutMs: 5000)
self.healthOK = ok
} catch {
self.healthOK = false
}
}
private func loadAttachments(urls: [URL]) async {
for url in urls {
do {
let data = try await Task.detached { try Data(contentsOf: url) }.value
await self.addImageAttachment(
url: url,
data: data,
fileName: url.lastPathComponent,
mimeType: Self.mimeType(for: url) ?? "application/octet-stream")
} catch {
await MainActor.run { self.errorText = error.localizedDescription }
}
}
}
private static func mimeType(for url: URL) -> String? {
let ext = url.pathExtension
guard !ext.isEmpty else { return nil }
return (UTType(filenameExtension: ext) ?? .data).preferredMIMEType
}
private func addImageAttachment(url: URL?, data: Data, fileName: String, mimeType: String) async {
if data.count > 5_000_000 {
self.errorText = "Attachment \(fileName) exceeds 5 MB limit"
return
}
let uti: UTType = {
if let url {
return UTType(filenameExtension: url.pathExtension) ?? .data
}
return UTType(mimeType: mimeType) ?? .data
}()
guard uti.conforms(to: .image) else {
self.errorText = "Only image attachments are supported right now"
return
}
let preview = Self.previewImage(data: data)
self.attachments.append(
ClawdbotPendingAttachment(
url: url,
data: data,
fileName: fileName,
mimeType: mimeType,
preview: preview))
}
private static func previewImage(data: Data) -> ClawdbotPlatformImage? {
#if canImport(AppKit)
NSImage(data: data)
#elseif canImport(UIKit)
UIImage(data: data)
#else
nil
#endif
}
}