|
|
|
@@ -11,22 +11,25 @@ struct SybilAgentView: View {
|
|
|
|
|
@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 scrollTracking = AgentScrollTracking()
|
|
|
|
|
@State private var atBottom = false
|
|
|
|
|
@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)"
|
|
|
|
|
"\(room.snapshot.room.cursor):\(atLatest):\(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 }
|
|
|
|
|
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
|
|
|
|
|
.min { $0.value.minY < $1.value.minY }
|
|
|
|
|
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
@@ -51,21 +54,11 @@ struct SybilAgentView: View {
|
|
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
AgentTranscriptRow(message: message, room: room,
|
|
|
|
|
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
|
|
|
|
.equatable()
|
|
|
|
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
|
|
|
|
.id(message.id)
|
|
|
|
|
}
|
|
|
|
|
if rendered.isTyping {
|
|
|
|
|
HStack(spacing: 9) {
|
|
|
|
@@ -103,7 +96,11 @@ struct SybilAgentView: View {
|
|
|
|
|
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
|
|
|
|
visibleBottom: geometry.visibleRect.maxY)
|
|
|
|
|
} action: { previous, next in
|
|
|
|
|
scrollMetrics = next
|
|
|
|
|
#if DEBUG
|
|
|
|
|
AgentRenderDiagnostics.record("scrollGeometry")
|
|
|
|
|
#endif
|
|
|
|
|
scrollTracking.metrics = next
|
|
|
|
|
if atBottom != next.atBottom { atBottom = next.atBottom }
|
|
|
|
|
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 {
|
|
|
|
@@ -111,23 +108,26 @@ struct SybilAgentView: View {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.onPreferenceChange(AgentRowFrames.self) { frames in
|
|
|
|
|
#if DEBUG
|
|
|
|
|
AgentRenderDiagnostics.record("rowPreferences")
|
|
|
|
|
#endif
|
|
|
|
|
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) }
|
|
|
|
|
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
|
|
|
|
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
|
|
|
|
let appliedShift = target - scrollTracking.metrics.offset
|
|
|
|
|
scrollTracking.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)
|
|
|
|
|
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
|
|
|
|
}
|
|
|
|
|
restoreAnchor = nil
|
|
|
|
|
} else {
|
|
|
|
|
rowFrames = frames
|
|
|
|
|
scrollTracking.rowFrames = frames
|
|
|
|
|
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -170,9 +170,9 @@ struct SybilAgentView: View {
|
|
|
|
|
// 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 }
|
|
|
|
|
guard atLatest, scrollTracking.metrics.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 }
|
|
|
|
|
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
|
|
|
|
room.markRead(renderedCursor: cursor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -236,10 +236,41 @@ struct AgentAvatar: View {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Activity/read publications still update the surrounding view. Completed rows
|
|
|
|
|
// depend only on their message values and date heading, so progress changes
|
|
|
|
|
// must not rebuild the eager history (whose exact heights preserve anchors).
|
|
|
|
|
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)
|
|
|
|
|
.background(GeometryReader { geometry in
|
|
|
|
|
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
@@ -257,7 +288,7 @@ private struct AgentMessageRow: View {
|
|
|
|
|
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")
|
|
|
|
|
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
|
|
|
@@ -297,6 +328,14 @@ private struct AgentEntrance: ViewModifier {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
|
|
|
|
// it outside observation avoids rebuilding the entire transcript on every scroll
|
|
|
|
|
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
|
|
|
|
|
private final class AgentScrollTracking {
|
|
|
|
|
var metrics = AgentScrollMetrics()
|
|
|
|
|
var rowFrames: [String: CGRect] = [:]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct AgentScrollMetrics: Equatable {
|
|
|
|
|
var offset: CGFloat = 0
|
|
|
|
|
var height: CGFloat = 0
|
|
|
|
|