From 15efbcc2052e559ab42c9c23b12936a4fade50c2 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 16 Oct 2026 14:27:01 -0700 Subject: [PATCH] ios: experiment with inverted Agent transcript layout --- .../Sybil/Sources/Sybil/SybilAgentView.swift | 67 +++++++++---------- 1 file changed, 31 insertions(+), 36 deletions(-) diff --git a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift index e0b81f3..2c69312 100644 --- a/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift +++ b/ios/Packages/Sybil/Sources/Sybil/SybilAgentView.swift @@ -45,25 +45,9 @@ private struct AgentTranscriptView: View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 15) { - if allMessages.count > displayed.count { - Button("Load earlier messages") { - // Loading older history may move the viewport as lazy - // row estimates settle; do not chase the old position. - 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 - 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) - } + // 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() @@ -72,33 +56,49 @@ private struct AgentTranscriptView: View { } .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) } - // Include bottom padding in the target so Latest reaches - // the actual content end before native keyboard avoidance. - Color.clear.frame(height: 17).id(Self.bottomID) } // 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(.top, 16 + topInset) + .padding(.bottom, 16 + topInset) .frame(maxWidth: SybilLayout.webContentMaxWidth) .frame(maxWidth: .infinity) } + .scaleEffect(x: 1, y: -1) .accessibilityIdentifier("agent-transcript") - .defaultScrollAnchor(.bottom, for: .initialOffset) - // Let SwiftUI follow size changes, including keyboard avoidance, - // without competing scrollTo calls from geometry updates. - .defaultScrollAnchor(atLatest ? .bottom : nil, for: .sizeChanges) - .defaultScrollAnchor(.bottom, for: .alignment) .scrollDismissesKeyboard(.interactively) .onScrollPhaseChange { _, phase in scrollPhase = phase } .onScrollGeometryChange(for: Bool.self) { geometry in geometry.containerSize.height > 0 && - geometry.contentSize.height + geometry.contentInsets.bottom - geometry.visibleRect.maxY <= 24 + geometry.contentOffset.y + geometry.contentInsets.top <= 24 } action: { _, atBottom in #if DEBUG AgentRenderDiagnostics.record("scrollGeometry") @@ -114,7 +114,7 @@ private struct AgentTranscriptView: View { if !atLatest { Button { atLatest = true - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .bottom) } + 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)) @@ -130,22 +130,17 @@ private struct AgentTranscriptView: View { appeared = true if !room.isLoading { historyStartID = displayed.first?.id - proxy.scrollTo(Self.bottomID, anchor: .bottom) } } .onChange(of: room.isLoading) { _, loading in if !loading && historyStartID == nil { historyStartID = displayed.first?.id - proxy.scrollTo(Self.bottomID, anchor: .bottom) } } - .onChange(of: allMessages.count) { _, _ in - if atLatest { proxy.scrollTo(Self.bottomID, anchor: .bottom) } - } .onChange(of: latestRequest) { _, _ in if !atLatest { atLatest = true - proxy.scrollTo(Self.bottomID, anchor: .bottom) + proxy.scrollTo(Self.bottomID, anchor: .top) } } .modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,