Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilChatTranscriptView.swift
T
buzzert 4389f805f4
TestFlight / Build and upload (push) Successful in 5m29s
ios: add persistent Sybil Agent room
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.
2026-10-14 13:34:37 -07:00

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
}
}
}