265 lines
9.6 KiB
Swift
265 lines
9.6 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: 26) {
|
|
if isLoading && messages.isEmpty {
|
|
Text("Loading messages…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.padding(.top, 24)
|
|
}
|
|
|
|
ForEach(messages) { message in
|
|
MessageBubble(message: message, isSending: isSending)
|
|
.frame(maxWidth: .infinity)
|
|
.id(message.id)
|
|
}
|
|
|
|
if isSending && !hasPendingAssistant {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.textMuted)
|
|
Text("Assistant is typing…")
|
|
.font(.sybil(.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(alignment: .top, spacing: 0) {
|
|
leadingSpacer
|
|
|
|
if let toolCallMetadata {
|
|
ToolCallActivityChip(
|
|
metadata: toolCallMetadata,
|
|
fallbackContent: message.content,
|
|
createdAt: message.createdAt
|
|
)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if isPendingAssistant {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.primary)
|
|
Text("Thinking…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
.padding(.vertical, 2)
|
|
} else {
|
|
Markdown(message.content)
|
|
.tint(SybilTheme.primary)
|
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
|
.markdownTheme(.sybilReadable)
|
|
}
|
|
}
|
|
.padding(.horizontal, isUser ? 14 : 2)
|
|
.padding(.vertical, isUser ? 13 : 2)
|
|
.background(
|
|
Group {
|
|
if isUser {
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(SybilTheme.userBubbleGradient)
|
|
.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)
|
|
}
|
|
|
|
trailingSpacer
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var leadingSpacer: some View {
|
|
if isUser {
|
|
Spacer(minLength: 44)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var trailingSpacer: some View {
|
|
if !isUser {
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ToolCallActivityChip: View {
|
|
var metadata: ToolCallMetadata
|
|
var fallbackContent: String
|
|
var createdAt: Date
|
|
|
|
private var summary: String {
|
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
|
return text
|
|
}
|
|
if (metadata.status ?? "").lowercased() == "failed",
|
|
let error = metadata.error?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!error.isEmpty {
|
|
return "Tool failed: \(error)"
|
|
}
|
|
if let preview = metadata.resultPreview?.trimmingCharacters(in: .whitespacesAndNewlines), !preview.isEmpty {
|
|
return preview
|
|
}
|
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
return fallbackContent
|
|
}
|
|
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
|
|
}
|
|
|
|
private var toolLabel: String {
|
|
let raw = metadata.toolName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !raw.isEmpty else { return "Tool call" }
|
|
return raw
|
|
.replacingOccurrences(of: "_", with: " ")
|
|
.split(separator: " ")
|
|
.map { word in
|
|
word.prefix(1).uppercased() + word.dropFirst()
|
|
}
|
|
.joined(separator: " ")
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
private var detailLabel: String {
|
|
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
|
|
if let durationMs = metadata.durationMs, durationMs > 0 {
|
|
pieces.append("\(durationMs) ms")
|
|
}
|
|
pieces.append(createdAt.sybilShortTimeLabel)
|
|
return pieces.joined(separator: " • ")
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 11) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
|
)
|
|
Image(systemName: iconName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
|
|
}
|
|
.frame(width: 30, height: 30)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(summary)
|
|
.font(.sybil(.subheadline))
|
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
|
|
.lineSpacing(3)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
HStack(spacing: 6) {
|
|
Text(toolLabel)
|
|
.font(.sybil(.caption2, weight: .semibold))
|
|
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
|
|
.lineLimit(1)
|
|
|
|
Text(detailLabel)
|
|
.font(.sybil(.caption2))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
|
|
)
|
|
)
|
|
.frame(maxWidth: 520, alignment: .leading)
|
|
}
|
|
}
|