feat(chat): share SwiftUI chat across macOS+iOS

This commit is contained in:
Peter Steinberger
2025-12-14 00:17:07 +00:00
parent c286573f5c
commit e6d522493b
22 changed files with 2001 additions and 1061 deletions

View File

@@ -16,6 +16,8 @@ actor BridgeSession {
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private(set) var state: State = .idle
@@ -106,6 +108,16 @@ actor BridgeSession {
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
@@ -127,14 +139,114 @@ actor BridgeSession {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func timeoutRPC(id: String) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: request timeout",
]))
}
private func failRPC(id: String, error: Error) async {
guard let cont = self.pendingRPC.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 10, userInfo: [

View File

@@ -0,0 +1,33 @@
import ClawdisChatUI
import SwiftUI
struct ChatSheet: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: ClawdisChatViewModel
init(bridge: BridgeSession, sessionKey: String = "main") {
let transport = IOSBridgeChatTransport(bridge: bridge)
self._viewModel = StateObject(
wrappedValue: ClawdisChatViewModel(
sessionKey: sessionKey,
transport: transport))
}
var body: some View {
NavigationStack {
ClawdisChatView(viewModel: self.viewModel)
.navigationTitle("Chat")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.dismiss()
} label: {
Image(systemName: "xmark")
}
.accessibilityLabel("Close")
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
import ClawdisChatUI
import ClawdisKit
import Foundation
struct IOSBridgeChatTransport: ClawdisChatTransport, Sendable {
private let bridge: BridgeSession
init(bridge: BridgeSession) {
self.bridge = bridge
}
func setActiveSessionKey(_ sessionKey: String) async throws {
struct Subscribe: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
try await self.bridge.sendEvent(event: "chat.subscribe", payloadJSON: json)
}
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload {
struct Params: Codable { var sessionKey: String }
let data = try JSONEncoder().encode(Params(sessionKey: sessionKey))
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.history", paramsJSON: json, timeoutSeconds: 15)
return try JSONDecoder().decode(ClawdisChatHistoryPayload.self, from: res)
}
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
{
struct Params: Codable {
var sessionKey: String
var message: String
var thinking: String
var attachments: [ClawdisChatAttachmentPayload]?
var timeoutMs: Int
var idempotencyKey: String
}
let params = Params(
sessionKey: sessionKey,
message: message,
thinking: thinking,
attachments: attachments.isEmpty ? nil : attachments,
timeoutMs: 30000,
idempotencyKey: idempotencyKey)
let data = try JSONEncoder().encode(params)
let json = String(data: data, encoding: .utf8)
let res = try await self.bridge.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
return try JSONDecoder().decode(ClawdisChatSendResponse.self, from: res)
}
func requestHealth(timeoutMs: Int) async throws -> Bool {
let seconds = max(1, Int(ceil(Double(timeoutMs) / 1000.0)))
let res = try await self.bridge.request(method: "health", paramsJSON: nil, timeoutSeconds: seconds)
return (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: res))?.ok ?? true
}
func events() -> AsyncStream<ClawdisChatTransportEvent> {
AsyncStream { continuation in
let task = Task {
let stream = await self.bridge.subscribeServerEvents()
for await evt in stream {
if Task.isCancelled { return }
switch evt.event {
case "tick":
continuation.yield(.tick)
case "seqGap":
continuation.yield(.seqGap)
case "health":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
let ok = (try? JSONDecoder().decode(ClawdisGatewayHealthOK.self, from: data))?.ok ?? true
continuation.yield(.health(ok: ok))
case "chat":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { break }
if let payload = try? JSONDecoder().decode(ClawdisChatEventPayload.self, from: data) {
continuation.yield(.chat(payload))
}
default:
break
}
}
}
continuation.onTermination = { @Sendable _ in
task.cancel()
}
}
}
}

View File

@@ -15,6 +15,8 @@ final class NodeAppModel: ObservableObject {
private var bridgeTask: Task<Void, Never>?
let voiceWake = VoiceWakeManager()
var bridgeSession: BridgeSession { self.bridge }
init() {
self.voiceWake.configure { [weak self] cmd in
guard let self else { return }

View File

@@ -1,50 +1,84 @@
import SwiftUI
struct RootCanvas: View {
@State private var isShowingSettings = false
@EnvironmentObject private var appModel: NodeAppModel
@State private var presentedSheet: PresentedSheet?
private enum PresentedSheet: Identifiable {
case settings
case chat
var id: Int {
switch self {
case .settings: 0
case .chat: 1
}
}
}
var body: some View {
ZStack(alignment: .topTrailing) {
ScreenTab()
Button {
self.isShowingSettings = true
} label: {
Image(systemName: "gearshape.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(0.18),
.white.opacity(0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
VStack(spacing: 10) {
OverlayButton(systemImage: "text.bubble.fill") {
self.presentedSheet = .chat
}
.accessibilityLabel("Chat")
OverlayButton(systemImage: "gearshape.fill") {
self.presentedSheet = .settings
}
.accessibilityLabel("Settings")
}
.buttonStyle(.plain)
.padding(.top, 10)
.padding(.trailing, 10)
.accessibilityLabel("Settings")
}
.sheet(isPresented: self.$isShowingSettings) {
SettingsTab()
.sheet(item: self.$presentedSheet) { sheet in
switch sheet {
case .settings:
SettingsTab()
case .chat:
ChatSheet(bridge: self.appModel.bridgeSession)
}
}
.preferredColorScheme(.dark)
}
}
private struct OverlayButton: View {
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
Image(systemName: self.systemImage)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(.primary)
.padding(10)
.background {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(.ultraThinMaterial)
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(
LinearGradient(
colors: [
.white.opacity(0.18),
.white.opacity(0.04),
.clear,
],
startPoint: .topLeading,
endPoint: .bottomTrailing))
.blendMode(.overlay)
}
.overlay {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(.white.opacity(0.18), lineWidth: 0.5)
}
.shadow(color: .black.opacity(0.35), radius: 12, y: 6)
}
}
.buttonStyle(.plain)
}
}

View File

@@ -17,6 +17,8 @@ targets:
- path: Sources
dependencies:
- package: ClawdisKit
- package: ClawdisKit
product: ClawdisChatUI
preBuildScripts:
- name: SwiftFormat (lint)
script: |

View File

@@ -41,6 +41,7 @@ let package = Package(
"ClawdisIPC",
"ClawdisProtocol",
.product(name: "ClawdisKit", package: "ClawdisKit"),
.product(name: "ClawdisChatUI", package: "ClawdisKit"),
.product(name: "MenuBarExtraAccess", package: "MenuBarExtraAccess"),
.product(name: "Subprocess", package: "swift-subprocess"),
.product(name: "Sparkle", package: "Sparkle"),

View File

@@ -39,7 +39,8 @@ actor BridgeConnectionHandler {
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (String) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil) async
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.connection.stateUpdateHandler = { [logger] state in
switch state {
@@ -94,6 +95,27 @@ actor BridgeConnectionHandler {
}
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
case "req":
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
continue
}
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
case "ping":
if !self.isAuthenticated {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
@@ -242,6 +264,15 @@ actor BridgeConnectionHandler {
}
}
func sendServerEvent(event: String, payloadJSON: String?) async {
guard self.isAuthenticated else { return }
do {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
} catch {
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {

View File

@@ -1,4 +1,5 @@
import AppKit
import ClawdisProtocol
import ClawdisKit
import Foundation
import Network
@@ -13,6 +14,8 @@ actor BridgeServer {
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
func start() async {
if self.isRunning { return }
@@ -86,6 +89,13 @@ actor BridgeServer {
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
},
onRequest: { [weak self] nodeId, req in
await self?.handleRequest(nodeId: nodeId, req: req)
?? BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
})
}
@@ -106,12 +116,15 @@ actor BridgeServer {
self.connections[nodeId] = handler
await self.beaconPresence(nodeId: nodeId, reason: "connect")
self.startPresenceTask(nodeId: nodeId)
self.ensureGatewayPushTask()
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle()
}
private struct VoiceTranscriptPayload: Codable, Sendable {
@@ -121,6 +134,26 @@ actor BridgeServer {
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "chat.subscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Subscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.insert(key)
self.chatSubscriptions[nodeId] = set
case "chat.unsubscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Unsubscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.remove(key)
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
@@ -171,6 +204,126 @@ actor BridgeServer {
}
}
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
guard allowed.contains(req.method) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
}
} else {
params = nil
}
do {
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30_000)
guard let json = String(data: data, encoding: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
}
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
}
}
private func ensureGatewayPushTask() {
if self.gatewayPushTask != nil { return }
self.gatewayPushTask = Task { [weak self] in
guard let self else { return }
do {
try await GatewayConnection.shared.refresh()
} catch {
// We'll still forward events once the gateway comes up.
}
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await self.forwardGatewayPush(push)
}
}
}
private func stopGatewayPushTaskIfIdle() {
guard self.connections.isEmpty else { return }
self.gatewayPushTask?.cancel()
self.gatewayPushTask = nil
}
private func forwardGatewayPush(_ push: GatewayPush) async {
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
guard !subscribedNodes.isEmpty else { return }
switch push {
case let .snapshot(hello):
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return }
let payloadJSON = (try? JSONEncoder().encode(payload))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case "tick":
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
}
case "chat":
guard let payload = evt.payload else { return }
let payloadData = try? JSONEncoder().encode(payload)
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
struct MinimalChat: Codable { var sessionKey: String }
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?.sessionKey
if let sessionKey {
for nodeId in subscribedNodes {
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
} else {
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
}
default:
break
}
case .seqGap:
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
}
}
}
private func beaconPresence(nodeId: String, reason: String) async {
do {
let paired = await self.store?.find(nodeId: nodeId)

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ let package = Package(
],
products: [
.library(name: "ClawdisKit", targets: ["ClawdisKit"]),
.library(name: "ClawdisChatUI", targets: ["ClawdisChatUI"]),
],
targets: [
.target(
@@ -18,6 +19,12 @@ let package = Package(
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.target(
name: "ClawdisChatUI",
dependencies: ["ClawdisKit"],
swiftSettings: [
.enableUpcomingFeature("StrictConcurrency"),
]),
.testTarget(
name: "ClawdisKitTests",
dependencies: ["ClawdisKit"]),

View File

@@ -0,0 +1,308 @@
import Foundation
import SwiftUI
#if !os(macOS)
import PhotosUI
import UniformTypeIdentifiers
#endif
@MainActor
struct ClawdisChatComposer: View {
@ObservedObject var viewModel: ClawdisChatViewModel
#if !os(macOS)
@State private var pickerItems: [PhotosPickerItem] = []
@FocusState private var isFocused: Bool
#endif
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
self.thinkingPicker
Spacer()
self.attachmentPicker
}
if !self.viewModel.attachments.isEmpty {
self.attachmentsStrip
}
self.editor
HStack {
if let error = self.viewModel.errorText {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
}
Spacer()
Button {
self.viewModel.send()
} label: {
Label(self.viewModel.isSending ? "Sending…" : "Send", systemImage: "arrow.up.circle.fill")
.font(.headline)
}
.buttonStyle(.borderedProminent)
.disabled(!self.viewModel.canSend)
}
}
.padding(14)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.card)
.shadow(color: .black.opacity(0.06), radius: 12, y: 6))
#if os(macOS)
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
self.handleDrop(providers)
}
#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)
.frame(maxWidth: 200)
}
@ViewBuilder
private var attachmentPicker: some View {
#if os(macOS)
Button {
self.pickFilesMac()
} label: {
Label("Add Image", systemImage: "paperclip")
}
.buttonStyle(.bordered)
#else
PhotosPicker(selection: self.$pickerItems, maxSelectionCount: 8, matching: .images) {
Label("Add Image", systemImage: "paperclip")
}
.buttonStyle(.bordered)
.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: \ClawdisPendingAttachment.id)
{ (att: ClawdisPendingAttachment) in
HStack(spacing: 6) {
if let img = att.preview {
ClawdisPlatformImageFactory.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, 10)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
}
}
private var editor: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(ClawdisChatTheme.divider)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.card))
.overlay(self.editorOverlay)
.frame(maxHeight: 180)
}
private var editorOverlay: some View {
ZStack(alignment: .topLeading) {
if self.viewModel.input.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Message Clawd…")
.foregroundStyle(.tertiary)
.padding(.horizontal, 12)
.padding(.vertical, 10)
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input) {
self.viewModel.send()
}
.frame(minHeight: 54, maxHeight: 160)
.padding(.horizontal, 10)
.padding(.vertical, 8)
#else
TextEditor(text: self.$viewModel.input)
.font(.system(size: 15))
.scrollContentBackground(.hidden)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.focused(self.$isFocused)
#endif
}
}
#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
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: 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? ChatComposerNSTextView 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: 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: ClawdisPlatformImage?
}
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: ClawdisPlatformImage? = {
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 ClawdisPlatformImage(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,230 @@
import Foundation
import SwiftUI
private enum ChatUIConstants {
static let bubbleMaxWidth: CGFloat = 760
static let bubbleCorner: CGFloat = 16
}
@MainActor
struct ChatMessageBubble: View {
let message: ClawdisChatMessage
var body: some View {
VStack(alignment: self.isUser ? .trailing : .leading, spacing: 8) {
HStack(spacing: 8) {
if !self.isUser {
Label("Assistant", systemImage: "sparkles")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if self.isUser {
Label("You", systemImage: "person.fill")
.labelStyle(.titleAndIcon)
.font(.caption)
.foregroundStyle(.secondary)
}
}
ChatMessageBody(message: self.message, isUser: self.isUser)
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: self.isUser ? .trailing : .leading)
}
.padding(.horizontal, 2)
}
private var isUser: Bool { self.message.role.lowercased() == "user" }
}
@MainActor
private struct ChatMessageBody: View {
let message: ClawdisChatMessage
let isUser: Bool
var body: some View {
let text = self.primaryText
let split = ChatMarkdownSplitter.split(markdown: text)
VStack(alignment: .leading, spacing: 10) {
ForEach(split.blocks) { block in
switch block.kind {
case .text:
MarkdownTextView(text: block.text)
case let .code(language):
CodeBlockView(code: block.text, language: language)
}
}
if !split.images.isEmpty {
ForEach(
split.images,
id: \ChatMarkdownSplitter.InlineImage.id)
{ (item: ChatMarkdownSplitter.InlineImage) in
if let img = item.image {
ClawdisPlatformImageFactory.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)
}
}
}
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: ChatUIConstants.bubbleCorner, style: .continuous))
}
private var primaryText: String {
let parts = self.message.content.compactMap(\.text)
return parts.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}
private var inlineAttachments: [ClawdisChatMessageContent] {
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(ClawdisChatTheme.subtleCard)
}
private var bubbleBorder: some View {
RoundedRectangle(cornerRadius: ChatUIConstants.bubbleCorner, style: .continuous)
.strokeBorder(self.isUser ? Color.orange.opacity(0.35) : Color.white.opacity(0.10), lineWidth: 1)
}
}
private struct AttachmentRow: View {
let att: ClawdisChatMessageContent
var body: some View {
HStack(spacing: 8) {
Image(systemName: "paperclip")
Text(self.att.fileName ?? "Attachment")
.font(.footnote)
.lineLimit(1)
Spacer()
}
.padding(10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@MainActor
struct ChatTypingIndicatorBubble: 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(ClawdisChatTheme.subtleCard))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.10), lineWidth: 1))
.frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
}
}
@MainActor
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)
}
}
@MainActor
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)
}
}
}
@MainActor
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))
}
}

