Compare commits

..
Author SHA1 Message Date
buzzert 15efbcc205 ios: experiment with inverted Agent transcript layout
TestFlight / Build and upload (push) Successful in 1m58s
2026-10-16 14:27:01 -07:00
buzzert ff48a25186 ios: simplify Agent transcript positioning and entrances
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-16 13:58:04 -07:00
buzzert e681e9e05c ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:37:04 -07:00
2 changed files with 161 additions and 142 deletions
@@ -18,26 +18,20 @@ struct SybilAgentView: View {
} }
// Draft edits and keyboard focus belong to the composer. The transcript observes // Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; resizing is handled by its scroll geometry. // only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View { private struct AgentTranscriptView: View {
var room: SybilAgentRoom var room: SybilAgentRoom
var topInset: CGFloat var topInset: CGFloat
var latestRequest: Int var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var atLatest = true @State private var atLatest = true
@State private var isScrolling = false @State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100 @State private var historyLimit = 100
@State private var historyStartID: String? @State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition() private static let bottomID = "agent-bottom"
@State private var scrollTracking = AgentScrollTracking() @State private var scrollTracking = AgentScrollTracking()
@State private var appeared = false @State private var appeared = false
private let bottomID = "agent-bottom" private var isScrolling: Bool { scrollPhase.isScrolling }
private var visibleReadingAnchor: AgentReadingAnchor? {
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 { var body: some View {
#if DEBUG #if DEBUG
@@ -51,26 +45,9 @@ private struct AgentTranscriptView: View {
ScrollViewReader { proxy in ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVStack(alignment: .leading, spacing: 15) { LazyVStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count { // The native scroll origin is the visual bottom. Keep model
Button("Load earlier messages") { // and cursor order ascending; reverse only presentation.
// Loading older history may move the viewport as lazy Color.clear.frame(height: 17).id(Self.bottomID)
// row estimates settle; do not chase the old position.
scrollTracking.rowFrames = [:]
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, tracksReadingAnchor: !atLatest,
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 { if rendered.isTyping {
HStack(spacing: 9) { HStack(spacing: 9) {
AgentAvatar() AgentAvatar()
@@ -79,72 +56,65 @@ private struct AgentTranscriptView: View {
} }
.foregroundStyle(SybilTheme.textMuted) .foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing") .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)
} }
Color.clear.frame(height: 1).id(bottomID)
} }
// Keep native lazy-row targets without binding scroll position.
.scrollTargetLayout() .scrollTargetLayout()
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.bottom, 16)
// Match normal chat: content clears the header, while the // Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade. // viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset) .padding(.bottom, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth) .frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.scaleEffect(x: 1, y: -1)
.accessibilityIdentifier("agent-transcript") .accessibilityIdentifier("agent-transcript")
.coordinateSpace(name: "agent-scroll")
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .initialOffset)
.defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in .onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling scrollPhase = phase
} }
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in .onScrollGeometryChange(for: Bool.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y, geometry.containerSize.height > 0 &&
height: geometry.containerSize.height, geometry.contentOffset.y + geometry.contentInsets.top <= 24
contentHeight: geometry.contentSize.height, } action: { _, atBottom in
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in
#if DEBUG #if DEBUG
AgentRenderDiagnostics.record("scrollGeometry") AgentRenderDiagnostics.record("scrollGeometry")
#endif #endif
scrollTracking.metrics = next scrollTracking.atBottom = atBottom
if scrollTracking.atBottom != next.atBottom { scrollTracking.atBottom = next.atBottom } // Only user scrolling changes follow intent. Animated Latest
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom } // navigation and native keyboard resizing must not cancel it.
if atLatest, !isScrolling, if isScrolling && scrollPhase != .animating {
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset { atLatest = atBottom
proxy.scrollTo(bottomID, anchor: .bottom)
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
#if DEBUG
AgentRenderDiagnostics.record("rowPreferences")
#endif
let anchor = !atLatest && !isScrolling ? visibleReadingAnchor : nil
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Preserve ordinary reading position after new messages or
// text reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
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 + scrollTracking.metrics.topInset)
}
} else {
scrollTracking.rowFrames = frames
} }
} }
.overlay(alignment: .bottomTrailing) { .overlay(alignment: .bottomTrailing) {
if !atLatest { if !atLatest {
Button { Button {
atLatest = true atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) } withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .top) }
} label: { } label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down") Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold)) .font(.sybil(.caption, weight: .semibold))
@@ -158,21 +128,24 @@ private struct AgentTranscriptView: View {
} }
.onAppear { .onAppear {
appeared = true appeared = true
if !room.isLoading { historyStartID = displayed.first?.id } if !room.isLoading {
historyStartID = displayed.first?.id
}
} }
.onChange(of: room.isLoading) { _, loading in .onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil { historyStartID = displayed.first?.id } if !loading && historyStartID == nil {
} historyStartID = displayed.first?.id
.onChange(of: allMessages.count) { _, _ in }
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
} }
.onChange(of: latestRequest) { _, _ in .onChange(of: latestRequest) { _, _ in
atLatest = true if !atLatest {
proxy.scrollTo(bottomID, anchor: .bottom) atLatest = true
proxy.scrollTo(Self.bottomID, anchor: .top)
}
} }
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
} }
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
} }
} }
@@ -197,9 +170,9 @@ private struct AgentReadAcknowledgement: ViewModifier {
.task(id: readKey) { .task(id: readKey) {
// Capture the rendered cursor before scheduling. New network input // Capture the rendered cursor before scheduling. New network input
// cancels this task and must settle in layout before acknowledgement. // cancels this task and must settle in layout before acknowledgement.
guard atLatest, tracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return } 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)) try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, tracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return } guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor) room.markRead(renderedCursor: cursor)
} }
} }
@@ -274,16 +247,14 @@ struct AgentAvatar: View {
} }
// Activity/read publications still update the transcript. Completed rows depend // Activity/read publications still update the transcript. Completed rows depend
// only on message values, heading and whether a reading anchor is needed. // only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable { private struct AgentTranscriptRow: View, Equatable {
var message: Message var message: Message
var room: SybilAgentRoom var room: SybilAgentRoom
var tracksReadingAnchor: Bool
var startsDay: Bool var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool { nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
&& lhs.tracksReadingAnchor == rhs.tracksReadingAnchor
} }
var body: some View { var body: some View {
@@ -299,15 +270,6 @@ private struct AgentTranscriptRow: View, Equatable {
.padding(.vertical, 6) .padding(.vertical, 6)
} }
AgentMessageRow(message: message, room: room) AgentMessageRow(message: message, room: room)
.background {
// Pinned-bottom layout needs only the scroll view's metrics.
// Rows report frames only while preserving a reading anchor.
if tracksReadingAnchor {
GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
}
}
}
} }
} }
} }
@@ -344,63 +306,24 @@ private struct AgentMessageRow: View {
} }
} }
// Animate both occupied height and content, so incoming rows push older history up. // Keep the row's measured height stable for lazy layout. Animate its appearance
private struct AgentGrowingLayout: Layout { // without collapsing the scroll target while the composer or keyboard resizes.
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 { private struct AgentEntrance: ViewModifier {
var animate: Bool var animate: Bool
var reduceMotion: Bool var reduceMotion: Bool
@State private var entered = false @State private var entered = false
func body(content: Content) -> some View { func body(content: Content) -> some View {
let preparing = animate && !entered && !reduceMotion let preparing = animate && !entered && !reduceMotion
AgentGrowingLayout(progress: preparing ? 0 : 1) { content
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0) .opacity(preparing ? 0 : 1)
} .offset(y: preparing ? 16 : 0)
.clipped()
.onAppear { .onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true } withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
} }
} }
} }
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping // Only the bottom boundary invalidates read acknowledgement, not the transcript.
// it outside observation avoids rebuilding the entire transcript on every scroll
// frame. Only the at-bottom boundary invalidates the read acknowledgement.
@Observable private final class AgentScrollTracking { @Observable private final class AgentScrollTracking {
var atBottom = false var atBottom = false
@ObservationIgnored var metrics = AgentScrollMetrics()
@ObservationIgnored var rowFrames: [String: CGRect] = [:]
}
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
}
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 })
}
} }
@@ -55,6 +55,79 @@ final class TranscriptFadeUITests: XCTestCase {
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
} }
private func reloadMixedHistory() async throws {
app.terminate()
let rows = (1...160).map { index in
let short = "History message \(index): local simulator fixture."
return ["role": index % 3 == 0 ? "user" : "assistant",
"content": index == 160 || index % 3 == 0 ? short :
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
}
try await control(["action": "presentation-samples", "messages": rows])
app.launch()
}
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
try await reloadMixedHistory()
openAgent()
let latest = app.staticTexts["History message 160: local simulator fixture."]
XCTAssertTrue(latest.isHittable)
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-initial-open")
showSidebar()
app.buttons["Settings"].tap()
XCTAssertFalse(composer.exists)
openAgent()
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-tab-return")
}
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
try await reloadMixedHistory()
openAgent()
composer.tap()
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
}
let draft = "Keyboard send\nsecond line\nthird line"
composer.typeText(draft)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let multilineHeight = composer.frame.height
app.buttons["agent-send"].tap()
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
await fulfillment(of: [cleared], timeout: 5)
XCTAssertLessThan(composer.frame.height, multilineHeight)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let sent = app.staticTexts[draft]
XCTAssertTrue(sent.waitForExistence(timeout: 5))
XCTAssertTrue(sent.isHittable)
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
await fulfillment(of: [followed], timeout: 10)
capture("agent-send-cleared-composer-with-keyboard")
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
// Finished: is only the first Markdown paragraph. Measure the final
// reply content, leaving the same allowance for metadata and spacing.
let replyEnd = app.staticTexts["The local fixture returned three matches."]
XCTAssertTrue(replyEnd.isHittable)
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
print("Agent send settled final-content gap: \(bottomGap)pt")
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
func testSharedHeaderFadeGeometry() async throws { func testSharedHeaderFadeGeometry() async throws {
showSidebar() showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
@@ -123,6 +196,7 @@ final class TranscriptFadeUITests: XCTestCase {
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest) let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5) await fulfillment(of: [followed], timeout: 5)
composer.tap() composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(latest.isHittable) XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable) XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard") capture("agent-fade-latest-with-keyboard")
@@ -133,5 +207,27 @@ final class TranscriptFadeUITests: XCTestCase {
try await Task.sleep(for: .milliseconds(500)) try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"]) let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int) XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
// A reply still follows and settles as read while the keyboard is up.
try await control(["action": "reply", "content": "Reply with keyboard visible"])
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
await fulfillment(of: [keyboardFollowed], timeout: 5)
try await Task.sleep(for: .milliseconds(500))
let keyboardRead = try await control(["action": "state"])
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
// Interactive dismissal is user navigation; Latest must still return to
// the bottom, and presenting the keyboard again must keep it visible.
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
await fulfillment(of: [dismissed], timeout: 5)
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
XCTAssertTrue(keyboardReply.isHittable)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(keyboardReply.isHittable)
} }
} }