324 lines
16 KiB
Swift
324 lines
16 KiB
Swift
import SwiftUI
|
|
|
|
struct SybilAgentView: View {
|
|
@Bindable var room: SybilAgentRoom
|
|
var topInset: CGFloat = 0
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
@FocusState private var composerFocused: Bool
|
|
@State private var atLatest = true
|
|
@State private var isScrolling = false
|
|
@State private var historyLimit = 100
|
|
@State private var historyStartID: String?
|
|
@State private var scrollPosition = ScrollPosition()
|
|
@State private var scrollMetrics = AgentScrollMetrics()
|
|
@State private var rowFrames: [String: CGRect] = [:]
|
|
@State private var restoreAnchor: AgentReadingAnchor?
|
|
@State private var appeared = false
|
|
private let bottomID = "agent-bottom"
|
|
|
|
private var readKey: String {
|
|
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
|
}
|
|
private var visibleReadingAnchor: AgentReadingAnchor? {
|
|
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
|
|
.min { $0.value.minY < $1.value.minY }
|
|
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
|
}
|
|
|
|
var body: some View {
|
|
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
|
|
VStack(spacing: 0) {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 15) {
|
|
if allMessages.count > displayed.count {
|
|
Button("Load earlier messages") {
|
|
if var anchor = visibleReadingAnchor {
|
|
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
|
restoreAnchor = anchor
|
|
}
|
|
atLatest = false
|
|
historyLimit += 100
|
|
historyStartID = allMessages[max(0, startIndex - 100)].id
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 10)
|
|
}
|
|
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
|
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
|
VStack(spacing: 14) {
|
|
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
|
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, retry: { room.retry(message.id) })
|
|
.background(GeometryReader { geometry in
|
|
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
|
})
|
|
}
|
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
|
.id(message.id)
|
|
}
|
|
if rendered.isTyping {
|
|
HStack(spacing: 9) {
|
|
AgentAvatar()
|
|
ProgressView().controlSize(.small)
|
|
Text("Sybil is thinking…").font(.sybil(.caption))
|
|
}
|
|
.foregroundStyle(SybilTheme.textMuted)
|
|
.accessibilityIdentifier("agent-typing")
|
|
}
|
|
Color.clear.frame(height: 1).id(bottomID)
|
|
}
|
|
.scrollTargetLayout()
|
|
.padding(.horizontal, 16)
|
|
.padding(.bottom, 16)
|
|
// Match normal chat: content clears the header, while the
|
|
// viewport extends beneath the shared navigation fade.
|
|
.padding(.top, 16 + topInset)
|
|
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.coordinateSpace(name: "agent-scroll")
|
|
.scrollPosition($scrollPosition)
|
|
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
|
.defaultScrollAnchor(.bottom, for: .alignment)
|
|
.scrollDismissesKeyboard(.interactively)
|
|
.onScrollPhaseChange { _, phase in
|
|
isScrolling = phase.isScrolling
|
|
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
|
}
|
|
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
|
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
|
height: geometry.containerSize.height,
|
|
contentHeight: geometry.contentSize.height,
|
|
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
|
visibleBottom: geometry.visibleRect.maxY)
|
|
} action: { previous, next in
|
|
scrollMetrics = next
|
|
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
|
|
if atLatest, !isScrolling, restoreAnchor == nil,
|
|
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
|
|
proxy.scrollTo(bottomID, anchor: .bottom)
|
|
}
|
|
}
|
|
.onPreferenceChange(AgentRowFrames.self) { frames in
|
|
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
|
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
|
// Restore the exact pixel offset after insertion or text
|
|
// reflow. Normalize the saved frames to
|
|
// the requested offset so the next layout cannot undo it.
|
|
let requested = scrollMetrics.offset + frame.minY - anchor.offset
|
|
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
|
|
let appliedShift = target - scrollMetrics.offset
|
|
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
|
if abs(appliedShift) > 1 {
|
|
// Geometry reports the raw offset; ScrollPosition uses
|
|
// zero at the top inset, including the safe area.
|
|
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
|
|
}
|
|
restoreAnchor = nil
|
|
} else {
|
|
rowFrames = frames
|
|
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
|
}
|
|
}
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if !atLatest {
|
|
Button {
|
|
atLatest = true
|
|
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
} label: {
|
|
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
|
.font(.sybil(.caption, weight: .semibold))
|
|
.padding(12)
|
|
.background(SybilTheme.surfaceStrong, in: Capsule())
|
|
.overlay(Capsule().stroke(SybilTheme.border))
|
|
}
|
|
.padding(14)
|
|
.accessibilityIdentifier("agent-jump-latest")
|
|
}
|
|
}
|
|
.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: allMessages.count) { _, _ in
|
|
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
}
|
|
.onChange(of: composerFocused) { _, _ in
|
|
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
}
|
|
}
|
|
statusBar
|
|
composer
|
|
}
|
|
.onAppear { room.isSelected = true }
|
|
.onDisappear { room.isSelected = false }
|
|
.task(id: readKey) {
|
|
// This cursor belongs to the rendered snapshot. New network input cancels
|
|
// the task, and cannot be acknowledged while still entering the layout.
|
|
let cursor = rendered.room.cursor
|
|
guard atLatest, scrollMetrics.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, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
|
room.markRead(renderedCursor: cursor)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder private var statusBar: 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 var canSendComposer: Bool {
|
|
room.attachmentDraft.canSend
|
|
}
|
|
|
|
private var composer: 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 }
|
|
atLatest = true
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
private struct AgentMessageRow: View {
|
|
var message: Message
|
|
var room: SybilAgentRoom
|
|
var retry: () -> Void
|
|
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) {
|
|
ForEach(message.agentAttachments) { attachment in
|
|
AgentAttachmentCard(attachment: attachment, room: room)
|
|
}
|
|
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, 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)
|
|
Button("Retry", action: retry).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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Animate both occupied height and content, so incoming rows push older history up.
|
|
private struct AgentGrowingLayout: 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(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
|
return CGSize(width: proposal.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: ProposedViewSize(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
|
|
AgentGrowingLayout(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 }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AgentScrollMetrics: Equatable {
|
|
var offset: CGFloat = 0
|
|
var height: CGFloat = 0
|
|
var contentHeight: CGFloat = 0
|
|
var bottomInset: CGFloat = 0
|
|
var topInset: CGFloat = 0
|
|
var visibleBottom: CGFloat = 0
|
|
// visibleRect includes the viewport extended into the safe area, as well as
|
|
// the alignment inset used when a short transcript is pinned to the bottom.
|
|
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
|
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
|
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
|
}
|
|
private struct AgentReadingAnchor {
|
|
var id: String
|
|
var offset: CGFloat
|
|
var expectedCount: Int? = nil
|
|
}
|
|
private struct AgentRowFrames: PreferenceKey {
|
|
static let defaultValue: [String: CGRect] = [:]
|
|
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
|
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
|
}
|
|
}
|