JSONSerialization.data(withJSONObject:) looks like a normal throwing API, but for a genuinely invalid top-level object (Double.nan/ .infinity anywhere in the object graph, or a non-bridgeable type) it raises an Objective-C exception instead of a catchable Swift Error — neither try nor try? protects against that, and the process crashes. Logged as a priority roadmap item after being found while building Apple Intelligence tool calling; fixing it now before release per Rune's ask. SafeJSONEncoding (new) checks JSONSerialization.isValidJSONObject first — a plain, safe, non-throwing Bool check — before ever calling the crash-prone encode path, returning nil instead of crashing for invalid input. Verified the check itself can't be fooled (a standalone script confirmed it correctly predicts NaN, Infinity, nested NaN, and non-bridgeable-type cases, all without crashing) before wiring it in. Applied to every call site that encodes data the app doesn't fully control — tool results (ChatViewModel.generateAIResponseWithTools, DynamicMCPTool.encodeResult, MCPService.serializeToolResult for research sub-agents) and MessageRow's tool-call-detail pretty-printer. Left outbound request-body construction in the provider files alone — those dictionaries are built entirely from known Swift types the app already controls (validated settings, string content), not from tool/model/external-server output, so the same crash class isn't realistically reachable there. Also restored a test that previously had to be deliberately skipped because it reproducibly crashed the whole test process on this exact bug (documented at the time in feature_apple_intelligence_provider memory) — now passes cleanly, directly confirming the fix rather than just the new code compiling. 436 tests total, stable across two consecutive full runs.
576 lines
21 KiB
Swift
576 lines
21 KiB
Swift
//
|
|
// MessageRow.swift
|
|
// Confab
|
|
//
|
|
// Individual message display
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import SwiftUI
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
|
|
struct MessageRow: View {
|
|
let message: Message
|
|
let viewModel: ChatViewModel?
|
|
private let settings = SettingsService.shared
|
|
|
|
@State private var isExpanded = false
|
|
@State private var isThinkingExpanded = true // auto-expand while streaming, collapse after
|
|
|
|
#if os(macOS)
|
|
@State private var isHovering = false
|
|
@State private var showCopied = false
|
|
@State private var isStarred = false
|
|
#endif
|
|
|
|
init(message: Message, viewModel: ChatViewModel? = nil) {
|
|
self.message = message
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
var body: some View {
|
|
// Compact layout for system messages (tool calls)
|
|
if message.role == .system && !isErrorMessage {
|
|
compactSystemMessage
|
|
} else {
|
|
standardMessageLayout
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var standardMessageLayout: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// Role icon
|
|
roleIcon
|
|
.frame(square: 32)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Header
|
|
HStack {
|
|
Text(message.role.displayName)
|
|
.font(.headline)
|
|
.foregroundColor(Color.messageColor(for: message.role))
|
|
|
|
Spacer()
|
|
|
|
#if os(macOS)
|
|
// Star button (user/assistant messages only, visible on hover)
|
|
if (message.role == .user || message.role == .assistant) && isHovering {
|
|
Button(action: toggleStar) {
|
|
Image(systemName: isStarred ? "star.fill" : "star")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(isStarred ? .yellow : .confabSecondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.transition(.opacity)
|
|
.help("Star this message to always include it in context")
|
|
}
|
|
|
|
// Copy button (user + assistant messages, visible on hover)
|
|
if (message.role == .assistant || message.role == .user) && isHovering && !message.content.isEmpty {
|
|
Button(action: copyContent) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: showCopied ? "checkmark" : "doc.on.doc")
|
|
.font(.system(size: 11))
|
|
if showCopied {
|
|
Text("Copied!")
|
|
.font(.system(size: 11))
|
|
}
|
|
}
|
|
.foregroundColor(showCopied ? .green : .confabSecondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.transition(.opacity)
|
|
}
|
|
#endif
|
|
|
|
Text(message.timestamp, style: .time)
|
|
.font(.caption2)
|
|
.foregroundColor(.confabSecondary)
|
|
}
|
|
|
|
// Thinking / reasoning block (collapsible)
|
|
if let thinking = message.thinkingContent, !thinking.isEmpty {
|
|
thinkingBlock(thinking)
|
|
.onChange(of: message.content) { _, newContent in
|
|
// Auto-collapse when response content starts arriving
|
|
if !newContent.isEmpty && isThinkingExpanded && !message.isStreaming {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
isThinkingExpanded = false
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: message.isStreaming) { _, streaming in
|
|
// Collapse when streaming finishes
|
|
if !streaming && !message.content.isEmpty {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
isThinkingExpanded = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Content
|
|
if !message.content.isEmpty {
|
|
messageContent
|
|
}
|
|
|
|
// Generated images
|
|
if let images = message.generatedImages, !images.isEmpty {
|
|
GeneratedImagesView(images: images)
|
|
}
|
|
|
|
// File attachments
|
|
if let attachments = message.attachments, !attachments.isEmpty {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
ForEach(attachments.indices, id: \.self) { index in
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "paperclip")
|
|
.font(.caption)
|
|
Text(attachments[index].path)
|
|
.font(.caption)
|
|
.foregroundColor(.confabSecondary)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
// Token/cost/time info
|
|
if message.role == .assistant && (message.tokens != nil || message.cost != nil || message.responseTime != nil || message.wasInterrupted) {
|
|
HStack(spacing: 8) {
|
|
if let tokens = message.tokens {
|
|
Label("\(tokens)", systemImage: "chart.bar.xaxis")
|
|
if message.cost != nil || message.responseTime != nil || message.wasInterrupted {
|
|
Text("\u{2022}")
|
|
}
|
|
}
|
|
if let cost = message.cost {
|
|
Text(String(format: "$%.4f", cost))
|
|
if message.responseTime != nil || message.wasInterrupted {
|
|
Text("\u{2022}")
|
|
}
|
|
}
|
|
if let responseTime = message.responseTime {
|
|
Text(String(format: "%.1fs", responseTime))
|
|
if message.wasInterrupted {
|
|
Text("\u{2022}")
|
|
}
|
|
}
|
|
if message.wasInterrupted {
|
|
Text("⚠️ interrupted")
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
.foregroundColor(.confabSecondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.padding(16)
|
|
.background(Color.messageBackground(for: message.role))
|
|
.cornerRadius(8)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.strokeBorder(messageBorderColor, lineWidth: isErrorMessage ? 2 : 2)
|
|
)
|
|
#if os(macOS)
|
|
.onHover { hovering in
|
|
withAnimation(.easeInOut(duration: 0.15)) {
|
|
isHovering = hovering
|
|
}
|
|
}
|
|
.onAppear {
|
|
loadStarredState()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// Close standardMessageLayout - the above closing braces close it
|
|
// The body: some View now handles the split between compact and standard
|
|
|
|
// MARK: - Thinking Block
|
|
|
|
@ViewBuilder
|
|
private func thinkingBlock(_ thinking: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
// Header button
|
|
Button(action: {
|
|
withAnimation(.easeInOut(duration: 0.18)) { isThinkingExpanded.toggle() }
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
if message.isStreaming && message.content.isEmpty {
|
|
ProgressView()
|
|
.scaleEffect(0.5)
|
|
.frame(width: 12, height: 12)
|
|
Text("Thinking…")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Image(systemName: "brain")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
Text("Reasoning")
|
|
.font(.system(size: 11))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Image(systemName: isThinkingExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundStyle(.secondary.opacity(0.5))
|
|
}
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if isThinkingExpanded {
|
|
Divider()
|
|
.padding(.horizontal, 6)
|
|
ScrollView {
|
|
Text(thinking)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(10)
|
|
}
|
|
.frame(maxHeight: 220)
|
|
}
|
|
}
|
|
.background(Color.secondary.opacity(0.07))
|
|
.cornerRadius(6)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.strokeBorder(Color.secondary.opacity(0.15), lineWidth: 1)
|
|
)
|
|
}
|
|
|
|
// MARK: - Compact System Message
|
|
|
|
@ViewBuilder
|
|
private var compactSystemMessage: some View {
|
|
let expandable = message.toolCalls != nil
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Button(action: {
|
|
if expandable {
|
|
withAnimation(.easeInOut(duration: 0.18)) { isExpanded.toggle() }
|
|
}
|
|
}) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "wrench.and.screwdriver")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(message.content)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer()
|
|
|
|
if expandable {
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(.secondary.opacity(0.5))
|
|
}
|
|
|
|
Text(message.timestamp, style: .time)
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.secondary.opacity(0.7))
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if isExpanded, let calls = message.toolCalls {
|
|
Divider()
|
|
.padding(.horizontal, 8)
|
|
toolCallsDetailView(calls)
|
|
}
|
|
}
|
|
.background(Color.secondary.opacity(0.08))
|
|
.cornerRadius(6)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func toolCallsDetailView(_ calls: [ToolCallDetail]) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(calls.indices, id: \.self) { i in
|
|
let call = calls[i]
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
// Tool name + status
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "function")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(.secondary)
|
|
Text(call.name)
|
|
.font(.system(size: 11, weight: .semibold))
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
if call.result == nil {
|
|
ProgressView()
|
|
.scaleEffect(0.5)
|
|
.frame(width: 12, height: 12)
|
|
} else {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 10))
|
|
.foregroundColor(.green.opacity(0.8))
|
|
}
|
|
}
|
|
|
|
// Input
|
|
if !call.input.isEmpty && call.input != "{}" {
|
|
toolDetailSection(label: "Input", text: prettyJSON(call.input), maxHeight: 100)
|
|
}
|
|
|
|
// Result
|
|
if let result = call.result {
|
|
toolDetailSection(label: "Result", text: prettyJSON(result), maxHeight: 180)
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
|
|
if i < calls.count - 1 {
|
|
Divider().padding(.horizontal, 12)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func toolDetailSection(label: String, text: String, maxHeight: CGFloat) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(label.uppercased())
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundColor(.secondary.opacity(0.6))
|
|
ScrollView([.vertical, .horizontal]) {
|
|
Text(text)
|
|
.font(.system(size: 10, design: .monospaced))
|
|
.foregroundColor(.secondary)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.frame(maxHeight: maxHeight)
|
|
.background(Color.secondary.opacity(0.06))
|
|
.cornerRadius(4)
|
|
}
|
|
}
|
|
|
|
private func prettyJSON(_ raw: String) -> String {
|
|
guard let data = raw.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data),
|
|
let pretty = SafeJSONEncoding.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
|
let str = String(data: pretty, encoding: .utf8) else {
|
|
return raw
|
|
}
|
|
return str
|
|
}
|
|
|
|
// MARK: - Message Content
|
|
|
|
@ViewBuilder
|
|
private var messageContent: some View {
|
|
switch message.role {
|
|
case .assistant:
|
|
MarkdownContentView(content: message.content, fontSize: settings.dialogTextSize)
|
|
case .system:
|
|
if isErrorMessage {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.confabError)
|
|
.font(.system(size: settings.dialogTextSize))
|
|
Text(message.content)
|
|
.font(.system(size: settings.dialogTextSize))
|
|
.foregroundColor(.confabPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
} else {
|
|
Text(message.content)
|
|
.font(.system(size: settings.dialogTextSize))
|
|
.foregroundColor(.confabPrimary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
case .user:
|
|
// User messages: preserve line breaks as-is (plain text, not markdown)
|
|
Text(message.content)
|
|
.font(.system(size: settings.dialogTextSize))
|
|
.foregroundColor(.confabPrimary)
|
|
.lineSpacing(4)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
// MARK: - Error Detection
|
|
|
|
private var isErrorMessage: Bool {
|
|
message.role == .system && message.content.hasPrefix("\u{274C}")
|
|
}
|
|
|
|
private var messageBorderColor: Color {
|
|
if isErrorMessage {
|
|
return .confabError.opacity(0.5)
|
|
}
|
|
return Color.messageColor(for: message.role).opacity(0.3)
|
|
}
|
|
|
|
// MARK: - Copy
|
|
|
|
#if os(macOS)
|
|
private func copyContent() {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(message.content, forType: .string)
|
|
withAnimation {
|
|
showCopied = true
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
|
withAnimation {
|
|
showCopied = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadStarredState() {
|
|
if let metadata = try? DatabaseService.shared.getMessageMetadata(messageId: message.id) {
|
|
isStarred = metadata.user_starred == 1
|
|
} else {
|
|
// Unsaved message — use in-memory flag on the Message struct
|
|
isStarred = message.isStarred
|
|
}
|
|
}
|
|
|
|
private func toggleStar() {
|
|
viewModel?.toggleMessageStar(messageId: message.id)
|
|
isStarred.toggle()
|
|
}
|
|
#endif
|
|
|
|
private var roleIcon: some View {
|
|
Image(systemName: message.role.iconName)
|
|
.font(.title3)
|
|
.foregroundColor(Color.messageColor(for: message.role))
|
|
.frame(width: 32, height: 32)
|
|
.background(
|
|
Circle()
|
|
.fill(Color.messageColor(for: message.role).opacity(0.15))
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Generated Images Display
|
|
|
|
struct GeneratedImagesView: View {
|
|
let images: [Data]
|
|
@State private var savedMessage: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
ForEach(images.indices, id: \.self) { index in
|
|
if let nsImage = platformImage(from: images[index]) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
#if os(macOS)
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(maxWidth: 512, maxHeight: 512)
|
|
.cornerRadius(8)
|
|
.shadow(color: .black.opacity(0.3), radius: 4)
|
|
.contextMenu {
|
|
Button("Save to Downloads") {
|
|
saveImage(data: images[index], index: index)
|
|
}
|
|
Button("Copy Image") {
|
|
copyImage(nsImage)
|
|
}
|
|
}
|
|
#else
|
|
Image(uiImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(maxWidth: 512, maxHeight: 512)
|
|
.cornerRadius(8)
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
if let msg = savedMessage {
|
|
Text(msg)
|
|
.font(.caption2)
|
|
.foregroundColor(.green)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func platformImage(from data: Data) -> NSImage? {
|
|
NSImage(data: data)
|
|
}
|
|
|
|
private func saveImage(data: Data, index: Int) {
|
|
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
let timestamp = Int(Date().timeIntervalSince1970)
|
|
let filename = "oai_image_\(timestamp)_\(index).png"
|
|
let fileURL = downloads.appendingPathComponent(filename)
|
|
|
|
do {
|
|
try data.write(to: fileURL)
|
|
withAnimation { savedMessage = "Saved to \(filename)" }
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
|
|
withAnimation { savedMessage = nil }
|
|
}
|
|
} catch {
|
|
withAnimation { savedMessage = "Failed to save: \(error.localizedDescription)" }
|
|
}
|
|
}
|
|
|
|
private func copyImage(_ image: NSImage) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.writeObjects([image])
|
|
}
|
|
#else
|
|
private func platformImage(from data: Data) -> UIImage? {
|
|
UIImage(data: data)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#Preview {
|
|
VStack(spacing: 12) {
|
|
MessageRow(message: Message.mockUser1)
|
|
MessageRow(message: Message.mockAssistant1)
|
|
MessageRow(message: Message.mockSystem)
|
|
}
|
|
.padding()
|
|
.background(Color.confabBackground)
|
|
}
|