Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
2026-02-20 00:09:02 -08:00

127 lines
4.5 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 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 {
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)
}
}
}