191 lines
6.8 KiB
Swift
191 lines
6.8 KiB
Swift
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)
|
|
}
|
|
}
|