TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details. Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
775 lines
27 KiB
Swift
775 lines
27 KiB
Swift
import MarkdownUI
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
struct SybilChatTranscriptView: View {
|
|
var messages: [Message]
|
|
var isLoading: Bool
|
|
var isSending: Bool
|
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
|
var topContentInset: CGFloat = 0
|
|
var bottomContentInset: CGFloat = 0
|
|
var bottomPinRequestID: Int = 0
|
|
var isForking: Bool = false
|
|
var onForkResponse: ((String) -> Void)? = nil
|
|
|
|
@State private var hasTrackedToolCallMessages = false
|
|
@State private var knownToolCallMessageIDs: Set<String> = []
|
|
|
|
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
|
|
private var renderItems: [TranscriptRenderItem] {
|
|
buildTranscriptRenderItems(from: messages)
|
|
}
|
|
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: "|")
|
|
}
|
|
|
|
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(renderItems) { item in
|
|
switch item {
|
|
case let .message(message):
|
|
MessageBubble(
|
|
message: message,
|
|
isSending: isSending,
|
|
streamingAssistantPresentation: streamingAssistantPresentation,
|
|
isForking: isForking,
|
|
onFork: {
|
|
onForkResponse?(message.id)
|
|
}
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
case let .toolGroup(id, messages):
|
|
ToolCallStackView(
|
|
groupID: id,
|
|
messages: messages,
|
|
entryAnimationIDs: enteringToolCallMessageIDs
|
|
)
|
|
.frame(maxWidth: .infinity)
|
|
.id(id)
|
|
}
|
|
}
|
|
|
|
Color.clear
|
|
.frame(height: 18 + bottomContentInset)
|
|
.id(bottomAnchorID)
|
|
}
|
|
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.horizontal, 14)
|
|
.padding(.top, 18 + topContentInset)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.onAppear {
|
|
syncKnownToolCallMessageIDs()
|
|
}
|
|
.onChange(of: toolCallMessageIDSignature) { _, _ in
|
|
syncKnownToolCallMessageIDs()
|
|
}
|
|
.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()
|
|
}
|
|
}
|
|
|
|
private func syncKnownToolCallMessageIDs() {
|
|
guard !toolCallMessageIDs.isEmpty else { return }
|
|
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
|
|
hasTrackedToolCallMessages = true
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
struct MessageBubble: View {
|
|
var message: Message
|
|
var isSending: Bool
|
|
var streamingAssistantPresentation: StreamingAssistantPresentation?
|
|
var isForking: Bool
|
|
var onFork: (() -> Void)?
|
|
var agentStyle = false
|
|
|
|
private var toolCallMetadata: ToolCallMetadata? {
|
|
message.toolCallMetadata
|
|
}
|
|
|
|
private var isUser: Bool {
|
|
message.role == .user
|
|
}
|
|
|
|
private var isStreamingAssistant: Bool {
|
|
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
|
|
}
|
|
|
|
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
|
|
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
|
|
return streamingAssistantPresentation
|
|
}
|
|
|
|
private var canFork: Bool {
|
|
message.role == .assistant &&
|
|
!message.id.hasPrefix("temp-") &&
|
|
onFork != nil
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if canFork {
|
|
bubbleContent
|
|
.contextMenu {
|
|
Button {
|
|
UIPasteboard.general.string = message.content
|
|
} label: {
|
|
Label("Copy", systemImage: "doc.on.doc")
|
|
}
|
|
|
|
Button {
|
|
onFork?()
|
|
} label: {
|
|
Label("Fork", systemImage: "arrow.triangle.branch")
|
|
}
|
|
.disabled(isForking)
|
|
}
|
|
} else {
|
|
bubbleContent
|
|
}
|
|
}
|
|
}
|
|
|
|
private var bubbleContent: 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 !message.attachments.isEmpty {
|
|
SybilAttachmentListView(
|
|
attachments: message.attachments,
|
|
tone: isUser ? .user : .assistant
|
|
)
|
|
}
|
|
|
|
if isStreamingAssistant {
|
|
StreamingAssistantContentView(
|
|
content: message.content,
|
|
isSending: isSending,
|
|
presentation: matchingStreamingAssistantPresentation
|
|
)
|
|
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
if isUser {
|
|
Text(message.content)
|
|
.font(.sybil(.body))
|
|
} else {
|
|
Markdown(message.content)
|
|
.tint(SybilTheme.primary)
|
|
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
|
.markdownTheme(.sybilReadable)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
|
.padding(.vertical, isUser || agentStyle ? 13 : 2)
|
|
.textSelection(.enabled)
|
|
.background(
|
|
Group {
|
|
if isUser {
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(SybilTheme.userBubbleGradient)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
|
|
)
|
|
} else if agentStyle {
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
|
|
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), 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 StreamingTraceHeightPreferenceKey: PreferenceKey {
|
|
static let defaultValue: CGFloat = 0
|
|
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|
|
|
|
struct StreamingAssistantContentView: View {
|
|
private enum Phase: Hashable {
|
|
case pending
|
|
case trace(Int)
|
|
case answer
|
|
}
|
|
|
|
var content: String
|
|
var isSending: Bool
|
|
var presentation: StreamingAssistantPresentation?
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var retainedTraceHeight: CGFloat = 0
|
|
|
|
private var traceText: String? {
|
|
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!trace.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
return trace
|
|
}
|
|
|
|
private var hasAnswer: Bool {
|
|
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private var phase: Phase {
|
|
if hasAnswer {
|
|
return .answer
|
|
}
|
|
if traceText != nil {
|
|
return .trace(presentation?.traceRevision ?? 0)
|
|
}
|
|
return .pending
|
|
}
|
|
|
|
private var traceTransition: AnyTransition {
|
|
guard !reduceMotion else { return .identity }
|
|
return .asymmetric(
|
|
insertion: .opacity.combined(with: .offset(y: 5)),
|
|
removal: .opacity.combined(with: .offset(y: -5))
|
|
)
|
|
}
|
|
|
|
private var answerTransition: AnyTransition {
|
|
guard !reduceMotion else { return .identity }
|
|
return .opacity.combined(with: .offset(y: 3))
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .topLeading) {
|
|
if let traceText {
|
|
Text(traceText)
|
|
.font(.sybil(size: 15))
|
|
.italic()
|
|
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
|
|
.lineSpacing(5)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.opacity(0.78)
|
|
.textSelection(.disabled)
|
|
.allowsHitTesting(false)
|
|
.accessibilityLabel("Assistant is working")
|
|
.background {
|
|
GeometryReader { geometry in
|
|
Color.clear.preference(
|
|
key: StreamingTraceHeightPreferenceKey.self,
|
|
value: geometry.size.height
|
|
)
|
|
}
|
|
}
|
|
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
|
|
.transition(traceTransition)
|
|
.zIndex(1)
|
|
}
|
|
|
|
if hasAnswer {
|
|
Markdown(content)
|
|
.tint(SybilTheme.primary)
|
|
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
|
.markdownTheme(.sybilReadable)
|
|
.textSelection(.enabled)
|
|
.id("streaming-assistant-answer")
|
|
.transition(answerTransition)
|
|
.zIndex(2)
|
|
}
|
|
|
|
if isSending && !hasAnswer && traceText == nil {
|
|
HStack(spacing: 8) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
.tint(SybilTheme.primary)
|
|
Text("Thinking…")
|
|
.font(.sybil(.footnote))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
}
|
|
.padding(.vertical, 2)
|
|
.transition(reduceMotion ? .identity : .opacity)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
|
|
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
|
|
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
|
|
retainedTraceHeight = measuredHeight
|
|
}
|
|
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
|
|
}
|
|
}
|
|
|
|
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]
|
|
var entryAnimationIDs: Set<String>
|
|
|
|
@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
|
|
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
|
|
|
|
ToolCallStackCard(
|
|
message: message,
|
|
cardHeight: cardHeight,
|
|
compactLayout: true,
|
|
animateEntry: shouldAnimateEntry
|
|
)
|
|
.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
|
|
var animateEntry: Bool
|
|
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@State private var entryAnimationArmed = false
|
|
@State private var didEnter = false
|
|
|
|
private var isPreparingEntry: Bool {
|
|
(animateEntry || entryAnimationArmed) && !didEnter
|
|
}
|
|
|
|
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)
|
|
.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)
|
|
.onAppear {
|
|
guard !didEnter, !entryAnimationArmed else { return }
|
|
guard animateEntry else {
|
|
didEnter = true
|
|
return
|
|
}
|
|
entryAnimationArmed = true
|
|
if reduceMotion {
|
|
didEnter = true
|
|
} else {
|
|
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
|
|
didEnter = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ToolCallActivityChip: View {
|
|
enum VisualState {
|
|
case initiated
|
|
case completed
|
|
case failed
|
|
}
|
|
|
|
var metadata: ToolCallMetadata
|
|
var fallbackContent: String
|
|
var createdAt: Date
|
|
var compactLayout: Bool = false
|
|
|
|
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 {
|
|
visualState == .failed
|
|
}
|
|
|
|
private var visualState: VisualState {
|
|
switch (metadata.status ?? "").lowercased() {
|
|
case "failed":
|
|
return .failed
|
|
case "initiated":
|
|
return .initiated
|
|
default:
|
|
return .completed
|
|
}
|
|
}
|
|
|
|
private var detailLabel: String {
|
|
var pieces: [String] = [stateLabel]
|
|
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(iconColor.opacity(0.13))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 9)
|
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
|
)
|
|
Image(systemName: iconName)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(iconColor)
|
|
}
|
|
.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)
|
|
.lineLimit(compactLayout ? 1 : nil)
|
|
.truncationMode(.tail)
|
|
.fixedSize(horizontal: false, vertical: !compactLayout)
|
|
|
|
HStack(spacing: 6) {
|
|
Text(toolLabel)
|
|
.font(.sybil(.caption2, weight: .semibold))
|
|
.foregroundStyle(iconColor.opacity(0.90))
|
|
.lineLimit(1)
|
|
|
|
Text(detailLabel)
|
|
.font(.sybil(.caption2))
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
.textSelection(.enabled)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.fill(backgroundGradient)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12)
|
|
.stroke(iconColor.opacity(0.34), lineWidth: 1)
|
|
)
|
|
)
|
|
.frame(maxWidth: 520, alignment: .leading)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|