View File

@@ -0,0 +1,147 @@
import ClawdisKit
import Foundation
#if canImport(AppKit)
import AppKit
public typealias ClawdisPlatformImage = NSImage
#elseif canImport(UIKit)
import UIKit
public typealias ClawdisPlatformImage = UIImage
#endif
public struct ClawdisChatMessageContent: Codable, Hashable, Sendable {
public let type: String?
public let text: String?
public let mimeType: String?
public let fileName: String?
public let content: String?
public init(
type: String?,
text: String?,
mimeType: String?,
fileName: String?,
content: String?)
{
self.type = type
self.text = text
self.mimeType = mimeType
self.fileName = fileName
self.content = content
}
}
public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
public var id: UUID = .init()
public let role: String
public let content: [ClawdisChatMessageContent]
public let timestamp: Double?
enum CodingKeys: String, CodingKey {
case role, content, timestamp
}
public init(
id: UUID = .init(),
role: String,
content: [ClawdisChatMessageContent],
timestamp: Double?)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
}
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)
if let decoded = try? container.decode([ClawdisChatMessageContent].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 = [
ClawdisChatMessageContent(
type: "text",
text: text,
mimeType: nil,
fileName: nil,
content: nil),
]
return
}
self.content = []
}
}
public struct ClawdisChatHistoryPayload: Codable, Sendable {
public let sessionKey: String
public let sessionId: String?
public let messages: [AnyCodable]?
public let thinkingLevel: String?
}
public struct ClawdisChatSendResponse: Codable, Sendable {
public let runId: String
public let status: String
}
public struct ClawdisChatEventPayload: Codable, Sendable {
public let runId: String?
public let sessionKey: String?
public let state: String?
public let message: AnyCodable?
public let errorMessage: String?
}
public struct ClawdisGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}
public struct ClawdisPendingAttachment: 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: ClawdisPlatformImage?
public init(
url: URL?,
data: Data,
fileName: String,
mimeType: String,
type: String = "file",
preview: ClawdisPlatformImage?)
{
self.url = url
self.data = data
self.fileName = fileName
self.mimeType = mimeType
self.type = type
self.preview = preview
}
}
public struct ClawdisChatAttachmentPayload: 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 ClawdisKit
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,47 @@
import SwiftUI
#if os(macOS)
import AppKit
#else
import UIKit
#endif
enum ClawdisChatTheme {
static var surface: Color {
#if os(macOS)
Color(nsColor: .windowBackgroundColor)
#else
Color(uiColor: .systemBackground)
#endif
}
static var card: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor)
#else
Color(uiColor: .secondarySystemBackground)
#endif
}
static var subtleCard: Color {
#if os(macOS)
Color(nsColor: .textBackgroundColor).opacity(0.55)
#else
Color(uiColor: .secondarySystemBackground).opacity(0.9)
#endif
}
static var divider: Color {
Color.secondary.opacity(0.2)
}
}
enum ClawdisPlatformImageFactory {
static func image(_ image: ClawdisPlatformImage) -> Image {
#if os(macOS)
Image(nsImage: image)
#else
Image(uiImage: image)
#endif
}
}

