diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift index 0d7903feb655..0103b43ce577 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift @@ -145,11 +145,10 @@ struct ChatTranscriptTableView: UIViewRepresentable { defer { isApplyingDataUpdate = false } tableView.reloadData() tableView.layoutIfNeeded() - - if shouldScrollToBottom || wasAtBottom { + if shouldScrollToBottom || (wasAtBottom && !tableView.isUserScrollMomentumActive) { pendingContentUpdateAnchor = nil scrollToBottom(in: tableView, animated: false) - } else if let anchor { + } else if let anchor, !tableView.isUserScrollMomentumActive { restore(anchor, in: tableView) pendingContentUpdateAnchor = anchor } @@ -214,11 +213,12 @@ struct ChatTranscriptTableView: UIViewRepresentable { isHandlingLayout = true defer { isHandlingLayout = false } - if tableView.isViewportInsetsExternallyDriven { + if tableView.isUserScrollMomentumActive { + pendingContentUpdateAnchor = nil updateBottomState(from: tableView) return } - if isApplyingDataUpdate { + if tableView.isViewportInsetsExternallyDriven || isApplyingDataUpdate { updateBottomState(from: tableView) return } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift index b1f79d17d098..717efe43046e 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift @@ -126,6 +126,10 @@ final class ChatTranscriptUITableView: UITableView { recordCurrentViewport() } + var isUserScrollMomentumActive: Bool { + return isTracking || isDragging || isDecelerating + } + func applyTranscriptViewportInsets( topChromeInset: CGFloat, adjustedBottomInset: CGFloat, @@ -162,6 +166,12 @@ final class ChatTranscriptUITableView: UITableView { indicatorInsets.top = resolvedTopInset indicatorInsets.bottom = resolvedOverlayBottomInset verticalScrollIndicatorInsets = indicatorInsets + if isUserScrollMomentumActive { + // Preserve UIKit's live inset compensation while drag/deceleration owns the offset. + recordCurrentViewport() + isViewportInsetsExternallyDriven = false + return + } if wasPinnedToTop { // Keep the transcript pinned to the top chrome reservation — the // symmetric counterpart to `wasAtBottom`. Without this, a @@ -236,7 +246,7 @@ final class ChatTranscriptUITableView: UITableView { let presentationGap = composerPresentationMinY - presentationFrameMaxY recordKeyboardAnimationGap(presentationGap) return String( - format: "frameMinY=%.2f;frameMaxY=%.2f;frameHeight=%.2f;presentationFrameMaxY=%.2f;boundsHeight=%.2f;offsetY=%.2f;adjustedTopInset=%.2f;adjustedBottomInset=%.2f;visibleTopY=%.2f;visibleBottomY=%.2f;contentHeight=%.2f;distanceFromBottom=%.2f;keyboardEvents=%d;keyboardOverlap=%.2f;keyboardTargetOverlap=%.2f;keyboardGuideOverlap=%.2f;keyboardBottomConstraint=%.2f;composerMinY=%.2f;composerPresentationMinY=%.2f;presentationGap=%.2f;topChromeOverlayInset=%.2f;composerOverlayBottomInset=%.2f;keyboardAnimationActive=%d;keyboardAnimationProgress=%.2f;keyboardTransitionDuration=%.3f;maxAnimationPresentationGap=%.2f;keyboardAnimationSamples=%d;topEdgeEffectSoft=%d;bottomEdgeEffectSoft=%d;topContentScrollViewRegistered=%d", + format: "frameMinY=%.2f;frameMaxY=%.2f;frameHeight=%.2f;presentationFrameMaxY=%.2f;boundsHeight=%.2f;offsetY=%.2f;adjustedTopInset=%.2f;adjustedBottomInset=%.2f;visibleTopY=%.2f;visibleBottomY=%.2f;contentHeight=%.2f;distanceFromBottom=%.2f;keyboardEvents=%d;keyboardOverlap=%.2f;keyboardTargetOverlap=%.2f;keyboardGuideOverlap=%.2f;keyboardBottomConstraint=%.2f;composerMinY=%.2f;composerPresentationMinY=%.2f;presentationGap=%.2f;topChromeOverlayInset=%.2f;composerOverlayBottomInset=%.2f;keyboardAnimationActive=%d;keyboardAnimationProgress=%.2f;keyboardTransitionDuration=%.3f;maxAnimationPresentationGap=%.2f;keyboardAnimationSamples=%d;topEdgeEffectSoft=%d;bottomEdgeEffectSoft=%d;topContentScrollViewRegistered=%d;scrollTracking=%d;scrollDragging=%d;scrollDecelerating=%d", locale: Locale(identifier: "en_US_POSIX"), frameInWindow.minY, frameInWindow.maxY, @@ -267,7 +277,10 @@ final class ChatTranscriptUITableView: UITableView { keyboardDebugAnimationSampleCount, debugTopEdgeEffectSoft ? 1 : 0, debugBottomEdgeEffectSoft ? 1 : 0, - debugTopContentScrollViewRegistered ? 1 : 0 + debugTopContentScrollViewRegistered ? 1 : 0, + isTracking ? 1 : 0, + isDragging ? 1 : 0, + isDecelerating ? 1 : 0 ) } diff --git a/ios/cmuxUITests/cmuxUITests.swift b/ios/cmuxUITests/cmuxUITests.swift index 2466412d709b..8d9eb1e2412e 100644 --- a/ios/cmuxUITests/cmuxUITests.swift +++ b/ios/cmuxUITests/cmuxUITests.swift @@ -1203,6 +1203,49 @@ final class cmuxUITests: XCTestCase { } } + @MainActor + func testAgentChatTranscriptFastSwipeEvidence() throws { + let app = launchAgentChatInlinePreviewApp(environment: [ + "CMUX_UITEST_CHAT_INITIAL_SCROLL": "middle", + ]) + let table = app.tables["ChatTranscriptTableView"] + XCTAssertTrue(table.waitForExistence(timeout: 8)) + + let before = try waitForTranscriptMetrics(table, timeout: 8) { + $0.frameHeight > 240 + && $0.contentHeight > $0.boundsHeight * 1.6 + && $0.offsetY > 80 + && $0.distanceFromBottom > 220 + } + captureKeyboardEvidenceFrame( + prefix: "scroll-deceleration-before", + index: 0, + startedAt: Date(), + metrics: before + ) + + table.swipeUp(velocity: .fast) + let afterSwipe = try waitForTranscriptMetrics(table, timeout: 1.5) { + $0.offsetY > before.offsetY + 40 + } + captureKeyboardEvidenceFrame( + prefix: "scroll-deceleration-after", + index: 0, + startedAt: Date(), + metrics: afterSwipe + ) + XCTAssertGreaterThan( + afterSwipe.offsetY, + before.offsetY + 40, + "A fast transcript swipe should move through the chat history instead of being swallowed by parent gesture handling. before=\(before) after=\(afterSwipe)" + ) + XCTAssertGreaterThan( + afterSwipe.distanceFromBottom, + 80, + "A single fast swipe from the middle fixture must not snap to the live bottom. before=\(before) after=\(afterSwipe)" + ) + } + @MainActor func testAgentChatExpansionControlsPreserveTranscriptScrollPosition() throws { let app = launchAgentChatInlinePreviewApp() @@ -2202,9 +2245,12 @@ final class cmuxUITests: XCTestCase { let topEdgeEffectSoft: Bool let bottomEdgeEffectSoft: Bool let topContentScrollViewRegistered: Bool + let scrollTracking: Bool + let scrollDragging: Bool + let scrollDecelerating: Bool var description: String { - "frameMinY=\(frameMinY), frameMaxY=\(frameMaxY), frameHeight=\(frameHeight), presentationFrameMaxY=\(presentationFrameMaxY), boundsHeight=\(boundsHeight), offsetY=\(offsetY), adjustedTopInset=\(adjustedTopInset), adjustedBottomInset=\(adjustedBottomInset), visibleTopY=\(visibleTopY), visibleBottomY=\(visibleBottomY), contentHeight=\(contentHeight), distanceFromBottom=\(distanceFromBottom), keyboardEvents=\(keyboardEvents), keyboardOverlap=\(keyboardOverlap), keyboardTargetOverlap=\(keyboardTargetOverlap), composerMinY=\(composerMinY), composerPresentationMinY=\(composerPresentationMinY), presentationGap=\(presentationGap), topChromeOverlayInset=\(topChromeOverlayInset), composerOverlayBottomInset=\(composerOverlayBottomInset), keyboardAnimationActive=\(keyboardAnimationActive), keyboardAnimationProgress=\(keyboardAnimationProgress), keyboardTransitionDuration=\(keyboardTransitionDuration), maxAnimationPresentationGap=\(maxAnimationPresentationGap), keyboardAnimationSamples=\(keyboardAnimationSamples), topEdgeEffectSoft=\(topEdgeEffectSoft), bottomEdgeEffectSoft=\(bottomEdgeEffectSoft), topContentScrollViewRegistered=\(topContentScrollViewRegistered)" + "frameMinY=\(frameMinY), frameMaxY=\(frameMaxY), frameHeight=\(frameHeight), presentationFrameMaxY=\(presentationFrameMaxY), boundsHeight=\(boundsHeight), offsetY=\(offsetY), adjustedTopInset=\(adjustedTopInset), adjustedBottomInset=\(adjustedBottomInset), visibleTopY=\(visibleTopY), visibleBottomY=\(visibleBottomY), contentHeight=\(contentHeight), distanceFromBottom=\(distanceFromBottom), keyboardEvents=\(keyboardEvents), keyboardOverlap=\(keyboardOverlap), keyboardTargetOverlap=\(keyboardTargetOverlap), composerMinY=\(composerMinY), composerPresentationMinY=\(composerPresentationMinY), presentationGap=\(presentationGap), topChromeOverlayInset=\(topChromeOverlayInset), composerOverlayBottomInset=\(composerOverlayBottomInset), keyboardAnimationActive=\(keyboardAnimationActive), keyboardAnimationProgress=\(keyboardAnimationProgress), keyboardTransitionDuration=\(keyboardTransitionDuration), maxAnimationPresentationGap=\(maxAnimationPresentationGap), keyboardAnimationSamples=\(keyboardAnimationSamples), topEdgeEffectSoft=\(topEdgeEffectSoft), bottomEdgeEffectSoft=\(bottomEdgeEffectSoft), topContentScrollViewRegistered=\(topContentScrollViewRegistered), scrollTracking=\(scrollTracking), scrollDragging=\(scrollDragging), scrollDecelerating=\(scrollDecelerating)" } var effectiveFrameMaxY: CGFloat { @@ -2259,6 +2305,9 @@ final class cmuxUITests: XCTestCase { self.topEdgeEffectSoft = (values["topEdgeEffectSoft"] ?? 0) >= 0.5 self.bottomEdgeEffectSoft = (values["bottomEdgeEffectSoft"] ?? 0) >= 0.5 self.topContentScrollViewRegistered = (values["topContentScrollViewRegistered"] ?? 0) >= 0.5 + self.scrollTracking = (values["scrollTracking"] ?? 0) >= 0.5 + self.scrollDragging = (values["scrollDragging"] ?? 0) >= 0.5 + self.scrollDecelerating = (values["scrollDecelerating"] ?? 0) >= 0.5 } }