330 lines
14 KiB
Swift
330 lines
14 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: 15) {
|
|
// 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")
|
|
.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()
|
|
.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)
|
|
.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)
|
|
.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. Animated Latest
|
|
// navigation and native keyboard resizing must not cancel it.
|
|
if isScrolling && scrollPhase != .animating {
|
|
atLatest = atBottom
|
|
}
|
|
}
|
|
.overlay(alignment: .bottomTrailing) {
|
|
if !atLatest {
|
|
Button {
|
|
atLatest = true
|
|
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .top) }
|
|
} 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: 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AgentMessageRow: View {
|
|
var message: Message
|
|
var room: SybilAgentRoom
|
|
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") { 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep the row's measured height stable for lazy layout. Animate its appearance
|
|
// without collapsing the scroll target while the composer or keyboard resizes.
|
|
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
|
|
content
|
|
.opacity(preparing ? 0 : 1)
|
|
.offset(y: preparing ? 16 : 0)
|
|
.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
|
|
}
|