// // MessageRow.swift // oAI // // Individual message display // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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 : .oaiSecondary) } .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 : .oaiSecondary) } .buttonStyle(.plain) .transition(.opacity) } #endif Text(message.timestamp, style: .time) .font(.caption2) .foregroundColor(.oaiSecondary) } // 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(.oaiSecondary) } } } .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(.oaiSecondary) } } } .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 = try? JSONSerialization.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(.oaiError) .font(.system(size: settings.dialogTextSize)) Text(message.content) .font(.system(size: settings.dialogTextSize)) .foregroundColor(.oaiPrimary) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } } else { Text(message.content) .font(.system(size: settings.dialogTextSize)) .foregroundColor(.oaiPrimary) .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(.oaiPrimary) .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 .oaiError.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.oaiBackground) }