feat(chat): Swift chat parity (abort/sessions/stream)

This commit is contained in:
Peter Steinberger
2025-12-17 15:51:31 +01:00
parent cc235fc312
commit 428a82e734
16 changed files with 1131 additions and 54 deletions

View File

@@ -169,19 +169,38 @@ struct ClawdisChatComposer: View {
}
private var sendButton: some View {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
Group {
if self.viewModel.pendingRunCount > 0 {
Button {
self.viewModel.abort()
} label: {
if self.viewModel.isAborting {
ProgressView().controlSize(.small)
} else {
Image(systemName: "stop.fill")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.bordered)
.tint(.red)
.controlSize(.small)
.disabled(self.viewModel.isAborting)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
}
#if os(macOS)

View File

@@ -158,6 +158,63 @@ struct ChatTypingIndicatorBubble: View {
}
}
@MainActor
struct ChatStreamingAssistantBubble: View {
let text: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Label("Assistant (streaming)", systemImage: "sparkles")
.font(.caption)
.foregroundStyle(.secondary)
ChatMarkdownBody(text: self.text)
}
.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
struct ChatPendingToolsBubble: View {
let toolCalls: [ClawdisChatPendingToolCall]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label("Running tools…", systemImage: "hammer")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(self.toolCalls) { call in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(call.name)
.font(.footnote.monospaced())
.lineLimit(1)
Spacer(minLength: 0)
ProgressView().controlSize(.mini)
}
.padding(10)
.background(Color.white.opacity(0.06))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
.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 {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@@ -202,6 +259,48 @@ private struct MarkdownTextView: View {
}
}
@MainActor
private struct ChatMarkdownBody: View {
let text: String
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)
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)
}
}
}
}
.textSelection(.enabled)
}
}
@MainActor
private struct CodeBlockView: View {
let code: String

View File

@@ -1,6 +1,8 @@
import ClawdisKit
import Foundation
// NOTE: keep this file lightweight; decode must be resilient to varying transcript formats.
#if canImport(AppKit)
import AppKit
@@ -11,25 +13,125 @@ import UIKit
public typealias ClawdisPlatformImage = UIImage
#endif
public struct ClawdisChatUsageCost: 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 ClawdisChatUsage: Codable, Hashable, Sendable {
public let input: Int?
public let output: Int?
public let cacheRead: Int?
public let cacheWrite: Int?
public let cost: ClawdisChatUsageCost?
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(ClawdisChatUsageCost.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 ClawdisChatMessageContent: 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: 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: 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
}
}
}
@@ -38,27 +140,47 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
public let role: String
public let content: [ClawdisChatMessageContent]
public let timestamp: Double?
public let toolCallId: String?
public let usage: ClawdisChatUsage?
public let stopReason: String?
enum CodingKeys: String, CodingKey {
case role, content, timestamp
case role
case content
case timestamp
case toolCallId
case tool_call_id
case usage
case stopReason
}
public init(
id: UUID = .init(),
role: String,
content: [ClawdisChatMessageContent],
timestamp: Double?)
timestamp: Double?,
toolCallId: String? = nil,
usage: ClawdisChatUsage? = nil,
stopReason: String? = nil)
{
self.id = id
self.role = role
self.content = content
self.timestamp = timestamp
self.toolCallId = toolCallId
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.usage = try container.decodeIfPresent(ClawdisChatUsage.self, forKey: .usage)
self.stopReason = try container.decodeIfPresent(String.self, forKey: .stopReason)
if let decoded = try? container.decode([ClawdisChatMessageContent].self, forKey: .content) {
self.content = decoded
@@ -71,15 +193,30 @@ public struct ClawdisChatMessage: Codable, Identifiable, Sendable {
ClawdisChatMessageContent(
type: "text",
text: text,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: 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.usage, forKey: .usage)
try container.encodeIfPresent(self.stopReason, forKey: .stopReason)
try container.encode(self.content, forKey: .content)
}
}
public struct ClawdisChatHistoryPayload: Codable, Sendable {
@@ -102,6 +239,24 @@ public struct ClawdisChatEventPayload: Codable, Sendable {
public let errorMessage: String?
}
public struct ClawdisAgentEventPayload: 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 ClawdisChatPendingToolCall: 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 ClawdisGatewayHealthOK: Codable, Sendable {
public let ok: Bool?
}

View File

@@ -0,0 +1,69 @@
import Foundation
public struct ClawdisChatSessionsDefaults: Codable, Sendable {
public let model: String?
public let contextTokens: Int?
}
public enum ClawdisChatSessionSyncing: Codable, Hashable, Sendable {
case bool(Bool)
case string(String)
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let b = try? container.decode(Bool.self) {
self = .bool(b)
return
}
if let s = try? container.decode(String.self) {
self = .string(s)
return
}
throw DecodingError.typeMismatch(
ClawdisChatSessionSyncing.self,
DecodingError.Context(
codingPath: decoder.codingPath,
debugDescription: "Expected Bool or String"))
}
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .bool(b):
try container.encode(b)
case let .string(s):
try container.encode(s)
}
}
}
public struct ClawdisChatSessionEntry: Codable, Identifiable, Sendable, Hashable {
public var id: String { self.key }
public let key: String
public let kind: 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 let syncing: ClawdisChatSessionSyncing?
}
public struct ClawdisChatSessionsListResponse: Codable, Sendable {
public let ts: Double?
public let path: String?
public let count: Int?
public let defaults: ClawdisChatSessionsDefaults?
public let sessions: [ClawdisChatSessionEntry]
}

