import ComposableArchitecture import ChatService import Foundation import MarkdownUI import SharedUIComponents import SwiftUI import ConversationServiceProvider import ChatTab import ChatAPIService import HostAppActivator struct BotMessage: View { var r: Double { messageBubbleCornerRadius } let message: DisplayedChatMessage let chat: StoreOf var id: String { message.id } var text: String { message.text } var references: [ConversationReference] { message.references } var followUp: ConversationFollowUp? { message.followUp } var errorMessages: [String] { message.errorMessages } var steps: [ConversationProgressStep] { message.steps } var thinking: [MessageThinking] { message.thinking } var editAgentRounds: [AgentRound] { message.editAgentRounds } var panelMessages: [CopilotShowMessageParams] { message.panelMessages } var codeReviewRound: CodeReviewRound? { message.codeReviewRound } @Environment(\.colorScheme) var colorScheme @AppStorage(\.chatFontSize) var chatFontSize @State var isHovering = false struct ReferenceButton: View { let references: [ConversationReference] let chat: StoreOf @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 { let files = references.map { $0.filePath } let fileHelpTexts = Dictionary(uniqueKeysWithValues: references.compactMap { reference in guard reference.url != nil else { return nil } return (reference.filePath, reference.getPathRelativeToHome()) }) let progressMessage = Text(MakeReferenceTitle(references: references)) .foregroundStyle(.secondary) HStack(spacing: 0) { ExpandableFileListView( progressMessage: progressMessage, files: files, chatFontSize: chatFontSize, helpText: "View referenced files", onFileClick: { filePath in if let reference = references.first(where: { $0.filePath == filePath }) { chat.send(.referenceClicked(reference)) } }, fileHelpTexts: fileHelpTexts ) Spacer() } } } var body: some View { WithPerceptionTracking { HStack { VStack(alignment: .leading, spacing: 8) { if !references.isEmpty { WithPerceptionTracking { ReferenceButton( references: references, chat: chat ) } } // progress step if steps.count > 0 { ProgressStep(steps: steps) } ForEach(Array(thinking.enumerated()), id: \.offset) { index, entry in ThinkingView( thinking: entry, isStreaming: index == thinking.count - 1 && isThinkingStreaming() ) } 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, isStreaming: isThinkingStreaming()) } if !text.isEmpty { Group{ ThemedMarkdownText(text: text, chat: chat) } .scaledPadding(.leading, 2) .scaledPadding(.vertical, 4) } if let codeReviewRound = codeReviewRound { CodeReviewMainView( store: chat, round: codeReviewRound ) .frame(maxWidth: .infinity) } if !errorMessages.isEmpty { buildErrorMessageView() } HStack { if shouldShowTurnStatus() { TurnStatusView( message: message, isSummarizingConversation: chat.isSummarizingConversation ) .modify { view in if message.turnStatus == .inProgress { view .scaledPadding(.leading, 6) } else { view } } } Spacer() ResponseToolBar( id: id, chat: chat, text: text, message: message ) .conditionalFontWeight(.medium) .opacity(shouldShowToolBar() ? 1 : 0) .scaledPadding(.trailing, -20) } } .padding(.leading, message.parentTurnId != nil ? 4 : 0) .shadow(color: .black.opacity(0.05), radius: 6) .contextMenu { Button("Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) } .scaledFont(.body) Button("Set as Extra System Prompt") { chat.send(.setAsExtraPromptButtonTapped(id)) } .scaledFont(.body) Divider() Button("Delete") { chat.send(.deleteMessageButtonTapped(id)) } .scaledFont(.body) } .onHover { isHovering = $0 } } } } @ViewBuilder private func buildErrorMessageView() -> some View { VStack(spacing: 4) { ForEach(errorMessages.indices, id: \.self) { index in if let attributedString = try? AttributedString(markdown: errorMessages[index]) { NotificationBanner(style: .warning) { VStack(alignment: .leading, spacing: 4) { Text(attributedString) if isSettingsActionableError(errorMessages[index]) { Button(action: { Task { try? launchHostAppAdvancedSettings() } }) { Text("Open Settings") } .buttonStyle(.link) } } } } } } .scaledPadding(.vertical, 4) } private func isSettingsActionableError(_ message: String) -> Bool { message == HardCodedToolRoundExceedErrorMessage || message == SSLCertificateErrorMessage } private func shouldShowTurnStatus() -> Bool { guard isLatestAssistantMessage() else { return false } if steps.isEmpty && editAgentRounds.isEmpty { return true } if !steps.isEmpty { return !message.text.isEmpty } return true } private func shouldShowToolBar() -> Bool { // Always show toolbar for historical messages if !isLatestAssistantMessage() { return isHovering } // 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 } private func isThinkingStreaming() -> Bool { guard isLatestAssistantMessage(), chat.isReceivingMessage else { return false } switch message.turnStatus { case .success, .error, .cancelled: return false default: return true } } } private struct TurnStatusView: View { let message: DisplayedChatMessage let isSummarizingConversation: Bool @AppStorage(\.chatFontSize) var chatFontSize var body: some View { HStack(spacing: 0) { if isSummarizingConversation { summarizingStatus } else if let turnStatus = message.turnStatus { switch turnStatus { case .inProgress: inProgressStatus case .success: completedStatus case .cancelled: cancelStatus case .error: EmptyView() case .waitForConfirmation: waitForConfirmationStatus } } } } private var inProgressStatus: some View { HStack(spacing: 4) { ProgressView() .controlSize(.small) .scaledScaleEffect(0.7) .scaledFrame(width: 16, height: 16) Text("Generating...") .scaledFont(size: chatFontSize - 1) .foregroundColor(.secondary) } } private var summarizingStatus: some View { HStack(spacing: 4) { ProgressView() .controlSize(.small) .scaledScaleEffect(0.7) .scaledFrame(width: 16, height: 16) Text("Summarizing conversation...") .scaledFont(size: chatFontSize - 1) .foregroundColor(.secondary) } } private var completedStatus: some View { statusView(icon: "checkmark.circle.fill", iconColor: .successLightGreen, text: "Completed") } private var waitForConfirmationStatus: some View { statusView(icon: "clock.fill", iconColor: .brown, text: "Waiting for your response") } private var cancelStatus: some View { statusView(icon: "slash.circle", iconColor: .secondary, text: "Stopped") } private var errorStatus: some View { statusView(icon: "xmark.circle.fill", iconColor: .red, text: "Error Occurred") } private func statusView(icon: String, iconColor: Color, text: String) -> some View { HStack(spacing: 4) { Image(systemName: icon) .scaledFont(size: chatFontSize) .foregroundColor(iconColor) .conditionalFontWeight(.medium) Text(text) .scaledFont(size: chatFontSize - 1) .foregroundColor(.secondary) } } } struct BotMessage_Previews: PreviewProvider { static let steps: [ConversationProgressStep] = [ .init(id: "001", title: "running step", description: "this is running step", status: .running, error: nil), .init(id: "002", title: "completed step", description: "this is completed step", status: .completed, error: nil), .init(id: "003", title: "failed step", description: "this is failed step", status: .failed, error: nil), .init(id: "004", title: "cancelled step", description: "this is cancelled step", status: .cancelled, error: nil) ] static let agentRounds: [AgentRound] = [ .init(roundId: 1, reply: "this is agent step 1", toolCalls: [ .init( id: "toolcall_001", name: "Tool Call 1", progressMessage: "Read Tool Call 1", status: .completed, error: nil) ]), .init(roundId: 2, reply: "this is agent step 2", toolCalls: [ .init( id: "toolcall_002", name: "Tool Call 2", progressMessage: "Running Tool Call 2", status: .running) ]) ] static var previews: some View { let chatTabInfo = ChatTabInfo(id: "id", workspacePath: "path", username: "name") BotMessage( message: .init( id: "1", role: .assistant, text: """ **Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you?**Hey**! What can I do for you? ```swift func foo() {} ``` """, references: .init( repeating: .init( uri: "/Core/Sources/ConversationTab/Views/BotMessage.swift", status: .included, kind: .class, referenceType: .file), count: 2 ), followUp: ConversationFollowUp(message: "followup question", id: "id", type: "type"), errorMessages: ["Sorry, an error occurred while generating a response."], steps: steps, editAgentRounds: agentRounds, panelMessages: [], codeReviewRound: nil, requestType: .conversation ), chat: .init(initialState: .init(), reducer: { Chat(service: ChatService.service(for: chatTabInfo)) }), ) .padding() .fixedSize(horizontal: true, vertical: true) } }