Files
Sybil-2/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift
T
Sybil Agent a390c7287a
TestFlight / Build and upload (push) Successful in 1m50s
Show inline Agent pending dots
2026-10-29 21:31:11 -07:00

403 lines
17 KiB
Swift

import SwiftUI
import Observation
struct SybilAgentView: View {
var room: SybilAgentRoom
var topInset: CGFloat = 0
@State private var latestRequest = 0
var body: some View {
VStack(spacing: 0) {
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
AgentStatusBar(room: room)
AgentComposer(room: room) { latestRequest += 1 }
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View {
var room: SybilAgentRoom
var topInset: CGFloat
var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var atLatest = true
@State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100
@State private var historyStartID: String?
private static let bottomID = "agent-bottom"
@State private var scrollTracking = AgentScrollTracking()
@State private var appeared = false
private var isScrolling: Bool { scrollPhase.isScrolling }
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("agentBody")
#endif
let rendered = room.snapshot
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
let allMessages = transcript.messages
let startIndex = transcript.startIndex
let displayed = transcript.displayed
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
// The native scroll origin is the visual bottom. Keep model
// and cursor order ascending; reverse only presentation.
Color.clear.frame(height: 17).id(Self.bottomID)
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room,
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
.equatable()
// Include the gap in the occupied-space entrance.
.padding(.bottom, 15)
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.scaleEffect(x: 1, y: -1)
.id(message.id)
}
if room.isLoading {
ProgressView("Loading messages…").frame(maxWidth: .infinity)
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
if allMessages.count > displayed.count {
Button("Load earlier messages") {
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
}
// Keep native lazy-row targets without binding scroll position.
.scrollTargetLayout()
.padding(.horizontal, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.bottom, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.scaleEffect(x: 1, y: -1)
.accessibilityIdentifier("agent-transcript")
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
scrollPhase = phase
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.containerSize.height > 0 &&
geometry.contentOffset.y + geometry.contentInsets.top <= 24
} action: { _, atBottom in
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.atBottom = atBottom
// Only user scrolling changes follow intent. Send navigation
// and native keyboard resizing must not cancel it.
if isScrolling && scrollPhase != .animating {
atLatest = atBottom
}
}
.onAppear {
appeared = true
if !room.isLoading {
historyStartID = displayed.first?.id
}
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil {
historyStartID = displayed.first?.id
}
}
.onChange(of: latestRequest) { _, _ in
if !atLatest {
atLatest = true
proxy.scrollTo(Self.bottomID, anchor: .top)
}
}
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
}
}
}
// Bottom proximity can change twice per entrance/keyboard frame while the
// scroll view follows the growing content. Only the read-settling task needs
// those transitions; they must not project and rebuild the transcript hierarchy.
private struct AgentReadAcknowledgement: ViewModifier {
var room: SybilAgentRoom
var tracking: AgentScrollTracking
var atLatest: Bool
var isScrolling: Bool
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
func body(content: Content) -> some View {
let cursor = room.snapshot.room.cursor
content
.task(id: readKey) {
// Capture the rendered cursor before scheduling. New network input
// cancels this task and must settle in layout before acknowledgement.
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
}
private struct AgentStatusBar: View {
var room: SybilAgentRoom
@ViewBuilder var body: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
Spacer(minLength: 4)
Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold))
}.padding(.horizontal, 16).padding(.vertical, 8)
} else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil {
HStack(spacing: 10) {
if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") }
if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") }
if let wait = activity.wait {
Text(wait.reason ?? "Waiting")
Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time)
}
Spacer(minLength: 0)
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
.padding(.horizontal, 16).padding(.vertical, 6)
}
}
}
private struct AgentComposer: View {
@Bindable var room: SybilAgentRoom
var onSubmit: () -> Void
@FocusState private var composerFocused: Bool
private var canSendComposer: Bool {
room.attachmentDraft.canSend
}
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return }
onSubmit()
}
}
struct AgentAvatar: View {
var body: some View {
Image("character-still", bundle: .module).resizable().scaledToFill()
.frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3)))
.accessibilityHidden(true)
}
}
struct AgentTypingDots: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
TimelineView(.animation(minimumInterval: 1 / 30, paused: reduceMotion)) { timeline in
HStack(spacing: 3) {
ForEach(0..<3, id: \.self) { index in
let pulse = pulse(at: timeline.date, index: index)
Circle()
.fill(Color(red: 0.80, green: 0.70, blue: 0.91))
.frame(width: 3, height: 3)
.opacity(reduceMotion ? 0.7 : pulse.opacity)
.offset(y: reduceMotion ? 0 : pulse.rise)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
Color(red: 0.12, green: 0.075, blue: 0.21).opacity(0.42),
in: RoundedRectangle(cornerRadius: 9)
)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Sybil is thinking")
}
private func pulse(at date: Date, index: Int) -> (opacity: Double, rise: CGFloat) {
let cycle = 1.4
let delay = Double(index) * 0.16 / cycle
let elapsed = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: cycle) / cycle
let phase = (elapsed - delay + 1).truncatingRemainder(dividingBy: 1)
let intensity: Double
if phase <= 0.35 {
let progress = phase / 0.35
let eased = progress * progress * (3 - 2 * progress)
intensity = 0.35 + 0.65 * eased
} else if phase <= 0.70 {
let progress = (phase - 0.35) / 0.35
let eased = progress * progress * (3 - 2 * progress)
intensity = 1 - 0.65 * eased
} else {
intensity = 0.35
}
return (intensity, -2 * CGFloat((intensity - 0.35) / 0.65))
}
}
// Activity/read publications still update the transcript. Completed rows depend
// only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable {
var message: Message
var room: SybilAgentRoom
var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
}
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("transcriptRow")
#endif
VStack(spacing: 14) {
if startsDay {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room)
}
}
}
struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom? = nil
var isSending = false
var streamingAssistantPresentation: StreamingAssistantPresentation? = nil
var isForking = false
var onFork: (() -> Void)? = nil
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if !own { AgentAvatar().padding(.bottom, 23) }
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
if let room {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
}
}
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || room == nil || message.agentAttachments.isEmpty {
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: onFork,
agentStyle: true
)
}
HStack(spacing: 6) {
Text(own ? "You" : "Sybil")
Text(message.createdAt, style: .time)
if own && status == "sending" { Text("Sending…") }
if own && status == "queued" { Label("Queued", systemImage: "clock") }
if own && status == "failed" {
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
if let room {
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
}
}
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
}
}
}
}
}
// Grow the occupied space with the bubble's entrance so neighboring rows move
// smoothly. Measure at natural height; no geometry state or scroll correction.
private struct AgentEntranceLayout: Layout {
var progress: CGFloat
var animatableData: CGFloat {
get { progress }
set { progress = newValue }
}
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
let size = subviews.first?.sizeThatFits(.init(width: proposal.width, height: nil)) ?? .zero
return CGSize(width: size.width, height: size.height * progress)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(at: bounds.origin, proposal: .init(width: bounds.width, height: nil))
}
}
private struct AgentEntrance: ViewModifier {
var animate: Bool
var reduceMotion: Bool
@State private var entered = false
func body(content: Content) -> some View {
let preparing = animate && !entered && !reduceMotion
AgentEntranceLayout(progress: preparing ? 0 : 1) {
content
.opacity(preparing ? 0 : 1)
.offset(y: preparing ? 16 : 0)
}
.clipped()
.onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
}
}
}
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
@Observable private final class AgentScrollTracking {
var atBottom = false
}