diff --git a/docs/native-chat-scrolling.md b/docs/native-chat-scrolling.md index 7ef4e5f2..54deff86 100644 --- a/docs/native-chat-scrolling.md +++ b/docs/native-chat-scrolling.md @@ -58,6 +58,17 @@ A custom layout retains stable-ID height estimates across snapshots, applies offset adjustments within UIKit layout, and compensates self-sizing changes above the viewport. Snapshot commits are serialized/coalesced. Viewport resize keeps the live bottom pinned only while following the tail. + +The latest-button affordance is separate from tail-following intent. Small +upward drags keep their reading offset without immediately showing the button. +It appears at 80pt from the retained bottom and hides at 24pt, retaining its +previous visibility between those thresholds. Hiding never resumes following; +the actual-bottom tolerance remains 1pt. The collection reports a hysteretic +Boolean rather than publishing per-frame distances. A trimmed live tail, +in-flight latest navigation, or an inspection pause awaiting explicit resume +keeps the action available regardless of proximity. Failed catch-up retains +the retry action. Thresholds do not change history demand or retention. + Visible-rectangle queries binary-search the ordered frame array, then visit only intersecting rows (`O(log n + visible rows)`). Height changes update the affected suffix's geometry, without allocating attributes for offscreen rows. diff --git a/ios/Hapi/Features/Chat/ChatModel.swift b/ios/Hapi/Features/Chat/ChatModel.swift index f7cffb01..03682a02 100644 --- a/ios/Hapi/Features/Chat/ChatModel.swift +++ b/ios/Hapi/Features/Chat/ChatModel.swift @@ -60,6 +60,12 @@ final class ChatModel { private(set) var jumpToLatestToken = 0 private(set) var isJumpingToLatest = false private(set) var hasTrimmedTail = false + private var isAwayFromBottom = false + private var resumeAfterInspection = false + var showsJumpToLatest: Bool { + isJumpingToLatest || hasTrimmedTail + || (!followsTail && (isAwayFromBottom || resumeAfterInspection)) + } let toolInspection = ToolInspectionState() private(set) var visibleSurfaces = Set() var isInspectingContent: Bool { @@ -166,7 +172,10 @@ final class ChatModel { jumpTask?.cancel() jumpTask = nil isJumpingToLatest = false - readingViewportChanged(followsTail: false, needsOlder: false) + // Closing an inspector must leave an explicit resume action even + // when no content has arrived and the reading anchor is at bottom. + resumeAfterInspection = true + readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: isAwayFromBottom) } @discardableResult @@ -274,11 +283,13 @@ final class ChatModel { // MARK: - Actions - func readingViewportChanged(followsTail: Bool, needsOlder: Bool) { + func readingViewportChanged(followsTail: Bool, needsOlder: Bool, isAwayFromBottom: Bool) { let followsTail = isInspectingContent ? false : followsTail let needsOlder = isInspectingContent ? false : needsOlder let changedMode = self.followsTail != followsTail self.followsTail = followsTail + self.isAwayFromBottom = !followsTail && isAwayFromBottom + if followsTail && !hasTrimmedTail { resumeAfterInspection = false } viewportNeedsOlder = needsOlder if changedMode, !isJumpingToLatest, let controller = chat.windowController { if followsTail && hasTrimmedTail { jumpToLatest(); return } @@ -407,6 +418,8 @@ final class ChatModel { await controller.setViewMode(.tail) guard !Task.isCancelled, self.chat === chat else { return } self.followsTail = true + self.isAwayFromBottom = false + self.resumeAfterInspection = false self.jumpToLatestToken += 1 } } diff --git a/ios/Hapi/Features/Chat/ChatTranscriptView.swift b/ios/Hapi/Features/Chat/ChatTranscriptView.swift index 563ed2cc..ffe07d3e 100644 --- a/ios/Hapi/Features/Chat/ChatTranscriptView.swift +++ b/ios/Hapi/Features/Chat/ChatTranscriptView.swift @@ -56,7 +56,10 @@ struct ChatTranscriptView: View { historyControlID: TranscriptRow.historyID, isInspectionPresented: model.isInspectingContent, onViewport: { viewport in - model.readingViewportChanged(followsTail: viewport.followsTail, needsOlder: viewport.needsOlder) + model.readingViewportChanged( + followsTail: viewport.followsTail, needsOlder: viewport.needsOlder, + isAwayFromBottom: viewport.isAwayFromBottom + ) }, onLayout: { version, progress in model.historyLaidOut(version: version, madeProgress: progress) }, spacingBefore: { previous, row in row.spacing(after: previous) } @@ -65,7 +68,7 @@ struct ChatTranscriptView: View { AnyView(rowView(row)) } .overlay(alignment: .bottomTrailing) { - if !model.followsTail || model.hasTrimmedTail || model.isJumpingToLatest { + if model.showsJumpToLatest { Button(action: model.jumpToLatest) { HStack(spacing: 6) { if model.isJumpingToLatest { ProgressView().controlSize(.small) } diff --git a/ios/HapiTests/ChatHistoryPumpTests.swift b/ios/HapiTests/ChatHistoryPumpTests.swift index 74eb7ae2..e57c52e5 100644 --- a/ios/HapiTests/ChatHistoryPumpTests.swift +++ b/ios/HapiTests/ChatHistoryPumpTests.swift @@ -26,7 +26,7 @@ final class ChatHistoryPumpTests: XCTestCase { // Drain both ChatSession's entry sync and the interactor's reconcile. await controller.syncTail(ensureAfterCurrent: true) try await eventually { !model.isSyncingTail } - model.readingViewportChanged(followsTail: false, needsOlder: true) + model.readingViewportChanged(followsTail: false, needsOlder: true, isAwayFromBottom: true) try await eventually { await performer.beforeRequests == 1 } XCTAssertEqual(model.historyPaging.phase, .loading) @@ -63,6 +63,7 @@ final class ChatHistoryPumpTests: XCTestCase { await controller.syncTail(ensureAfterCurrent: true) let initialRevision = await controller.state.tailRevision model.beginContentInspection() + XCTAssertTrue(model.showsJumpToLatest, "Inspection keeps an explicit resume action even at bottom") model.retainSurface("inspector:chat") model.retainSurface("inspector:chat") // Idempotent appearance. model.releaseSurface("chat") @@ -83,17 +84,115 @@ final class ChatHistoryPumpTests: XCTestCase { model.beginContentInspection() model.retainSurface("message:chat") XCTAssertTrue(model.isInspectingContent) - model.readingViewportChanged(followsTail: true, needsOlder: true) + model.readingViewportChanged(followsTail: true, needsOlder: true, isAwayFromBottom: false) XCTAssertFalse(model.followsTail) + XCTAssertTrue(model.showsJumpToLatest) try await Task.sleep(for: .milliseconds(30)) let olderRequests = await performer.beforeRequests XCTAssertEqual(olderRequests, 0) model.releaseSurface("message:chat") XCTAssertFalse(model.isInspectingContent) XCTAssertFalse(model.followsTail, "Closing the reader must not silently return to latest") + XCTAssertTrue(model.showsJumpToLatest, "Closing must not hide the only resume action") + let jump = model.jumpToLatestToken + model.jumpToLatest() + try await eventually { model.jumpToLatestToken > jump && !model.isJumpingToLatest } + XCTAssertTrue(model.followsTail) + XCTAssertFalse(model.showsJumpToLatest) + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + XCTAssertFalse(model.showsJumpToLatest, "A completed jump must clear the inspection exception") + model.beginContentInspection() + XCTAssertTrue(model.showsJumpToLatest) + model.readingViewportChanged(followsTail: true, needsOlder: false, isAwayFromBottom: false) + XCTAssertFalse(model.showsJumpToLatest, "Manually reaching bottom also clears the inspection exception") + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + XCTAssertFalse(model.showsJumpToLatest) model.releaseSurface("chat") } + func testLatestVisibilityPreservesHistoryModeAndTrimmedTailRetry() async throws { + let performer = HistoryPumpHTTP() + defer { Task { await performer.releaseLatest(failing: true) } } + let credentials = InMemoryCredentialStore() + let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString() + try credentials.store(HubCredentials(hubUrl: "http://127.0.0.1:1", accessToken: "test", jwt: "e30.\(payload).test")) + let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/latest-visibility-\(UUID().uuidString)", + credentialStore: credentials, performer: performer)) + let model = ChatModel(session: hub, sessionId: "latest-visibility") + defer { model.stop(); hub.shutdown() } + model.start() + try await eventually { !model.blocks.isEmpty && !model.isSyncingTail } + let controller = await hub.windows.open(sessionId: model.sessionId) + await controller.syncTail(ensureAfterCurrent: true) + try await eventually { !model.isSyncingTail } + XCTAssertFalse(model.showsJumpToLatest) + + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + try await eventually { await controller.state.viewMode == .history } + XCTAssertFalse(model.followsTail) + XCTAssertFalse(model.showsJumpToLatest, "A small reading offset pauses following without showing the button") + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: true) + XCTAssertTrue(model.showsJumpToLatest) + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + XCTAssertFalse(model.showsJumpToLatest) + XCTAssertFalse(model.followsTail) + let mode = await controller.state.viewMode + XCTAssertEqual(mode, .history, "Hiding the button must not change window retention") + + let initialJump = model.jumpToLatestToken + model.jumpToLatest() + XCTAssertTrue(model.isJumpingToLatest) + XCTAssertTrue(model.showsJumpToLatest, "An in-flight jump is visible even without a distance/trim exception") + try await eventually { model.jumpToLatestToken == initialJump + 1 } + XCTAssertFalse(model.showsJumpToLatest) + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + try await eventually { await controller.state.viewMode == .history } + + // Overflow the real native history budget, leaving a retained bottom + // that is not the live tail. Geometry alone must not hide recovery. + let messages = (11...810).map { seq in + WindowMessage(wire: DecryptedMessage(id: "history-\(seq)", seq: seq, + content: ["role": "agent", "content": ["type": "codex", "data": [ + "type": "message", "message": .string("History \(seq)"), + ]]], createdAt: seq * 1000, invokedAt: seq * 1000)) + } + await controller.ingestSSEMessages(messages) + try await eventually { model.hasTrimmedTail } + XCTAssertTrue(model.showsJumpToLatest) + let retained = await controller.state.messages.map(\.id) + let jump = model.jumpToLatestToken + var requests = await performer.latestRequests + await performer.gateLatest() + model.jumpToLatest() + XCTAssertTrue(model.isJumpingToLatest) + XCTAssertTrue(model.showsJumpToLatest) + try await eventually { await performer.latestRequests == requests + 1 } + model.jumpToLatest() // Disabled UI / re-entrant taps remain single-flight. + await performer.releaseLatest(failing: true) + try await eventually { !model.isJumpingToLatest && !model.isSyncingTail } + XCTAssertEqual(model.jumpToLatestToken, jump) + XCTAssertFalse(model.followsTail) + XCTAssertTrue(model.hasTrimmedTail) + XCTAssertTrue(model.showsJumpToLatest, "Failed recovery must leave the retry affordance") + let afterFailure = await controller.state + XCTAssertEqual(afterFailure.messages.map(\.id), retained) + XCTAssertEqual(afterFailure.viewMode, .history) + let failedRequests = await performer.latestRequests + XCTAssertEqual(failedRequests, requests + 1) + + requests = failedRequests + await performer.gateLatest() + model.jumpToLatest() + try await eventually { await performer.latestRequests == requests + 1 } + await performer.releaseLatest(failing: false) + try await eventually { model.jumpToLatestToken == jump + 1 && !model.hasTrimmedTail } + XCTAssertTrue(model.followsTail) + XCTAssertFalse(model.isJumpingToLatest) + XCTAssertFalse(model.showsJumpToLatest) + model.readingViewportChanged(followsTail: false, needsOlder: false, isAwayFromBottom: false) + XCTAssertFalse(model.showsJumpToLatest) + } + private func eventually(file: StaticString = #filePath, line: UInt = #line, _ condition: () async -> Bool) async throws { for _ in 0..<200 { if await condition() { return } @@ -108,9 +207,25 @@ private actor HistoryPumpHTTP: HTTPPerforming { private(set) var beforeRequests = 0 private var olderContinuation: CheckedContinuation? private var sendsTailUpdate = false + private(set) var latestRequests = 0 + private var gatesLatest = false + private var latestFails = false + private var latestContinuation: CheckedContinuation? func enableTailUpdate() { sendsTailUpdate = true } + func gateLatest() { + gatesLatest = true + latestFails = false + } + + func releaseLatest(failing: Bool) { + gatesLatest = false + latestFails = failing + latestContinuation?.resume() + latestContinuation = nil + } + func releaseOlder() { olderContinuation?.resume() olderContinuation = nil @@ -137,6 +252,9 @@ private actor HistoryPumpHTTP: HTTPPerforming { } else if query.contains(where: { $0.name == "afterAt" }) { response = page(sendsTailUpdate ? [row("live", seq: 11)] : [], direction: .after, hasMore: false) } else { + latestRequests += 1 + if gatesLatest { await withCheckedContinuation { latestContinuation = $0 } } + if latestFails { throw URLError(.notConnectedToInternet) } response = page([row("new", seq: 10)], direction: .latest, hasMore: true) } return (try JSONEncoder().encode(response), diff --git a/ios/HapiTests/ToolTranscriptPresentationTests.swift b/ios/HapiTests/ToolTranscriptPresentationTests.swift index 9b1c3411..dc8b4dad 100644 --- a/ios/HapiTests/ToolTranscriptPresentationTests.swift +++ b/ios/HapiTests/ToolTranscriptPresentationTests.swift @@ -89,6 +89,7 @@ final class ToolTranscriptPresentationTests: XCTestCase { try await eventually { host.presentedViewController != nil && !model.followsTail } try await Task.sleep(for: .milliseconds(600)) XCTAssertTrue(model.isInspectingContent) + XCTAssertTrue(model.showsJumpToLatest, "Viewport proximity must not hide an inspection's resume action") XCTAssertNil(model.toolInspection.selection, "Group root must pause following without a selected tool") XCTAssertEqual(collection.numberOfItems(inSection: 0), 6) let presented = try XCTUnwrap(host.presentedViewController) @@ -125,6 +126,7 @@ final class ToolTranscriptPresentationTests: XCTestCase { let restored = try XCTUnwrap(collection.visibleCells.first { $0.accessibilityIdentifier == anchorID }) XCTAssertEqual(restored.frame.minY - collection.contentOffset.y, anchorY, accuracy: 1) XCTAssertFalse(model.followsTail) + XCTAssertTrue(model.showsJumpToLatest, "The real transcript must retain its resume action after dismissal") XCTAssertEqual(collection.numberOfItems(inSection: 0), 6) } } diff --git a/ios/HapiTests/TranscriptLayoutTests.swift b/ios/HapiTests/TranscriptLayoutTests.swift index 114c999c..4cd4df9a 100644 --- a/ios/HapiTests/TranscriptLayoutTests.swift +++ b/ios/HapiTests/TranscriptLayoutTests.swift @@ -21,6 +21,7 @@ final class TranscriptLayoutTests: XCTestCase { var jump = 0 var layouts: [Int] = [] var viewport: TranscriptViewport? + @ObservationIgnored var viewports: [TranscriptViewport] = [] let presentation = ChatPresentationState() init(_ rows: [Row]) { self.rows = rows } } @@ -32,7 +33,7 @@ final class TranscriptLayoutTests: XCTestCase { items: driver.rows, historyVersion: driver.version, jumpToken: driver.jump, historyControlID: "history", isInspectionPresented: driver.inspecting, - onViewport: { driver.viewport = $0 }, + onViewport: { driver.viewport = $0; driver.viewports.append($0) }, onLayout: { version, _ in driver.layouts.append(version) } ) { row in AnyView(StatefulRow(row: row).environment(\.chatPresentationState, driver.presentation)) @@ -101,6 +102,20 @@ final class TranscriptLayoutTests: XCTestCase { .first.map { ($0.accessibilityIdentifier!, $0.frame.minY - view.contentOffset.y) } } + private func bottomDistance(_ view: UICollectionView) -> CGFloat { + max(-view.adjustedContentInset.top, + view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom) - view.contentOffset.y + } + + private func browseBottom(_ view: UICollectionView, distance: CGFloat) async { + view.delegate?.scrollViewWillBeginDragging?(view) + view.contentOffset.y += bottomDistance(view) - distance + view.layoutIfNeeded() + view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false) + await settle() + XCTAssertEqual(bottomDistance(view), distance, accuracy: 1) + } + private func anchorY(_ id: String, in view: UICollectionView) -> CGFloat? { view.visibleCells.first { $0.accessibilityIdentifier == id }.map { $0.frame.minY - view.contentOffset.y } } @@ -110,6 +125,72 @@ final class TranscriptLayoutTests: XCTestCase { return "offset=\(view.contentOffset.y) height=\(view.contentSize.height) follows=\(String(describing: driver.viewport?.followsTail)) cells=\(cells)" } + func testLatestAffordanceHysteresisNeverResumesFollowingAtSmallOffsets() async { + let driver = Driver(rows(0..<50)) + let (window, view) = host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) + + view.delegate?.scrollViewWillBeginDragging?(view) + XCTAssertEqual(driver.viewport?.followsTail, false) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Starting a drag must not reveal the button") + view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false) + await settle() + XCTAssertEqual(driver.viewport?.followsTail, true, "A zero-distance drag still ends at the actual bottom") + + await browseBottom(view, distance: 30) + XCTAssertEqual(driver.viewport?.followsTail, false) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) + let reports = driver.viewports.count + await browseBottom(view, distance: 50) + XCTAssertEqual(driver.viewports.count, reports, "Offsets inside the same band must not republish the viewport") + + for (distance, away): (CGFloat, Bool) in [(81, true), (60, true), (23, false), (40, false)] { + await browseBottom(view, distance: distance) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, away) + XCTAssertEqual(driver.viewport?.followsTail, false, "Hiding the affordance must not snap back to latest") + } + await browseBottom(view, distance: 0) + XCTAssertEqual(driver.viewport?.followsTail, true) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) + } + + func testStreamingAndViewportResizeRevealLatestWithoutMovingTheSmallOffsetAnchor() async throws { + let driver = Driver(rows(0..<50)) + let (window, view) = host(driver) + defer { window.isHidden = true } + await settle { driver.layouts.contains(0) } + await browseBottom(view, distance: 30) + let anchor = try XCTUnwrap(readingAnchor(view)) + + driver.rows[49].height += 20 // Streaming / a small media resize. + await settle { abs(self.bottomDistance(view) - 50) <= 1 } + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) + XCTAssertEqual(driver.viewport?.followsTail, false) + XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1) + + driver.rows[49].height += 40 + await settle { driver.viewport?.isAwayFromBottom == true } + XCTAssertEqual(bottomDistance(view), 90, accuracy: 1) + XCTAssertEqual(driver.viewport?.followsTail, false) + XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1) + + for height: CGFloat in [600, 844] { // Keyboard / composer viewport changes. + window.frame.size.height = height + window.layoutIfNeeded() + await settle() + XCTAssertEqual(driver.viewport?.followsTail, false) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, true) + XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1) + } + driver.jump += 1 + await settle { driver.viewport?.followsTail == true } + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) + await browseBottom(view, distance: 40) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Explicit latest resets the hysteresis latch") + } + func testInspectorPausesTailAndPreservesAnchorUntilExplicitJump() async throws { let driver = Driver(rows(0..<50)) let (window, view) = host(driver) @@ -120,6 +201,7 @@ final class TranscriptLayoutTests: XCTestCase { driver.inspecting = true await settle { driver.viewport?.followsTail == false } XCTAssertEqual(driver.viewport?.needsOlder, false) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) driver.rows.append(contentsOf: rows(50..<65)) driver.version += 1 await settle { driver.layouts.contains(1) } @@ -131,6 +213,7 @@ final class TranscriptLayoutTests: XCTestCase { driver.jump += 1 await settle { driver.viewport?.followsTail == true } XCTAssertEqual(driver.viewport?.isAtBottom, true) + XCTAssertEqual(driver.viewport?.isAwayFromBottom, false) } func testInspectorPausesShortTranscriptHistoryDemand() async { @@ -197,6 +280,8 @@ final class TranscriptLayoutTests: XCTestCase { await settle() XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1) XCTAssertEqual(driver.viewport?.followsTail, true) + XCTAssertTrue(driver.viewports.allSatisfy { !$0.isAwayFromBottom }, + "Following must hide the affordance even while self-sizing/viewport corrections are pending") } func testShortAndHiddenOnlyPagesStillAcknowledgeLayout() async { diff --git a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift index f73bed2d..41899216 100644 --- a/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift +++ b/ios/Packages/HapiKit/Sources/HapiUI/Chat/AnchoredTranscriptList.swift @@ -8,6 +8,8 @@ public struct TranscriptViewport: Equatable { public let followsTail: Bool public let needsOlder: Bool public let isAtBottom: Bool + /// Hysteretic button affordance, not a tail-following threshold. + public let isAwayFromBottom: Bool } /// SwiftUI owns message content; UIKit owns scrolling, recycling and layout @@ -279,6 +281,7 @@ public final class TranscriptCollectionController() #if DEBUG private(set) var cellConfigurationCount = 0 @@ -457,10 +460,12 @@ public final class TranscriptCollectionController= 80 { + isAwayFromBottom = true + } + } +} diff --git a/ios/Packages/HapiKit/Tests/HapiUITests/TranscriptBottomProximityTests.swift b/ios/Packages/HapiKit/Tests/HapiUITests/TranscriptBottomProximityTests.swift new file mode 100644 index 00000000..dbe3fe92 --- /dev/null +++ b/ios/Packages/HapiKit/Tests/HapiUITests/TranscriptBottomProximityTests.swift @@ -0,0 +1,45 @@ +import Testing +@testable import HapiUI + +@Suite("Transcript latest-button proximity") +struct TranscriptBottomProximityTests { + @Test func smallDeparturesRemainHiddenUntilTheShowThreshold() { + var state = TranscriptBottomProximity() + #expect(!state.isAwayFromBottom) + for distance in [-30.0, 0, 1, 2, 24, 50, 79.9] { + state.update(bottomDistance: distance, followsTail: false) + #expect(!state.isAwayFromBottom) + } + state.update(bottomDistance: 80, followsTail: false) + #expect(state.isAwayFromBottom) + } + + @Test func visibilityPersistsBetweenThresholdsAndHidesAtTheLowerBoundary() { + var state = TranscriptBottomProximity() + state.update(bottomDistance: 100, followsTail: false) + for distance in [80.0, 79, 81, 40, 24.1] { + state.update(bottomDistance: distance, followsTail: false) + #expect(state.isAwayFromBottom) + } + state.update(bottomDistance: 24, followsTail: false) + #expect(!state.isAwayFromBottom) + for distance in [25.0, 23, 25, 50, 79.9] { + state.update(bottomDistance: distance, followsTail: false) + #expect(!state.isAwayFromBottom) + } + state.update(bottomDistance: 80, followsTail: false) + #expect(state.isAwayFromBottom) + state.update(bottomDistance: -20, followsTail: false) + #expect(!state.isAwayFromBottom) + } + + @Test func followingResetsVisibilityEvenBeforeBottomLayoutCatchesUp() { + var state = TranscriptBottomProximity() + state.update(bottomDistance: 200, followsTail: false) + #expect(state.isAwayFromBottom) + state.update(bottomDistance: 200, followsTail: true) + #expect(!state.isAwayFromBottom) + state.update(bottomDistance: 50, followsTail: false) + #expect(!state.isAwayFromBottom) + } +}