// // 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 #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 (assistant messages only, visible on hover) if message.role == .assistant && 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) } // 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: - Compact System Message @ViewBuilder private var compactSystemMessage: some View { HStack(spacing: 8) { Image(systemName: "wrench.and.screwdriver") .font(.system(size: 11)) .foregroundColor(.secondary) Text(message.content) .font(.system(size: 11)) .foregroundColor(.secondary) Spacer() Text(message.timestamp, style: .time) .font(.system(size: 10)) .foregroundColor(.secondary.opacity(0.7)) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color.secondary.opacity(0.08)) .cornerRadius(6) } // 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) }