View File

@@ -0,0 +1,27 @@
import Foundation
public enum ClawdisChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case chat(ClawdisChatEventPayload)
case seqGap
}
public protocol ClawdisChatTransport: Sendable {
func requestHistory(sessionKey: String) async throws -> ClawdisChatHistoryPayload
func sendMessage(
sessionKey: String,
message: String,
thinking: String,
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<ClawdisChatTransportEvent>
func setActiveSessionKey(_ sessionKey: String) async throws
}
extension ClawdisChatTransport {
public func setActiveSessionKey(_: String) async throws {}
}

View File

@@ -0,0 +1,100 @@
import SwiftUI
@MainActor
public struct ClawdisChatView: View {
@StateObject private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
public init(viewModel: ClawdisChatViewModel) {
self._viewModel = StateObject(wrappedValue: viewModel)
}
public var body: some View {
ZStack {
ClawdisChatTheme.surface
.ignoresSafeArea()
VStack(spacing: 14) {
self.header
self.messageList
ClawdisChatComposer(viewModel: self.viewModel)
}
.padding(.horizontal, 18)
.padding(.vertical, 16)
.frame(maxWidth: 1040)
}
.background(
LinearGradient(
colors: [
Color(red: 0.96, green: 0.97, blue: 1.0),
Color(red: 0.93, green: 0.94, blue: 0.98),
],
startPoint: .top,
endPoint: .bottom)
.opacity(0.35)
.ignoresSafeArea())
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }
}
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Clawd Chat")
.font(.title2.weight(.semibold))
Text("Session \(self.viewModel.sessionKey) · \(self.viewModel.healthOK ? "Connected" : "Connecting…")")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
self.viewModel.refresh()
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
}
private var messageList: some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 14) {
ForEach(self.viewModel.messages) { msg in
ChatMessageBubble(message: msg)
.frame(
maxWidth: .infinity,
alignment: msg.role.lowercased() == "user" ? .trailing : .leading)
}
if self.viewModel.pendingRunCount > 0 {
ChatTypingIndicatorBubble()
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: 1)
.id(self.scrollerBottomID)
}
.padding(.vertical, 10)
.padding(.horizontal, 12)
}
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(ClawdisChatTheme.card)
.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)
}
}
}
}
}

