2026-02-20 00:09:02 -08:00
|
|
|
import MarkdownUI
|
|
|
|
|
import SwiftUI
|
|
|
|
|
|
|
|
|
|
struct SybilChatTranscriptView: View {
|
|
|
|
|
var messages: [Message]
|
|
|
|
|
var isLoading: Bool
|
|
|
|
|
var isSending: Bool
|
2026-05-03 17:52:57 -07:00
|
|
|
var topContentInset: CGFloat = 0
|
2026-05-03 21:06:20 -07:00
|
|
|
var bottomContentInset: CGFloat = 0
|
2026-06-07 19:58:04 -07:00
|
|
|
var bottomPinRequestID: Int = 0
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-06-14 19:10:56 -07:00
|
|
|
@State private var hasTrackedToolCallMessages = false
|
|
|
|
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
|
|
|
|
|
2026-06-07 19:58:04 -07:00
|
|
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
2026-06-12 00:26:21 -07:00
|
|
|
private var renderItems: [TranscriptRenderItem] {
|
|
|
|
|
buildTranscriptRenderItems(from: messages)
|
|
|
|
|
}
|
2026-06-14 19:10:56 -07:00
|
|
|
private var toolCallMessageIDs: Set<String> {
|
|
|
|
|
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
|
|
|
|
|
}
|
|
|
|
|
private var enteringToolCallMessageIDs: Set<String> {
|
|
|
|
|
guard hasTrackedToolCallMessages else { return [] }
|
|
|
|
|
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
|
|
|
|
|
}
|
|
|
|
|
private var toolCallMessageIDSignature: String {
|
|
|
|
|
toolCallMessageIDs.sorted().joined(separator: "|")
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
|
|
|
|
var body: some View {
|
2026-06-07 19:58:04 -07:00
|
|
|
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)
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
|
2026-06-12 00:26:21 -07:00
|
|
|
ForEach(renderItems) { item in
|
|
|
|
|
switch item {
|
|
|
|
|
case let .message(message):
|
|
|
|
|
MessageBubble(message: message, isSending: isSending)
|
|
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
case let .toolGroup(id, messages):
|
2026-06-14 19:10:56 -07:00
|
|
|
ToolCallStackView(
|
|
|
|
|
groupID: id,
|
|
|
|
|
messages: messages,
|
|
|
|
|
entryAnimationIDs: enteringToolCallMessageIDs
|
|
|
|
|
)
|
2026-06-12 00:26:21 -07:00
|
|
|
.frame(maxWidth: .infinity)
|
|
|
|
|
.id(id)
|
|
|
|
|
}
|
2026-06-07 19:58:04 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Color.clear
|
|
|
|
|
.frame(height: 18 + bottomContentInset)
|
|
|
|
|
.id(bottomAnchorID)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-06-07 19:58:04 -07:00
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.horizontal, 14)
|
|
|
|
|
.padding(.top, 18 + topContentInset)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
2026-06-07 19:58:04 -07:00
|
|
|
.scrollDismissesKeyboard(.interactively)
|
|
|
|
|
.onAppear {
|
2026-06-14 19:10:56 -07:00
|
|
|
syncKnownToolCallMessageIDs()
|
2026-06-07 19:58:04 -07:00
|
|
|
scrollToBottom(with: proxy, animated: false)
|
|
|
|
|
}
|
2026-06-14 19:10:56 -07:00
|
|
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
|
|
|
|
syncKnownToolCallMessageIDs()
|
|
|
|
|
}
|
2026-06-07 19:58:04 -07:00
|
|
|
.onChange(of: bottomPinRequestID) { _, _ in
|
|
|
|
|
scrollToBottom(with: proxy, animated: true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
|
|
|
|
|
let action = {
|
|
|
|
|
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if animated {
|
|
|
|
|
withAnimation(.easeOut(duration: 0.18), action)
|
|
|
|
|
} else {
|
|
|
|
|
action()
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-06-14 19:10:56 -07:00
|
|
|
|
|
|
|
|
private func syncKnownToolCallMessageIDs() {
|
|
|
|
|
guard !toolCallMessageIDs.isEmpty else { return }
|
|
|
|
|
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
|
|
|
|
hasTrackedToolCallMessages = true
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 00:26:21 -07:00
|
|
|
enum TranscriptRenderItem: Identifiable {
|
|
|
|
|
case message(Message)
|
|
|
|
|
case toolGroup(id: String, messages: [Message])
|
|
|
|
|
|
|
|
|
|
var id: String {
|
|
|
|
|
switch self {
|
|
|
|
|
case let .message(message):
|
|
|
|
|
return message.id
|
|
|
|
|
case let .toolGroup(id, _):
|
|
|
|
|
return "tool-group-\(id)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
|
|
|
|
|
var items: [TranscriptRenderItem] = []
|
|
|
|
|
var toolRun: [Message] = []
|
|
|
|
|
|
|
|
|
|
func flushToolRun() {
|
|
|
|
|
guard !toolRun.isEmpty else { return }
|
|
|
|
|
if toolRun.count == 1, let message = toolRun.first {
|
|
|
|
|
items.append(.message(message))
|
|
|
|
|
} else if let first = toolRun.first {
|
|
|
|
|
items.append(.toolGroup(id: first.id, messages: toolRun))
|
|
|
|
|
}
|
|
|
|
|
toolRun.removeAll(keepingCapacity: true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for message in messages {
|
|
|
|
|
if message.toolCallMetadata != nil {
|
|
|
|
|
toolRun.append(message)
|
|
|
|
|
} else {
|
|
|
|
|
flushToolRun()
|
|
|
|
|
items.append(.message(message))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
flushToolRun()
|
|
|
|
|
return items
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
private struct MessageBubble: View {
|
|
|
|
|
var message: Message
|
|
|
|
|
var isSending: Bool
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
private var toolCallMetadata: ToolCallMetadata? {
|
|
|
|
|
message.toolCallMetadata
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 00:09:02 -08:00
|
|
|
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 {
|
2026-05-02 16:40:50 -07:00
|
|
|
HStack(alignment: .top, spacing: 0) {
|
|
|
|
|
leadingSpacer
|
2026-05-02 16:23:00 -07:00
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
if let toolCallMetadata {
|
|
|
|
|
ToolCallActivityChip(
|
|
|
|
|
metadata: toolCallMetadata,
|
2026-05-02 16:23:00 -07:00
|
|
|
fallbackContent: message.content,
|
|
|
|
|
createdAt: message.createdAt
|
2026-03-02 16:18:10 -08:00
|
|
|
)
|
|
|
|
|
} else {
|
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
2026-05-02 19:47:38 -07:00
|
|
|
if !message.attachments.isEmpty {
|
|
|
|
|
SybilAttachmentListView(
|
|
|
|
|
attachments: message.attachments,
|
|
|
|
|
tone: isUser ? .user : .assistant
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
if isPendingAssistant {
|
|
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
ProgressView()
|
|
|
|
|
.controlSize(.small)
|
2026-05-02 16:23:00 -07:00
|
|
|
.tint(SybilTheme.primary)
|
2026-03-02 16:18:10 -08:00
|
|
|
Text("Thinking…")
|
2026-05-02 16:23:00 -07:00
|
|
|
.font(.sybil(.footnote))
|
2026-03-02 16:18:10 -08:00
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-03-02 16:18:10 -08:00
|
|
|
.padding(.vertical, 2)
|
2026-05-02 19:47:38 -07:00
|
|
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
2026-03-02 16:18:10 -08:00
|
|
|
Markdown(message.content)
|
|
|
|
|
.tint(SybilTheme.primary)
|
|
|
|
|
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
2026-05-02 16:32:22 -07:00
|
|
|
.markdownTheme(.sybilReadable)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-02 16:18:10 -08:00
|
|
|
.padding(.horizontal, isUser ? 14 : 2)
|
2026-05-02 16:32:22 -07:00
|
|
|
.padding(.vertical, isUser ? 13 : 2)
|
2026-05-03 23:26:58 -07:00
|
|
|
.textSelection(.enabled)
|
2026-03-02 16:18:10 -08:00
|
|
|
.background(
|
|
|
|
|
Group {
|
|
|
|
|
if isUser {
|
|
|
|
|
RoundedRectangle(cornerRadius: 16)
|
2026-05-02 16:23:00 -07:00
|
|
|
.fill(SybilTheme.userBubbleGradient)
|
2026-03-02 16:18:10 -08:00
|
|
|
.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)
|
|
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
|
2026-05-02 16:40:50 -07:00
|
|
|
trailingSpacer
|
2026-03-02 16:18:10 -08:00
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
.frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading)
|
2026-03-02 16:18:10 -08:00
|
|
|
}
|
2026-05-02 16:40:50 -07:00
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
private var leadingSpacer: some View {
|
|
|
|
|
if isUser {
|
|
|
|
|
Spacer(minLength: 44)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ViewBuilder
|
|
|
|
|
private var trailingSpacer: some View {
|
|
|
|
|
if !isUser {
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-02 16:18:10 -08:00
|
|
|
}
|
|
|
|
|
|
2026-06-12 00:26:21 -07:00
|
|
|
private struct ToolCallStackView: View {
|
|
|
|
|
private struct CardLayout {
|
|
|
|
|
var x: CGFloat
|
|
|
|
|
var y: CGFloat
|
|
|
|
|
var scale: CGFloat
|
|
|
|
|
var opacity: Double
|
|
|
|
|
var zIndex: Double
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var groupID: String
|
|
|
|
|
var messages: [Message]
|
2026-06-14 19:10:56 -07:00
|
|
|
var entryAnimationIDs: Set<String>
|
2026-06-12 00:26:21 -07:00
|
|
|
|
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
|
|
|
@State private var isExpanded = false
|
|
|
|
|
|
|
|
|
|
private let visibleCollapsedLimit = 4
|
|
|
|
|
private let cardHeight: CGFloat = 62
|
|
|
|
|
private let expandedGap: CGFloat = 10
|
|
|
|
|
private let collapsedStepX: CGFloat = 11
|
|
|
|
|
private let collapsedStepY: CGFloat = 10
|
|
|
|
|
private let toggleSize: CGFloat = 32
|
|
|
|
|
private let toggleGap: CGFloat = 12
|
|
|
|
|
|
|
|
|
|
private var animation: Animation? {
|
|
|
|
|
reduceMotion ? nil : .easeInOut(duration: 0.34)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var visibleCollapsedCount: Int {
|
|
|
|
|
min(messages.count, visibleCollapsedLimit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var hiddenCount: Int {
|
|
|
|
|
max(0, messages.count - visibleCollapsedLimit)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var containerHeight: CGFloat {
|
|
|
|
|
if isExpanded {
|
|
|
|
|
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
|
|
|
|
|
}
|
|
|
|
|
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var accessibilityLabel: String {
|
|
|
|
|
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var body: some View {
|
|
|
|
|
HStack(alignment: .top, spacing: 0) {
|
|
|
|
|
GeometryReader { geometry in
|
|
|
|
|
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
|
|
|
|
|
let toggleX = cardWidth + toggleGap
|
|
|
|
|
|
|
|
|
|
ZStack(alignment: .topLeading) {
|
|
|
|
|
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
|
|
|
|
|
let layout = layout(for: index)
|
|
|
|
|
let depth = messages.count - index - 1
|
|
|
|
|
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
|
2026-06-14 19:10:56 -07:00
|
|
|
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
2026-06-12 00:26:21 -07:00
|
|
|
|
2026-06-14 19:10:56 -07:00
|
|
|
ToolCallStackCard(
|
|
|
|
|
message: message,
|
|
|
|
|
cardHeight: cardHeight,
|
|
|
|
|
compactLayout: true,
|
|
|
|
|
animateEntry: shouldAnimateEntry
|
|
|
|
|
)
|
2026-06-12 00:26:21 -07:00
|
|
|
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
|
|
|
|
|
.scaleEffect(layout.scale, anchor: .topLeading)
|
|
|
|
|
.opacity(layout.opacity)
|
|
|
|
|
.offset(x: layout.x, y: layout.y)
|
|
|
|
|
.zIndex(layout.zIndex)
|
|
|
|
|
.allowsHitTesting(!isHidden)
|
|
|
|
|
.accessibilityHidden(isHidden)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !isExpanded && hiddenCount > 0 {
|
|
|
|
|
Text("+\(hiddenCount)")
|
|
|
|
|
.font(.sybil(.caption2, weight: .semibold))
|
|
|
|
|
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
|
|
|
|
.padding(.horizontal, 7)
|
|
|
|
|
.padding(.vertical, 3)
|
|
|
|
|
.background(
|
|
|
|
|
Capsule()
|
|
|
|
|
.fill(Color.black.opacity(0.58))
|
|
|
|
|
.overlay(
|
|
|
|
|
Capsule()
|
|
|
|
|
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
|
|
|
|
|
.transition(.opacity)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Button {
|
|
|
|
|
withAnimation(animation) {
|
|
|
|
|
isExpanded.toggle()
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
|
|
|
.font(.system(size: 14, weight: .bold))
|
|
|
|
|
.foregroundStyle(SybilTheme.accent.opacity(0.95))
|
|
|
|
|
.frame(width: toggleSize, height: toggleSize)
|
|
|
|
|
.background(
|
|
|
|
|
Circle()
|
|
|
|
|
.fill(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
|
|
|
|
|
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .top,
|
|
|
|
|
endPoint: .bottom
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
.overlay(
|
|
|
|
|
Circle()
|
|
|
|
|
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
|
|
|
|
|
)
|
|
|
|
|
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
|
|
|
|
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
|
|
|
|
|
.offset(x: toggleX, y: 8)
|
|
|
|
|
.zIndex(Double(messages.count + 2))
|
|
|
|
|
}
|
|
|
|
|
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
|
|
|
|
|
.animation(animation, value: isExpanded)
|
|
|
|
|
}
|
|
|
|
|
.frame(height: containerHeight)
|
|
|
|
|
|
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
|
}
|
|
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func layout(for index: Int) -> CardLayout {
|
|
|
|
|
if isExpanded {
|
|
|
|
|
return CardLayout(
|
|
|
|
|
x: 0,
|
|
|
|
|
y: CGFloat(index) * (cardHeight + expandedGap),
|
|
|
|
|
scale: 1,
|
|
|
|
|
opacity: 1,
|
|
|
|
|
zIndex: Double(messages.count - index)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let depth = messages.count - index - 1
|
|
|
|
|
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
|
|
|
|
|
let isHidden = depth >= visibleCollapsedLimit
|
|
|
|
|
return CardLayout(
|
|
|
|
|
x: CGFloat(visibleDepth) * collapsedStepX,
|
|
|
|
|
y: CGFloat(visibleDepth) * collapsedStepY,
|
|
|
|
|
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
|
|
|
|
|
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
|
|
|
|
|
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct ToolCallStackCard: View {
|
|
|
|
|
var message: Message
|
|
|
|
|
var cardHeight: CGFloat
|
|
|
|
|
var compactLayout: Bool
|
2026-06-14 19:10:56 -07:00
|
|
|
var animateEntry: Bool
|
2026-06-12 00:26:21 -07:00
|
|
|
|
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
2026-06-14 19:10:56 -07:00
|
|
|
@State private var entryAnimationArmed = false
|
2026-06-12 00:26:21 -07:00
|
|
|
@State private var didEnter = false
|
|
|
|
|
|
2026-06-14 19:10:56 -07:00
|
|
|
private var isPreparingEntry: Bool {
|
|
|
|
|
(animateEntry || entryAnimationArmed) && !didEnter
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 00:26:21 -07:00
|
|
|
var body: some View {
|
|
|
|
|
Group {
|
|
|
|
|
if let metadata = message.toolCallMetadata {
|
|
|
|
|
ToolCallActivityChip(
|
|
|
|
|
metadata: metadata,
|
|
|
|
|
fallbackContent: message.content,
|
|
|
|
|
createdAt: message.createdAt,
|
|
|
|
|
compactLayout: compactLayout
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.frame(height: cardHeight, alignment: .top)
|
2026-06-14 19:10:56 -07:00
|
|
|
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
|
|
|
|
|
.offset(y: isPreparingEntry ? -8 : 0)
|
|
|
|
|
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
|
|
|
|
|
.opacity(isPreparingEntry ? 0.72 : 1)
|
2026-06-12 00:26:21 -07:00
|
|
|
.onAppear {
|
2026-06-14 19:10:56 -07:00
|
|
|
guard !didEnter, !entryAnimationArmed else { return }
|
|
|
|
|
guard animateEntry else {
|
|
|
|
|
didEnter = true
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entryAnimationArmed = true
|
2026-06-12 00:26:21 -07:00
|
|
|
if reduceMotion {
|
|
|
|
|
didEnter = true
|
|
|
|
|
} else {
|
|
|
|
|
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
|
|
|
|
didEnter = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
private struct ToolCallActivityChip: View {
|
2026-06-05 22:20:56 -07:00
|
|
|
enum VisualState {
|
|
|
|
|
case initiated
|
|
|
|
|
case completed
|
|
|
|
|
case failed
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
var metadata: ToolCallMetadata
|
|
|
|
|
var fallbackContent: String
|
2026-05-02 16:23:00 -07:00
|
|
|
var createdAt: Date
|
2026-06-12 00:26:21 -07:00
|
|
|
var compactLayout: Bool = false
|
2026-03-02 16:18:10 -08:00
|
|
|
|
|
|
|
|
private var summary: String {
|
|
|
|
|
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
|
|
|
|
|
return text
|
|
|
|
|
}
|
2026-05-02 16:23:00 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-03-02 16:18:10 -08:00
|
|
|
if !fallbackContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
|
|
|
return fallbackContent
|
|
|
|
|
}
|
|
|
|
|
return "Ran tool '\(metadata.toolName ?? "unknown_tool")'."
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
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: " ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
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 {
|
2026-06-05 22:20:56 -07:00
|
|
|
visualState == .failed
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var visualState: VisualState {
|
|
|
|
|
switch (metadata.status ?? "").lowercased() {
|
|
|
|
|
case "failed":
|
|
|
|
|
return .failed
|
|
|
|
|
case "initiated":
|
|
|
|
|
return .initiated
|
|
|
|
|
default:
|
|
|
|
|
return .completed
|
|
|
|
|
}
|
2026-03-02 16:18:10 -08:00
|
|
|
}
|
|
|
|
|
|
2026-05-02 16:23:00 -07:00
|
|
|
private var detailLabel: String {
|
2026-06-05 22:20:56 -07:00
|
|
|
var pieces: [String] = [stateLabel]
|
2026-05-02 16:23:00 -07:00
|
|
|
if let durationMs = metadata.durationMs, durationMs > 0 {
|
|
|
|
|
pieces.append("\(durationMs) ms")
|
|
|
|
|
}
|
|
|
|
|
pieces.append(createdAt.sybilShortTimeLabel)
|
|
|
|
|
return pieces.joined(separator: " • ")
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 16:18:10 -08:00
|
|
|
var body: some View {
|
2026-05-02 16:23:00 -07:00
|
|
|
HStack(alignment: .top, spacing: 11) {
|
|
|
|
|
ZStack {
|
|
|
|
|
RoundedRectangle(cornerRadius: 9)
|
2026-06-05 22:20:56 -07:00
|
|
|
.fill(iconColor.opacity(0.13))
|
2026-05-02 16:23:00 -07:00
|
|
|
.overlay(
|
|
|
|
|
RoundedRectangle(cornerRadius: 9)
|
2026-06-05 22:20:56 -07:00
|
|
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
2026-05-02 16:23:00 -07:00
|
|
|
)
|
|
|
|
|
Image(systemName: iconName)
|
|
|
|
|
.font(.system(size: 14, weight: .semibold))
|
2026-06-05 22:20:56 -07:00
|
|
|
.foregroundStyle(iconColor)
|
2026-05-02 16:23:00 -07:00
|
|
|
}
|
|
|
|
|
.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))
|
2026-05-02 16:32:22 -07:00
|
|
|
.lineSpacing(3)
|
2026-06-12 00:26:21 -07:00
|
|
|
.lineLimit(compactLayout ? 1 : nil)
|
|
|
|
|
.truncationMode(.tail)
|
|
|
|
|
.fixedSize(horizontal: false, vertical: !compactLayout)
|
2026-05-02 16:23:00 -07:00
|
|
|
|
|
|
|
|
HStack(spacing: 6) {
|
|
|
|
|
Text(toolLabel)
|
|
|
|
|
.font(.sybil(.caption2, weight: .semibold))
|
2026-06-05 22:20:56 -07:00
|
|
|
.foregroundStyle(iconColor.opacity(0.90))
|
2026-05-02 16:23:00 -07:00
|
|
|
.lineLimit(1)
|
|
|
|
|
|
|
|
|
|
Text(detailLabel)
|
|
|
|
|
.font(.sybil(.caption2))
|
|
|
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-05-03 23:26:58 -07:00
|
|
|
.textSelection(.enabled)
|
2026-05-02 16:23:00 -07:00
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.padding(.vertical, 10)
|
2026-03-02 16:18:10 -08:00
|
|
|
.background(
|
2026-05-02 16:23:00 -07:00
|
|
|
RoundedRectangle(cornerRadius: 12)
|
2026-06-05 22:20:56 -07:00
|
|
|
.fill(backgroundGradient)
|
2026-03-02 16:18:10 -08:00
|
|
|
.overlay(
|
2026-05-02 16:23:00 -07:00
|
|
|
RoundedRectangle(cornerRadius: 12)
|
2026-06-05 22:20:56 -07:00
|
|
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
2026-03-02 16:18:10 -08:00
|
|
|
)
|
|
|
|
|
)
|
2026-05-02 16:23:00 -07:00
|
|
|
.frame(maxWidth: 520, alignment: .leading)
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|
2026-06-05 22:20:56 -07:00
|
|
|
|
|
|
|
|
private var stateLabel: String {
|
|
|
|
|
switch visualState {
|
|
|
|
|
case .failed:
|
|
|
|
|
return "Failed"
|
|
|
|
|
case .initiated:
|
|
|
|
|
return "Running"
|
|
|
|
|
case .completed:
|
|
|
|
|
return "Completed"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var iconColor: Color {
|
|
|
|
|
switch visualState {
|
|
|
|
|
case .failed:
|
|
|
|
|
return SybilTheme.danger
|
|
|
|
|
case .initiated:
|
|
|
|
|
return SybilTheme.warning
|
|
|
|
|
case .completed:
|
|
|
|
|
return SybilTheme.accent
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var backgroundGradient: LinearGradient {
|
|
|
|
|
switch visualState {
|
|
|
|
|
case .failed:
|
|
|
|
|
return SybilTheme.failedToolCallGradient
|
|
|
|
|
case .initiated:
|
|
|
|
|
return SybilTheme.runningToolCallGradient
|
|
|
|
|
case .completed:
|
|
|
|
|
return SybilTheme.toolCallGradient
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 00:09:02 -08:00
|
|
|
}
|