import ComposableArchitecture import ChatService import Foundation import MarkdownUI import SharedUIComponents import SwiftUI import ConversationServiceProvider import ChatTab import ChatAPIService struct BotMessage: View { var r: Double { messageBubbleCornerRadius } let id: String let text: String let references: [ConversationReference] let followUp: ConversationFollowUp? let errorMessages: [String] let chat: StoreOf let steps: [ConversationProgressStep] let editAgentRounds: [AgentRound] let panelMessages: [CopilotShowMessageParams] @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @State var isReferencesPresented = false struct ResponseToolBar: View { let id: String let chat: StoreOf let text: String var body: some View { HStack(spacing: 4) { UpvoteButton { rating in chat.send(.upvote(id, rating)) } DownvoteButton { rating in chat.send(.downvote(id, rating)) } CopyButton { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) chat.send(.copyCode(id)) } Spacer() // Pushes the buttons to the left } } } struct ReferenceButton: View { var r: Double { messageBubbleCornerRadius } let references: [ConversationReference] let chat: StoreOf @Binding var isReferencesPresented: Bool @State var isReferencesHovered = false @AppStorage(\.chatFontSize) var chatFontSize func MakeReferenceTitle(references: [ConversationReference]) -> String { guard !references.isEmpty else { return "" } let count = references.count let title = count > 1 ? "Used \(count) references" : "Used \(count) reference" return title } var body: some View { VStack(alignment: .leading, spacing: 8) { Button(action: { isReferencesPresented.toggle() }, label: { HStack(spacing: 4) { Image(systemName: isReferencesPresented ? "chevron.down" : "chevron.right") Text(MakeReferenceTitle(references: references)) .font(.system(size: chatFontSize)) } .background { RoundedRectangle(cornerRadius: r - 4) .fill(isReferencesHovered ? Color.gray.opacity(0.1) : Color.clear) } .foregroundStyle(.secondary) }) .buttonStyle(HoverButtonStyle()) .accessibilityValue(isReferencesPresented ? "Collapse" : "Expand") if isReferencesPresented { ReferenceList(references: references, chat: chat) .background( RoundedRectangle(cornerRadius: 5) .stroke(Color.gray, lineWidth: 0.2) ) } } } } private var agentWorkingStatus: some View { HStack(spacing: 4) { ProgressView() .controlSize(.small) .frame(width: 20, height: 16) .scaleEffect(0.7) Text("Working...") .font(.system(size: chatFontSize)) .foregroundColor(.secondary) } } var body: some View { HStack { VStack(alignment: .leading, spacing: 8) { CopilotMessageHeader() .padding(.leading, 6) if !references.isEmpty { WithPerceptionTracking { ReferenceButton( references: references, chat: chat, isReferencesPresented: $isReferencesPresented ) } } // progress step if steps.count > 0 { ProgressStep(steps: steps) } if !panelMessages.isEmpty { WithPerceptionTracking { ForEach(panelMessages.indices, id: \.self) { index in FunctionMessage(text: panelMessages[index].message, chat: chat) } } } if editAgentRounds.count > 0 { ProgressAgentRound(rounds: editAgentRounds, chat: chat) } if !text.isEmpty { ThemedMarkdownText(text: text, chat: chat) } if !errorMessages.isEmpty { VStack(spacing: 4) { ForEach(errorMessages.indices, id: \.self) { index in if let attributedString = try? AttributedString(markdown: errorMessages[index]) { NotificationBanner(style: .warning) { Text(attributedString) } } } } } if shouldShowWorkingStatus() { agentWorkingStatus } if shouldShowToolBar() { ResponseToolBar(id: id, chat: chat, text: text) } } .shadow(color: .black.opacity(0.05), radius: 6) .contextMenu { Button("Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } Button("Set as Extra System Prompt") { chat.send(.setAsExtraPromptButtonTapped(id)) } Divider() Button("Delete") { chat.send(.deleteMessageButtonTapped(id)) } } } } private func shouldShowWorkingStatus() -> Bool { let hasRunningStep: Bool = steps.contains(where: { $0.status == .running }) let hasRunningRound: Bool = editAgentRounds.contains(where: { round in return round.toolCalls?.contains(where: { $0.status == .running }) ?? false }) if hasRunningStep || hasRunningRound { return false } // Only show working status for the current bot message being received return chat.isReceivingMessage && isLatestAssistantMessage() } private func shouldShowToolBar() -> Bool { // Always show toolbar for historical messages if !isLatestAssistantMessage() { return true } // For current message, only show toolbar when message is complete return !chat.isReceivingMessage } private func isLatestAssistantMessage() -> Bool { let lastMessage = chat.history.last return lastMessage?.role == .assistant && lastMessage?.id == id } } struct ReferenceList: View { let references: [ConversationReference] let chat: StoreOf private let maxVisibleItems: Int = 6 @State private var itemHeight: CGFloat = 16 @AppStorage(\.chatFontSize) var chatFontSize struct ReferenceView: View { let references: [ConversationReference] let chat: StoreOf @AppStorage(\.chatFontSize) var chatFontSize @Binding var itemHeight: CGFloat var body: some View { VStack(alignment: .leading, spacing: 0) { ForEach(0..