View File

@@ -0,0 +1,293 @@
import ClawdisKit
import Foundation
import OSLog
import UniformTypeIdentifiers
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
private let chatUILogger = Logger(subsystem: "com.steipete.clawdis", category: "ClawdisChatUI")
@MainActor
public final class ClawdisChatViewModel: ObservableObject {
@Published public private(set) var messages: [ClawdisChatMessage] = []
@Published public var input: String = ""
@Published public var thinkingLevel: String = "off"
@Published public private(set) var isLoading = false
@Published public private(set) var isSending = false
@Published public var errorText: String?
@Published public var attachments: [ClawdisPendingAttachment] = []
@Published public private(set) var healthOK: Bool = true
@Published public private(set) var pendingRunCount: Int = 0
public let sessionKey: String
private let transport: any ClawdisChatTransport
private var eventTask: Task<Void, Never>?
private var pendingRuns = Set<String>() {
didSet { self.pendingRunCount = self.pendingRuns.count }
}
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) {
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()
}
public func load() {
Task { await self.bootstrap() }
}
public func refresh() {
Task { await self.bootstrap() }
}
public func send() {
Task { await self.performSend() }
}
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: ClawdisPendingAttachment.ID) {
self.attachments.removeAll { $0.id == id }
}
public var canSend: Bool {
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty)
}
// MARK: - Internals
private func bootstrap() async {
self.isLoading = true
defer { self.isLoading = false }
do {
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 ?? [])
if let level = payload.thinkingLevel, !level.isEmpty {
self.thinkingLevel = level
}
await self.pollHealthIfNeeded(force: true)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("bootstrap failed \(error.localizedDescription, privacy: .public)")
}
}
private static func decodeMessages(_ raw: [AnyCodable]) -> [ClawdisChatMessage] {
raw.compactMap { item in
(try? ChatPayloadDecoding.decode(item, as: ClawdisChatMessage.self))
}
}
private func performSend() async {
guard !self.isSending else { return }
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty || !self.attachments.isEmpty else { return }
guard self.healthOK else {
self.errorText = "Gateway health not OK; cannot send"
return
}
self.isSending = true
self.errorText = nil
let runId = UUID().uuidString
let messageText = trimmed.isEmpty && !self.attachments.isEmpty ? "See attached." : trimmed
// Optimistically append user message to UI.
var userContent: [ClawdisChatMessageContent] = [
ClawdisChatMessageContent(
type: "text",
text: messageText,
mimeType: nil,
fileName: nil,
content: nil),
]
let encodedAttachments = self.attachments.map { att -> ClawdisChatAttachmentPayload in
ClawdisChatAttachmentPayload(
type: att.type,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.data.base64EncodedString())
}
for att in encodedAttachments {
userContent.append(
ClawdisChatMessageContent(
type: att.type,
text: nil,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.content))
}
self.messages.append(
ClawdisChatMessage(
id: UUID(),
role: "user",
content: userContent,
timestamp: Date().timeIntervalSince1970 * 1000))
do {
let response = try await self.transport.sendMessage(
sessionKey: self.sessionKey,
message: messageText,
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
self.pendingRuns.insert(response.runId)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
}
self.input = ""
self.attachments = []
self.isSending = false
}
private func handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
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 .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
}
}
private func handleChatEvent(_ chat: ClawdisChatEventPayload) {
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
return
}
if let runId = chat.runId, !self.pendingRuns.contains(runId) {
// Ignore events for other runs.
return
}
switch chat.state {
case "final":
if let raw = chat.message,
let msg = try? ChatPayloadDecoding.decode(raw, as: ClawdisChatMessage.self)
{
self.messages.append(msg)
}
if let runId = chat.runId {
self.pendingRuns.remove(runId)
}
case "error":
self.errorText = chat.errorMessage ?? "Chat failed"
if let runId = chat.runId {
self.pendingRuns.remove(runId)
}
default:
break
}
}
private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return
}
self.lastHealthPollAt = Date()
do {
let 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(
ClawdisPendingAttachment(
url: url,
data: data,
fileName: fileName,
mimeType: mimeType,
preview: preview))
}
private static func previewImage(data: Data) -> ClawdisPlatformImage? {
#if canImport(AppKit)
NSImage(data: data)
#elseif canImport(UIKit)
UIImage(data: data)
#else
nil
#endif
}
}

