403 lines
17 KiB
Swift
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
|
|
}
|