Files
rune 768b0901da Fix the JSONSerialization crash risk before release
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.
2026-08-28 13:28:28 +02:00

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)
}