Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ final class ChatTranscriptUITableView: UITableView {
recordCurrentViewport()
}

var isUserScrollMomentumActive: Bool {
return isTracking || isDragging || isDecelerating
}

func applyTranscriptViewportInsets(
topChromeInset: CGFloat,
adjustedBottomInset: CGFloat,
Expand Down Expand Up @@ -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
}
Comment thread
azooz2003-bit marked this conversation as resolved.
if wasPinnedToTop {
// Keep the transcript pinned to the top chrome reservation — the
// symmetric counterpart to `wasAtBottom`. Without this, a
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
}

Expand Down
51 changes: 50 additions & 1 deletion ios/cmuxUITests/cmuxUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Comment on lines +1228 to +1245

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Wait for momentum-state evidence, not just an early offset jump.

This goes green as soon as one post-swipe sample has a larger offsetY. That can still happen even if the coordinator later restores/snaps during deceleration, so the test does not actually pin the regression this PR is fixing. Please key the assertion off the new structured scroll-state fields (scrollDragging/scrollDecelerating) and verify the transcript stays away from the live tail while momentum is active, or after momentum fully settles.

Suggested direction
-        let afterSwipe = try waitForTranscriptMetrics(table, timeout: 1.5) {
-            $0.offsetY > before.offsetY + 40
-        }
+        let duringMomentum = try waitForTranscriptMetrics(table, timeout: 1.5) {
+            ($0.scrollDragging || $0.scrollDecelerating)
+                && $0.offsetY > before.offsetY + 40
+                && $0.distanceFromBottom > 80
+        }
+        let settled = try waitForTranscriptMetrics(table, timeout: 2.5) {
+            !$0.scrollDragging && !$0.scrollDecelerating
+        }
...
-            metrics: afterSwipe
+            metrics: duringMomentum
...
-            afterSwipe.offsetY,
+            duringMomentum.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)"
+            "A fast transcript swipe should keep control while user momentum is active. before=\(before) duringMomentum=\(duringMomentum) settled=\(settled)"
         )
         XCTAssertGreaterThan(
-            afterSwipe.distanceFromBottom,
+            settled.distanceFromBottom,
             80,
-            "A single fast swipe from the middle fixture must not snap to the live bottom. before=\(before) after=\(afterSwipe)"
+            "A single fast swipe from the middle fixture must not snap back to the live bottom after momentum settles. before=\(before) duringMomentum=\(duringMomentum) settled=\(settled)"
         )

As per path instructions, "base logic on a single authoritative, structured source ... not on ... 'best effort' fallbacks." Based on learnings and the PR context, these new scroll-state fields are the authoritative momentum signal this regression test should assert against.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)"
let duringMomentum = try waitForTranscriptMetrics(table, timeout: 1.5) {
($0.scrollDragging || $0.scrollDecelerating)
&& $0.offsetY > before.offsetY + 40
&& $0.distanceFromBottom > 80
}
let settled = try waitForTranscriptMetrics(table, timeout: 2.5) {
!$0.scrollDragging && !$0.scrollDecelerating
}
captureKeyboardEvidenceFrame(
prefix: "scroll-deceleration-after",
index: 0,
startedAt: Date(),
metrics: duringMomentum
)
XCTAssertGreaterThan(
duringMomentum.offsetY,
before.offsetY + 40,
"A fast transcript swipe should keep control while user momentum is active. before=\(before) duringMomentum=\(duringMomentum) settled=\(settled)"
)
XCTAssertGreaterThan(
settled.distanceFromBottom,
80,
"A single fast swipe from the middle fixture must not snap back to the live bottom after momentum settles. before=\(before) duringMomentum=\(duringMomentum) settled=\(settled)"
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ios/cmuxUITests/cmuxUITests.swift` around lines 1228 - 1245, The transcript
swipe test is asserting only an early `offsetY` increase, which can pass before
the coordinator later snaps back during deceleration. Update the
`waitForTranscriptMetrics`/`captureKeyboardEvidenceFrame` flow in `cmuxUITests`
to key off the authoritative structured scroll-state fields (`scrollDragging`
and `scrollDecelerating`) instead of a single offset jump, and make the
`XCTAssertGreaterThan` checks verify the transcript remains away from the live
tail while momentum is active or after it fully settles.

Source: Path instructions

)
}

@MainActor
func testAgentChatExpansionControlsPreserveTranscriptScrollPosition() throws {
let app = launchAgentChatInlinePreviewApp()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}

Expand Down
Loading