From 8bcb31e99dab74644cfc2933f593754b330e24f4 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:32:50 -0400 Subject: [PATCH] fix(ios): keep Work chat on-screen when the keyboard opens Opening the composer or system keyboard shrank the transcript window without re-gluing a following viewport, and could overscroll a reading position into blank space. Re-pin the live tail across window and card collapse the same way terminals already do; restore a scrolled-up offset instead of yanking to latest. Co-authored-by: Cursor --- .../Work/WorkChatSessionView+Actions.swift | 119 ++++++++- .../ADE/Views/Work/WorkChatSessionView.swift | 192 +++++++++++++- apps/ios/ADETests/ADETests.swift | 249 ++++++++++++++++++ .../sync-and-multi-device/ios-companion.md | 17 +- 4 files changed, 558 insertions(+), 19 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 889e463fe..cc6014dd5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -1498,6 +1498,30 @@ extension WorkChatSessionView { let distance = max(0, rawDistance) scrollMetrics.distanceFromBottom = distance + let layoutRecent = workChatLayoutAdjustedRecently( + lastAdjustmentUptime: scrollMetrics.lastLayoutAdjustmentUptime, + now: ProcessInfo.processInfo.systemUptime + ) + if workChatShouldReleaseFollowForUserScroll( + following: isNearBottom, + userDrivenPhase: timelineDragActive || scrollMetrics.phaseIsUserDriven, + layoutAdjustedRecently: layoutRecent, + distanceFromBottom: distance, + offsetRetreat: scrollMetrics.stableOffsetY - scrollMetrics.offsetY + ) { + cancelPendingInitialBottomPinForUserScroll() + releaseBottomStickinessForUserScroll(reason: "user-scroll") + return + } + + // Keyboard shrink inflates `distanceFromBottom` with an unchanged offset — + // the same predicate flip the terminal refuses to consult on layout. Skip + // resume/near-bottom inference until that pass has settled; the layout + // observer re-glues. A real scroll-up already returned above. + if layoutRecent { + return + } + if bottomStickinessReleasedByUser { guard distance <= workChatStickResumeThreshold, !timelineDragActive else { return } bottomStickinessReleasedByUser = false @@ -1512,9 +1536,11 @@ extension WorkChatSessionView { return } + // Once following, stay following until the reader actually moves (the + // deadband check above). A leftover `.interacting` phase from keyboard + // avoidance must not drop the pin. let nextIsNearBottom = isNearBottom - ? !timelineDragActive - : (!timelineDragActive && distance <= workChatStickResumeThreshold) + || (!timelineDragActive && distance <= workChatStickResumeThreshold) if nextIsNearBottom != isNearBottom { isNearBottom = nextIsNearBottom @@ -1531,9 +1557,9 @@ extension WorkChatSessionView { /// The reader took the transcript over, so the initial bottom pin stands down. /// - /// A user-driven scroll phase is enough to stand down the opening pin. This - /// runs on the native scroll phase transition, so a small tap cannot strand a - /// freshly-opened chat while a real drag still cancels the pin immediately. + /// Called from stickiness once the container is stable and the reader has + /// actually moved past the 2pt deadband. Keyboard `.interacting` never + /// reaches here. @MainActor func cancelPendingInitialBottomPinForUserScroll() { guard pendingInitialBottomPinSessionId == session.id else { return } @@ -1548,6 +1574,89 @@ extension WorkChatSessionView { isNearBottom = false } + /// Re-glue or restore the transcript after a window/content layout pass. + /// + /// Lives on the content-size observer, not the per-frame position observer, + /// because a pin is a scroll write and a scan of the tail. Keyboard shrink + /// already changes `scrollableHeight`/`containerHeight` here; after the + /// opening pin disarms this was previously a no-op, which is what left the + /// newest lines cropped below the shorter window. + @MainActor + func applyLayoutGeometryScrollAdjustment( + previous: WorkChatContentSizeSample, + next: WorkChatContentSizeSample, + proxy: ScrollViewProxy + ) { + defer { + scrollMetrics.containerHeight = next.containerHeight + scrollMetrics.contentHeight = next.contentHeight + } + + guard previous.containerHeight > 1 || previous.contentHeight > 1 else { + scrollMetrics.stableOffsetY = scrollMetrics.offsetY + return + } + + let containerDelta = next.containerHeight - previous.containerHeight + let contentDelta = next.contentHeight - previous.contentHeight + let viewportDelta = workChatLayoutViewportDelta( + contentDelta: contentDelta, + scrollableDelta: next.scrollableHeight - previous.scrollableHeight + ) + let windowChanged = workChatLayoutWindowChanged( + containerDelta: containerDelta, + viewportDelta: viewportDelta + ) + let contentShrunk = contentDelta < -workChatLayoutGeometrySlop + + if windowChanged || contentShrunk { + scrollMetrics.lastLayoutAdjustmentUptime = ProcessInfo.processInfo.systemUptime + } + // Reclaim write permission before the pin so keyboard `.interacting` cannot + // block the very scroll write that re-glues the tail. + if windowChanged { + if timelineScrollPhaseUserDriven { + timelineScrollPhaseUserDriven = false + } + if timelineDragActive { + timelineDragActive = false + } + // Phase-change can still mark the reader as having taken over before + // this observer runs. If they were at the tail, that was the keyboard, + // not a scroll-up — put follow back so the pin can run. + if workChatShouldReclaimFollowAfterWindowChange( + following: isNearBottom, + distanceFromPreviousTail: max(0, previous.scrollableHeight - scrollMetrics.stableOffsetY) + ) { + bottomStickinessReleasedByUser = false + isNearBottom = true + } + } + + let adjustment = workChatLayoutScrollAdjustment( + following: isNearBottom, + mayWriteScrollOffset: canWriteAutomaticScrollOffset, + containerDelta: containerDelta, + contentDelta: contentDelta, + viewportDelta: viewportDelta, + previousOffsetY: scrollMetrics.stableOffsetY, + nextScrollableHeight: next.scrollableHeight + ) + switch adjustment { + case .none: + break + case .pinToLatest: + pinToLatestAfterLayout(proxy, reason: "layout-geometry") + case .restoreOffset(let y): + guard abs(y - scrollMetrics.offsetY) > workChatLayoutGeometrySlop else { return } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + scrollPosition.scrollTo(y: y) + } + } + } + @MainActor func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool) { bottomStickinessReleasedByUser = false diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index ecfaf0f1d..f500d5d1c 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -28,6 +28,116 @@ let workChatOlderHistoryScrollableDistance: CGFloat = 1 /// How long the transcript's content size has to stay unchanged before the /// opening bottom pin is considered settled. let workChatInitialPinQuiescenceMilliseconds = 600 +/// Sub-point layout jitter is not a keyboard, composer, or collapse event. +let workChatLayoutGeometrySlop: CGFloat = 1 +/// After a window or collapse layout pass, keep treating `.interacting` as the +/// keyboard rather than the reader. Keyboard animation is ~250ms; this grace +/// covers the last frame plus SwiftUI's trailing phase. +let workChatLayoutFollowGraceSeconds: TimeInterval = 0.45 + +/// How a layout pass should move the transcript. +enum WorkChatLayoutScrollAdjustment: Equatable { + case none + /// Follow is on: glue the window to the real tail after the pass. + case pinToLatest + /// Follow is off: put the offset back to the pre-layout reading, clamped to + /// the new range, so a shorter window does not overscroll into blank and + /// does not let keyboard avoidance steal the reader's place. + case restoreOffset(CGFloat) +} + +/// Isolates the window/inset contribution of a layout pass. +/// +/// `scrollableHeight = contentHeight - containerHeight + insets`. A keyboard +/// typically raises `scrollableHeight` without changing `contentHeight`. +func workChatLayoutViewportDelta(contentDelta: CGFloat, scrollableDelta: CGFloat) -> CGFloat { + scrollableDelta - contentDelta +} + +func workChatLayoutWindowChanged(containerDelta: CGFloat, viewportDelta: CGFloat) -> Bool { + abs(containerDelta) > workChatLayoutGeometrySlop + || abs(viewportDelta) > workChatLayoutGeometrySlop +} + +func workChatLayoutAdjustedRecently( + lastAdjustmentUptime: TimeInterval, + now: TimeInterval, + grace: TimeInterval = workChatLayoutFollowGraceSeconds +) -> Bool { + lastAdjustmentUptime > 0 && (now - lastAdjustmentUptime) < grace +} + +/// Keyboard avoidance reports `.interacting`. That is not the reader taking +/// over, and treating it as such both drops follow and blocks the pin write. +func workChatShouldIgnoreUserScrollPhaseForLayout( + userDrivenPhase: Bool, + layoutAdjustedRecently: Bool +) -> Bool { + userDrivenPhase && layoutAdjustedRecently +} + +func workChatShouldReleaseFollowForUserScroll( + following: Bool, + userDrivenPhase: Bool, + layoutAdjustedRecently: Bool, + distanceFromBottom: CGFloat, + offsetRetreat: CGFloat +) -> Bool { + guard following, userDrivenPhase else { return false } + // During keyboard/composer layout, `distanceFromBottom` is inflated with an + // unchanged offset — the terminal predicate flip. A real scroll-up still + // retreats `contentOffset` relative to the frozen pre-layout restore point. + if layoutAdjustedRecently { + return offsetRetreat > workChatTouchScrollDeadband + } + return distanceFromBottom > workChatTouchScrollDeadband +} + +/// Keyboard `.interacting` can drop follow before the layout observer runs. +/// Put it back only when the pre-keyboard offset was still at the tail — a +/// reader who had already scrolled away keeps their place. +func workChatShouldReclaimFollowAfterWindowChange( + following: Bool, + distanceFromPreviousTail: CGFloat +) -> Bool { + !following && distanceFromPreviousTail <= workChatTouchScrollDeadband +} + +/// Same class of layout pin the terminal already has: a following viewport +/// re-glues across keyboard/composer shrink and card collapse; a reader who +/// scrolled up keeps their place. +func workChatLayoutScrollAdjustment( + following: Bool, + mayWriteScrollOffset: Bool, + containerDelta: CGFloat, + contentDelta: CGFloat, + viewportDelta: CGFloat, + previousOffsetY: CGFloat, + nextScrollableHeight: CGFloat +) -> WorkChatLayoutScrollAdjustment { + guard mayWriteScrollOffset else { return .none } + + let windowChanged = workChatLayoutWindowChanged( + containerDelta: containerDelta, + viewportDelta: viewportDelta + ) + let contentShrunk = contentDelta < -workChatLayoutGeometrySlop + let windowShrunk = containerDelta < -workChatLayoutGeometrySlop + || viewportDelta > workChatLayoutGeometrySlop + + if following { + // Content growth while following is already pinned by the timeline + // observers. Pinning on every streaming layout pass would cancel the + // in-flight pin Task and lag the tail. Keyboard, keyboard-hide, and + // collapse are the cases this observer uniquely owns. + guard windowChanged || contentShrunk else { return .none } + return .pinToLatest + } + + guard windowShrunk || contentShrunk else { return .none } + let restored = min(max(0, previousOffsetY), nextScrollableHeight) + return .restoreOffset(restored) +} struct WorkChatOlderHistoryLoadResult { let succeeded: Bool @@ -156,6 +266,20 @@ final class WorkChatScrollMetrics { var distanceFromTop: CGFloat = 0 var offsetY: CGFloat = 0 var scrollableHeight: CGFloat = 0 + var containerHeight: CGFloat = 0 + var contentHeight: CGFloat = 0 + /// Offset to restore across a layout pass. Updated only while the container + /// is stable so a keyboard animation cannot overwrite the place the reader + /// was looking. + var stableOffsetY: CGFloat = 0 + /// `ProcessInfo.processInfo.systemUptime` of the last window or collapse + /// layout pass. 0 means no layout pin has run in this session. + var lastLayoutAdjustmentUptime: TimeInterval = 0 + /// Raw scroll-phase bit, updated even while layout grace is ignoring the + /// phase for write-permission purposes. After the keyboard settles, a finger + /// that stayed on the transcript never emits a new transition — stickiness + /// still has to see that the reader is driving the offset. + var phaseIsUserDriven = false /// Position of the row currently being probed (the list's first row, or the /// armed row while a prepend is in flight), in the scroll coordinate space. var probeRowId: String? @@ -180,6 +304,7 @@ struct WorkChatScrollGeometrySample: Equatable { /// Distance still to scroll to reach the last row. 0 at the very bottom. let distanceFromBottom: CGFloat let containerHeight: CGFloat + let contentHeight: CGFloat init(_ geometry: ScrollGeometry) { self.offsetY = (geometry.contentOffset.y * 2).rounded() / 2 @@ -192,21 +317,25 @@ struct WorkChatScrollGeometrySample: Equatable { let position = geometry.contentOffset.y + geometry.contentInsets.top self.distanceFromTop = max(0, (position * 2).rounded() / 2) self.distanceFromBottom = max(0, self.scrollableHeight - self.distanceFromTop) - self.containerHeight = geometry.containerSize.height + self.containerHeight = (geometry.containerSize.height * 2).rounded() / 2 + self.contentHeight = (geometry.contentSize.height * 2).rounded() / 2 } } /// The transcript's content size, sampled separately from the per-frame scroll /// position so layout-driven work (the opening pin, the short-transcript -/// alignment) runs on content changes instead of on every scroll frame. +/// alignment, the keyboard/collapse re-glue) runs on content and window +/// changes instead of on every scroll frame. struct WorkChatContentSizeSample: Equatable { let contentHeight: CGFloat + let containerHeight: CGFloat let scrollableHeight: CGFloat var contentFitsViewport: Bool { scrollableHeight <= 0.5 } init(_ geometry: ScrollGeometry) { self.contentHeight = (geometry.contentSize.height * 2).rounded() / 2 + self.containerHeight = (geometry.containerSize.height * 2).rounded() / 2 let scrollable = geometry.contentSize.height - geometry.containerSize.height + geometry.contentInsets.top + geometry.contentInsets.bottom self.scrollableHeight = max(0, (scrollable * 2).rounded() / 2) @@ -1661,15 +1790,28 @@ struct WorkChatSessionView: View { .scrollPosition($scrollPosition) .onScrollPhaseChange { _, phase in let userDriven = workChatScrollPhaseIsUserDriven(phase) + scrollMetrics.phaseIsUserDriven = userDriven + let layoutRecent = workChatLayoutAdjustedRecently( + lastAdjustmentUptime: scrollMetrics.lastLayoutAdjustmentUptime, + now: ProcessInfo.processInfo.systemUptime + ) + if workChatShouldIgnoreUserScrollPhaseForLayout( + userDrivenPhase: userDriven, + layoutAdjustedRecently: layoutRecent + ) { + // Keyboard/composer geometry reports as `.interacting`. Keep + // follow and pin writes enabled; the layout observer re-glues. + return + } guard timelineScrollPhaseUserDriven != userDriven else { return } timelineScrollPhaseUserDriven = userDriven timelineDragActive = userDriven if userDriven { - // Let the native ScrollView own the whole interaction. The old - // zero-distance simultaneous DragGesture competed with it and - // could leave a tail-pinned chat one gesture away from moving. - cancelPendingInitialBottomPinForUserScroll() - releaseBottomStickinessForUserScroll(reason: "scroll-phase") + // Follow release is not decided here. Keyboard avoidance reports + // `.interacting` before the geometry sample lands; unsticking on + // the phase alone is what dropped follow and blocked the pin. + // `updateBottomStickiness` releases only when the container is + // stable and the reader has actually moved. return } // A fling that ended may have left a prepend correction waiting. @@ -1681,24 +1823,52 @@ struct WorkChatSessionView: View { // Fires per scroll frame. Everything here is O(1) and writes to a // reference box or to state that only changes at a threshold — no // list scans, and nothing that invalidates the transcript per frame. + let contentDelta = sample.contentHeight - scrollMetrics.contentHeight + let scrollableDelta = sample.scrollableHeight - scrollMetrics.scrollableHeight + let viewportDelta = workChatLayoutViewportDelta( + contentDelta: contentDelta, + scrollableDelta: scrollableDelta + ) + let containerDelta = sample.containerHeight - scrollMetrics.containerHeight + let hasLayoutBaseline = scrollMetrics.containerHeight > 1 + || scrollMetrics.contentHeight > 1 + let windowChanged = hasLayoutBaseline && workChatLayoutWindowChanged( + containerDelta: containerDelta, + viewportDelta: viewportDelta + ) + let contentShrunk = hasLayoutBaseline + && contentDelta < -workChatLayoutGeometrySlop scrollMetrics.offsetY = sample.offsetY scrollMetrics.distanceFromTop = sample.distanceFromTop scrollMetrics.scrollableHeight = sample.scrollableHeight + // Stamp the layout grace on this frame — before stickiness runs — + // so a keyboard shrink cannot lose the race to `.interacting`. + // Freeze the restore point while the window is moving or the tape + // is collapsing so offsetRetreat can still see a real scroll-up. + // Inset-only keyboard (safe-area) shows up as viewportDelta. + if windowChanged || contentShrunk { + scrollMetrics.lastLayoutAdjustmentUptime = ProcessInfo.processInfo.systemUptime + } else { + scrollMetrics.stableOffsetY = sample.offsetY + } guard sample.containerHeight > 1 else { return } updateBottomStickiness(distanceFromBottom: sample.distanceFromBottom, proxy: proxy) continueAutomaticOlderHistoryIfNeeded() requestOlderHistoryIfScrolledNearTop(distanceFromTop: sample.distanceFromTop) } - // Content SIZE changes only — this observer never fires while the - // reader is merely scrolling, which is what keeps the tail scan in - // `resolvePendingInitialBottomPinAfterLayout` off the scroll path. + // Content SIZE and window changes only — this observer never fires + // while the reader is merely scrolling, which is what keeps the tail + // scan in `resolvePendingInitialBottomPinAfterLayout` off the scroll + // path. Keyboard shrink changes `containerHeight`/`scrollableHeight` + // here, which is why the layout pin lives on this observer. .onScrollGeometryChange(for: WorkChatContentSizeSample.self) { geometry in WorkChatContentSizeSample(geometry) - } action: { _, sample in + } action: { previous, sample in if transcriptContentFitsViewport != sample.contentFitsViewport { transcriptContentFitsViewport = sample.contentFitsViewport } resolvePendingInitialBottomPinAfterLayout(proxy, reason: "content-size") + applyLayoutGeometryScrollAdjustment(previous: previous, next: sample, proxy: proxy) } .coordinateSpace(name: workChatScrollCoordinateSpace) .background( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 982d253be..c27385a70 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -15580,6 +15580,255 @@ final class ADETests: XCTestCase { XCTAssertFalse(workChatScrollPhaseIsUserDriven(.animating)) } + func testFollowingViewportShrinkPinsToLatest() { + // Keyboard/composer shrink: same class of bug as + // `testKeyboardShrinkFlipsTailPredicateWithUnchangedOffset` on the + // terminal. Follow must re-glue rather than consult the inflated distance. + let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: 340) + XCTAssertEqual(viewportDelta, 340) + XCTAssertTrue(workChatLayoutWindowChanged(containerDelta: -340, viewportDelta: viewportDelta)) + XCTAssertTrue(workChatLayoutWindowChanged(containerDelta: 0, viewportDelta: viewportDelta)) + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: true, + containerDelta: -340, + contentDelta: 0, + viewportDelta: viewportDelta, + previousOffsetY: 4200, + nextScrollableHeight: 4540 + ), + .pinToLatest + ) + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: true, + containerDelta: 0, + contentDelta: 0, + viewportDelta: viewportDelta, + previousOffsetY: 4200, + nextScrollableHeight: 4540 + ), + .pinToLatest + ) + } + + func testFollowingViewportGrowPinsToLatest() { + // Keyboard hide grows the window; a following viewport re-glues to the + // real end rather than sitting on the offset the smaller window left. + let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: -340) + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: true, + containerDelta: 340, + contentDelta: 0, + viewportDelta: viewportDelta, + previousOffsetY: 4540, + nextScrollableHeight: 4200 + ), + .pinToLatest + ) + } + + func testReadingHistoryViewportShrinkRestoresOffsetWithoutPinning() { + let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: 340) + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: false, + mayWriteScrollOffset: true, + containerDelta: -340, + contentDelta: 0, + viewportDelta: viewportDelta, + previousOffsetY: 1800, + nextScrollableHeight: 4540 + ), + .restoreOffset(1800) + ) + } + + func testFollowingContentShrinkPinsToLatest() { + // Cards collapsing at turn-end shorten the tape under a following + // viewport. Pinning to the real end is what avoids the blank-tail look. + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: true, + containerDelta: 0, + contentDelta: -400, + viewportDelta: 0, + previousOffsetY: 4200, + nextScrollableHeight: 3800 + ), + .pinToLatest + ) + } + + func testReadingHistoryContentShrinkClampsOffsetToNewRange() { + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: false, + mayWriteScrollOffset: true, + containerDelta: 0, + contentDelta: -3000, + viewportDelta: 0, + previousOffsetY: 4000, + nextScrollableHeight: 1200 + ), + .restoreOffset(1200) + ) + } + + func testReadingHistoryContentGrowthDoesNotMoveOffset() { + // Streaming into the tail while the reader is in history must not restore + // a stale offset or yank to latest. The prepend machinery owns insertion + // above; growth below should leave the reader put. + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: false, + mayWriteScrollOffset: true, + containerDelta: 0, + contentDelta: 240, + viewportDelta: 0, + previousOffsetY: 1800, + nextScrollableHeight: 4440 + ), + .none + ) + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: true, + containerDelta: 0, + contentDelta: 240, + viewportDelta: 0, + previousOffsetY: 4200, + nextScrollableHeight: 4440 + ), + .none + ) + } + + func testLayoutPinDefersToReaderDuringFling() { + XCTAssertEqual( + workChatLayoutScrollAdjustment( + following: true, + mayWriteScrollOffset: false, + containerDelta: -340, + contentDelta: 0, + viewportDelta: 340, + previousOffsetY: 4200, + nextScrollableHeight: 4540 + ), + .none + ) + } + + func testKeyboardUserPhaseDoesNotReleaseFollow() { + XCTAssertTrue( + workChatShouldIgnoreUserScrollPhaseForLayout( + userDrivenPhase: true, + layoutAdjustedRecently: true + ) + ) + XCTAssertFalse( + workChatShouldReleaseFollowForUserScroll( + following: true, + userDrivenPhase: true, + layoutAdjustedRecently: true, + distanceFromBottom: 300, + offsetRetreat: 0 + ) + ) + XCTAssertTrue( + workChatShouldReleaseFollowForUserScroll( + following: true, + userDrivenPhase: true, + layoutAdjustedRecently: true, + distanceFromBottom: 300, + offsetRetreat: 3 + ) + ) + XCTAssertTrue( + workChatLayoutAdjustedRecently( + lastAdjustmentUptime: 10, + now: 10.2, + grace: workChatLayoutFollowGraceSeconds + ) + ) + XCTAssertFalse( + workChatLayoutAdjustedRecently( + lastAdjustmentUptime: 10, + now: 10.5, + grace: workChatLayoutFollowGraceSeconds + ) + ) + } + + func testUserScrollPhaseReleasesFollowOnceLayoutIsStable() { + XCTAssertTrue( + workChatShouldReleaseFollowForUserScroll( + following: true, + userDrivenPhase: true, + layoutAdjustedRecently: false, + distanceFromBottom: 3, + offsetRetreat: 0 + ) + ) + XCTAssertFalse( + workChatShouldReleaseFollowForUserScroll( + following: true, + userDrivenPhase: true, + layoutAdjustedRecently: false, + distanceFromBottom: 0, + offsetRetreat: 0 + ) + ) + XCTAssertFalse( + workChatShouldReleaseFollowForUserScroll( + following: false, + userDrivenPhase: true, + layoutAdjustedRecently: false, + distanceFromBottom: 80, + offsetRetreat: 80 + ) + ) + XCTAssertFalse( + workChatShouldIgnoreUserScrollPhaseForLayout( + userDrivenPhase: true, + layoutAdjustedRecently: false + ) + ) + } + + func testKeyboardDoesNotReclaimFollowOnceTheReaderHasLeftTheTail() { + XCTAssertTrue( + workChatShouldReclaimFollowAfterWindowChange( + following: false, + distanceFromPreviousTail: 0 + ) + ) + XCTAssertTrue( + workChatShouldReclaimFollowAfterWindowChange( + following: false, + distanceFromPreviousTail: workChatTouchScrollDeadband + ) + ) + XCTAssertFalse( + workChatShouldReclaimFollowAfterWindowChange( + following: false, + distanceFromPreviousTail: workChatTouchScrollDeadband + 1 + ) + ) + XCTAssertFalse( + workChatShouldReclaimFollowAfterWindowChange( + following: true, + distanceFromPreviousTail: 0 + ) + ) + } + func testShortTranscriptRendersFromTheTop() { XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: true), .topLeading) XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: false), .bottomLeading) diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index aa07a42a4..e9690312c 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -707,9 +707,20 @@ total-height correction the paragraph above exists to avoid. The force-pin remains as belt-and-braces, but it now stays armed until the content size has been quiet for 600ms rather than firing on a fixed retry ladder, because hydration routinely lands after that ladder ends. It stands down early only for -a deliberate drag (16pt — the 2pt stickiness deadband is finger jitter on a -freshly-opened chat). A transcript shorter than the viewport renders from the -top, desktop-style; a one-entry chat skips the pin entirely. +a deliberate scroll (the 2pt stickiness deadband — finger jitter and keyboard +`.interacting` do not count). A transcript shorter than the viewport renders from +the top, desktop-style; a one-entry chat skips the pin entirely. + +**Follow survives the keyboard the same way a terminal does.** Opening the +composer or the system keyboard shrinks the transcript window. A reader who was +glued to the live tail stays glued: the content-size observer re-pins to +`chat-end` after that pass (`workChatLayoutScrollAdjustment`), and the +keyboard's `.interacting` phase is not treated as the reader taking over — +consulting `distanceFromBottom` there is the same predicate flip the terminal +refuses to use on a layout resize. A reader who had scrolled up keeps that +place; the pre-keyboard offset is restored and clamped so a shorter window +cannot overscroll into blank. The same following re-pin runs when a finishing +turn collapses cards and the tape shrinks under the viewport. **A message's truncation budget only ever grows.** The newest assistant message renders tail-anchored under a generous budget so a finishing turn is readable in