View File

@@ -0,0 +1,93 @@
import Foundation
/// Lightweight `Codable` wrapper that round-trips heterogeneous JSON payloads.
///
/// Marked `@unchecked Sendable` because it can hold reference types.
public struct AnyCodable: Codable, @unchecked Sendable, Hashable {
public let value: Any
public init(_ value: Any) { self.value = value }
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intVal = try? container.decode(Int.self) { self.value = intVal; return }
if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return }
if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return }
if let stringVal = try? container.decode(String.self) { self.value = stringVal; return }
if container.decodeNil() { self.value = NSNull(); return }
if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return }
if let array = try? container.decode([AnyCodable].self) { self.value = array; return }
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported type")
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self.value {
case let intVal as Int: try container.encode(intVal)
case let doubleVal as Double: try container.encode(doubleVal)
case let boolVal as Bool: try container.encode(boolVal)
case let stringVal as String: try container.encode(stringVal)
case is NSNull: try container.encodeNil()
case let dict as [String: AnyCodable]: try container.encode(dict)
case let array as [AnyCodable]: try container.encode(array)
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as NSDictionary:
var converted: [String: AnyCodable] = [:]
for (k, v) in dict {
guard let key = k as? String else { continue }
converted[key] = AnyCodable(v)
}
try container.encode(converted)
case let array as NSArray:
try container.encode(array.map { AnyCodable($0) })
default:
let context = EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")
throw EncodingError.invalidValue(self.value, context)
}
}
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
switch (lhs.value, rhs.value) {
case let (l as Int, r as Int): l == r
case let (l as Double, r as Double): l == r
case let (l as Bool, r as Bool): l == r
case let (l as String, r as String): l == r
case (_ as NSNull, _ as NSNull): true
case let (l as [String: AnyCodable], r as [String: AnyCodable]): l == r
case let (l as [AnyCodable], r as [AnyCodable]): l == r
default:
false
}
}
public func hash(into hasher: inout Hasher) {
switch self.value {
case let v as Int:
hasher.combine(0); hasher.combine(v)
case let v as Double:
hasher.combine(1); hasher.combine(v)
case let v as Bool:
hasher.combine(2); hasher.combine(v)
case let v as String:
hasher.combine(3); hasher.combine(v)
case _ as NSNull:
hasher.combine(4)
case let v as [String: AnyCodable]:
hasher.combine(5)
for (k, val) in v.sorted(by: { $0.key < $1.key }) {
hasher.combine(k)
hasher.combine(val)
}
case let v as [AnyCodable]:
hasher.combine(6)
for item in v {
hasher.combine(item)
}
default:
hasher.combine(999)
}
}
}

View File

@@ -157,3 +157,51 @@ public struct BridgeErrorFrame: Codable, Sendable {
self.message = message
}
}
// MARK: - Optional RPC (node -> bridge)
public struct BridgeRPCRequest: Codable, Sendable {
public let type: String
public let id: String
public let method: String
public let paramsJSON: String?
public init(type: String = "req", id: String, method: String, paramsJSON: String? = nil) {
self.type = type
self.id = id
self.method = method
self.paramsJSON = paramsJSON
}
}
public struct BridgeRPCError: Codable, Sendable, Equatable {
public let code: String
public let message: String
public init(code: String, message: String) {
self.code = code
self.message = message
}
}
public struct BridgeRPCResponse: Codable, Sendable {
public let type: String
public let id: String
public let ok: Bool
public let payloadJSON: String?
public let error: BridgeRPCError?
public init(
type: String = "res",
id: String,
ok: Bool,
payloadJSON: String? = nil,
error: BridgeRPCError? = nil)
{
self.type = type
self.id = id
self.ok = ok
self.payloadJSON = payloadJSON
self.error = error
}
}