View File

@@ -0,0 +1,67 @@
import Observation
import SwiftUI
@MainActor
struct ChatSessionsSheet: View {
@Bindable var viewModel: ClawdisChatViewModel
@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.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

@@ -4,6 +4,7 @@ public enum ClawdisChatTransportEvent: Sendable {
case health(ok: Bool)
case tick
case chat(ClawdisChatEventPayload)
case agent(ClawdisAgentEventPayload)
case seqGap
}
@@ -16,6 +17,9 @@ public protocol ClawdisChatTransport: Sendable {
idempotencyKey: String,
attachments: [ClawdisChatAttachmentPayload]) async throws -> ClawdisChatSendResponse
func abortRun(sessionKey: String, runId: String) async throws
func listSessions(limit: Int?) async throws -> ClawdisChatSessionsListResponse
func requestHealth(timeoutMs: Int) async throws -> Bool
func events() -> AsyncStream<ClawdisChatTransportEvent>
@@ -24,4 +28,18 @@ public protocol ClawdisChatTransport: Sendable {
extension ClawdisChatTransport {
public func setActiveSessionKey(_: String) async throws {}
public func abortRun(sessionKey _: String, runId _: String) async throws {
throw NSError(
domain: "ClawdisChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "chat.abort not supported by this transport"])
}
public func listSessions(limit _: Int?) async throws -> ClawdisChatSessionsListResponse {
throw NSError(
domain: "ClawdisChatTransport",
code: 0,
userInfo: [NSLocalizedDescriptionKey: "sessions.list not supported by this transport"])
}
}

View File

@@ -4,6 +4,7 @@ import SwiftUI
public struct ClawdisChatView: View {
@State private var viewModel: ClawdisChatViewModel
@State private var scrollerBottomID = UUID()
@State private var showSessions = false
public init(viewModel: ClawdisChatViewModel) {
self._viewModel = State(initialValue: viewModel)
@@ -24,6 +25,9 @@ public struct ClawdisChatView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.onAppear { self.viewModel.load() }
.sheet(isPresented: self.$showSessions) {
ChatSessionsSheet(viewModel: self.viewModel)
}
}
private var messageList: some View {
@@ -42,6 +46,16 @@ public struct ClawdisChatView: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
if !self.viewModel.pendingToolCalls.isEmpty {
ChatPendingToolsBubble(toolCalls: self.viewModel.pendingToolCalls)
.frame(maxWidth: .infinity, alignment: .leading)
}
if let text = self.viewModel.streamingAssistantText, !text.isEmpty {
ChatStreamingAssistantBubble(text: text)
.frame(maxWidth: .infinity, alignment: .leading)
}
Color.clear
.frame(height: 1)
.id(self.scrollerBottomID)
@@ -64,6 +78,23 @@ public struct ClawdisChatView: View {
Text(self.viewModel.healthOK ? "Connected" : "Connecting…")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 0)
Button {
self.showSessions = true
} label: {
Image(systemName: "tray.full")
}
.buttonStyle(.borderless)
.help("Sessions")
Button {
self.viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.buttonStyle(.borderless)
.help("Refresh")
}
.padding(.horizontal, 10)
.padding(.vertical, 6)

View File

@@ -20,12 +20,17 @@ public final class ClawdisChatViewModel {
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: [ClawdisPendingAttachment] = []
public private(set) var healthOK: Bool = false
public private(set) var pendingRunCount: Int = 0
public let sessionKey: String
public private(set) var sessionKey: String
public private(set) var sessionId: String?
public private(set) var streamingAssistantText: String?
public private(set) var pendingToolCalls: [ClawdisChatPendingToolCall] = []
public private(set) var sessions: [ClawdisChatSessionEntry] = []
private let transport: any ClawdisChatTransport
@ObservationIgnored
@@ -38,6 +43,12 @@ public final class ClawdisChatViewModel {
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var pendingToolCallsById: [String: ClawdisChatPendingToolCall] = [:] {
didSet {
self.pendingToolCalls = self.pendingToolCallsById.values.sorted { ($0.startedAt ?? 0) < ($1.startedAt ?? 0) }
}
}
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) {
@@ -75,6 +86,18 @@ public final class ClawdisChatViewModel {
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 func addAttachments(urls: [URL]) {
Task { await self.loadAttachments(urls: urls) }
}
@@ -89,7 +112,7 @@ public final class ClawdisChatViewModel {
public var canSend: Bool {
let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines)
return !self.isSending && (!trimmed.isEmpty || !self.attachments.isEmpty)
return !self.isSending && self.pendingRunCount == 0 && (!trimmed.isEmpty || !self.attachments.isEmpty)
}
// MARK: - Internals
@@ -99,6 +122,9 @@ public final class ClawdisChatViewModel {
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.sessionId = nil
defer { self.isLoading = false }
do {
do {
@@ -109,10 +135,12 @@ public final class ClawdisChatViewModel {
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
@@ -140,15 +168,24 @@ public final class ClawdisChatViewModel {
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: [ClawdisChatMessageContent] = [
ClawdisChatMessageContent(
type: "text",
text: messageText,
thinking: nil,
thinkingSignature: nil,
mimeType: nil,
fileName: nil,
content: nil),
content: nil,
id: nil,
name: nil,
arguments: nil),
]
let encodedAttachments = self.attachments.map { att -> ClawdisChatAttachmentPayload in
ClawdisChatAttachmentPayload(
@@ -162,9 +199,14 @@ public final class ClawdisChatViewModel {
ClawdisChatMessageContent(
type: att.type,
text: nil,
thinking: nil,
thinkingSignature: nil,
mimeType: att.mimeType,
fileName: att.fileName,
content: att.content))
content: AnyCodable(att.content),
id: nil,
name: nil,
arguments: nil))
}
self.messages.append(
ClawdisChatMessage(
@@ -180,9 +222,13 @@ public final class ClawdisChatViewModel {
thinking: self.thinkingLevel,
idempotencyKey: runId,
attachments: encodedAttachments)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
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)")
}
@@ -192,6 +238,39 @@ public final class ClawdisChatViewModel {
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 handleTransportEvent(_ evt: ClawdisChatTransportEvent) {
switch evt {
case let .health(ok):
@@ -200,6 +279,8 @@ public final class ClawdisChatViewModel {
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)
@@ -217,29 +298,66 @@ public final class ClawdisChatViewModel {
}
switch chat.state {
case "final":
if let raw = chat.message,
let msg = try? ChatPayloadDecoding.decode(raw, as: ClawdisChatMessage.self)
{
self.messages.append(msg)
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)
}
case "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: ClawdisAgentEventPayload) {
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] = ClawdisChatPendingToolCall(
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