Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.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)
}
}