import MarkdownUI import SwiftUI struct SybilChatTranscriptView: View { var messages: [Message] var isLoading: Bool var isSending: Bool private var hasPendingAssistant: Bool { messages.contains { message in message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 24) { if isLoading && messages.isEmpty { Text("Loading messages…") .font(.footnote) .foregroundStyle(SybilTheme.textMuted) .padding(.top, 24) } ForEach(messages) { message in MessageBubble(message: message, isSending: isSending) .id(message.id) } if isSending && !hasPendingAssistant { HStack(spacing: 8) { ProgressView() .controlSize(.small) .tint(SybilTheme.textMuted) Text("Assistant is typing…") .font(.footnote) .foregroundStyle(SybilTheme.textMuted) } .id("typing-indicator") } Color.clear .frame(height: 2) .id("chat-bottom-anchor") } .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) .padding(.vertical, 18) } .frame(maxWidth: .infinity, alignment: .leading) .scrollDismissesKeyboard(.interactively) .onAppear { proxy.scrollTo("chat-bottom-anchor", anchor: .bottom) } .onChange(of: messages.map(\.id)) { _, _ in withAnimation(.easeOut(duration: 0.22)) { proxy.scrollTo("chat-bottom-anchor", anchor: .bottom) } } .onChange(of: isSending) { _, _ in withAnimation(.easeOut(duration: 0.22)) { proxy.scrollTo("chat-bottom-anchor", anchor: .bottom) } } } } } private struct MessageBubble: View { var message: Message var isSending: Bool private var toolCallMetadata: ToolCallMetadata? { message.toolCallMetadata } private var isUser: Bool { message.role == .user } private var isPendingAssistant: Bool { message.id.hasPrefix("temp-assistant-") && isSending && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } var body: some View { HStack { if let toolCallMetadata { ToolCallActivityChip( metadata: toolCallMetadata, fallbackContent: message.content ) } else { VStack(alignment: .leading, spacing: 8) { if isPendingAssistant { HStack(spacing: 8) { ProgressView() .controlSize(.small) .tint(SybilTheme.textMuted) Text("Thinking…") .font(.footnote) .foregroundStyle(SybilTheme.textMuted) } .padding(.vertical, 2) } else { Markdown(message.content) .tint(SybilTheme.primary) .foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95)) .markdownTextStyle { FontSize(15) } } } .padding(.horizontal, isUser ? 14 : 2) .padding(.vertical, isUser ? 11 : 2) .background( Group { if isUser { RoundedRectangle(cornerRadius: 16) .fill(SybilTheme.userBubble.opacity(0.86)) .overlay( RoundedRectangle(cornerRadius: 16) .stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1) ) } else { RoundedRectangle(cornerRadius: 0) .fill(Color.clear) } } ) .frame(maxWidth: isUser ? 420 : nil, alignment: isUser ? .trailing : .leading) } } } } private struct ToolCallActivityChip: View { var metadata: ToolCallMetadata var fallbackContent: String private var summary: String { if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty { return text } if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return fallbackContent } return "Ran tool '\(metadata.toolName ?? "unknown_tool")'." } private var iconName: String { let name = (metadata.toolName ?? "").lowercased() if name.contains("search") { return "globe" } if name.contains("url") || name.contains("fetch") || name.contains("http") { return "link" } return "wrench.and.screwdriver" } private var isFailed: Bool { (metadata.status ?? "").lowercased() == "failed" } var body: some View { HStack(spacing: 8) { Image(systemName: iconName) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.primary) Text(summary) .font(.caption) .foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.95) : SybilTheme.text.opacity(0.9)) .fixedSize(horizontal: false, vertical: true) } .padding(.horizontal, 10) .padding(.vertical, 7) .background( RoundedRectangle(cornerRadius: 10) .fill((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.12)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke((isFailed ? SybilTheme.danger : SybilTheme.primary).opacity(0.35), lineWidth: 1) ) ) .frame(maxWidth: 460, alignment: .leading) } }