Skip to content

Fix iOS chat scroll-to-bottom completion - #7287

Closed
azooz2003-bit wants to merge 3 commits into
mainfrom
fix-ios-scroll-bottom
Closed

azooz2003-bit wants to merge 3 commits into
mainfrom
fix-ios-scroll-bottom

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jul 3, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • Keep an explicit bottom-follow request active until the transcript reaches the measured bottom or the user takes scrolling back over.
  • Stop hiding the scroll-to-bottom button optimistically on tap.
  • Add long streaming fixture coverage for the partial-scroll regression and active momentum case.

Fix mechanism

The table coordinator now treats a scroll-to-bottom tap as an explicit bottom-follow state. Layout reloads, content-height growth, and cell size churn re-pin to the real bottom while that state is active. User dragging, or stable-content upward movement, cancels the follow. Normal no-reload SwiftUI updates at the bottom avoid repeated no-op scroll work.

This is a principled fix because scroll ownership is modeled from measured table geometry instead of timing or a one-shot offset write.

Repro

Before the fix, testAgentChatScrollToBottomButtonReturnsDuringStreamingLayoutChurn failed with the transcript still far from bottom after tapping the button:

  • result bundle: /tmp/cmux-ios-fixscroll-streaming-red.xcresult
  • observed distance from bottom: 5290.33

Verification

  • Focused local UI tests: /tmp/cmux-ios-fixscroll-focused-final3.xcresult, 3 passed, 0 failed.
  • Independent verifier: approved rebased HEAD 483dd4e4b53314f1f5a591bd08e137c194d90291 based on identical touched Swift/UI-test content and /tmp/cmux-ios-fixscroll-verifier-57a388.xcresult, 3 passed, 0 failed.
  • Video/frame evidence: /tmp/cmux-assets/fix-ios-scroll-bottom/scroll-bottom-streaming-tap/contact-sheet.png.
  • Autoreview: clean against origin/main, no accepted/actionable findings, no cmux-policy findings.

Residual risk: the passing UI-test result bundles still contain pre-existing SwiftUI runtime warnings about modifying state during view update. This PR does not introduce or fix that warning class.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Summary by cubic

Fixes iOS chat scroll-to-bottom so a tap reliably reaches the true bottom with a visible final glide, even during streaming churn and fast-swipe momentum. The button stays visible until the transcript is actually at bottom.

  • Bug Fixes

    • Treats a tap as an explicit bottom-follow with a user-visible final glide; stages near-bottom when far away and re-pins during content growth until completion.
    • Cancels active scroll momentum before following; cancels follow on user drag or stable-content upward scroll.
    • Derives bottom state from measured table geometry only; removes optimistic isAtBottom writes.
    • Adds regression UI tests across short/long/huge histories and streaming preview; verifies staging/glide behavior; introduces StreamingPreviewSeedConversation and supports CMUX_UITEST_AGENT_CHAT_FIXTURE_MESSAGE_COUNT and CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT.
  • Refactors

    • Moves ChatTranscriptTableConfiguration, ChatTranscriptTableItem, and scroll geometry helpers into dedicated files to simplify the table view.

Written for commit 2f34f80. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Enhanced chat transcript UI states (loading/truncated/empty/typing) with improved retry and initial-load rendering.
    • Demo/preview chat seeding can now repeat fixture conversations to create richer preview scenarios.
  • Bug Fixes
    • Improved the chat “scroll to bottom” button reliability, keeping it stable during streaming updates and respecting active user scrolling/momentum before following bottom.
  • Tests
    • Expanded UI coverage for the “scroll to bottom” button across multiple transcript lengths, streaming churn, and active scroll momentum scenarios.

@vercel

vercel Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jul 6, 2026 2:20am
cmux-staging Building Building Preview, Comment Jul 6, 2026 2:20am

@coderabbitai

coderabbitai Bot commented Jul 3, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Updates iOS chat transcript bottom-follow handling, adds transcript item/rendering configuration, introduces repeatable preview fixture seeding, and expands UI coverage for scroll-to-bottom behavior.

Changes

Explicit bottom-follow scrolling and tests

Layer / File(s) Summary
Scroll-to-bottom button triggers explicit follow request
Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptListView.swift
Button action increments scrollToBottomRequest instead of setting isAtBottom directly.
Transcript item model and rendering configuration
Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptTableItem.swift, Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptTableConfiguration.swift
Adds transcript table item cases and stable ids, then builds the SwiftUI transcript list and per-state row rendering.
Scroll geometry and explicit follow handling
Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptTableView+ScrollGeometry.swift, Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptTableView.swift
Adds scroll geometry helpers, explicit bottom-follow state, stable upward-scroll cancellation, follow-specific layout handling, and refactored bottom-scrolling helpers that update bottom state through shared bindings.
Debug bottom-follow metrics for transcript table
Packages/iOS/CmuxAgentChatUI/.../ChatTranscriptUITableView.swift
Adds DEBUG-only bottom-follow counters/flags and exposes them through the transcript accessibility value.
Fixture message repetition helpers for demo and preview screens
Packages/iOS/CmuxMobileShellUI/.../AgentChatDemoScreen.swift, Packages/iOS/CmuxMobileShellUI/.../StreamingPreviewSeedConversation.swift, Packages/iOS/CmuxMobileShellUI/.../StreamingChatPreviewView.swift
Adds helpers reading CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT to repeat seeded chat messages with adjusted ids, seq, and timestamps, and wires the streaming preview to the shared seed conversation.
UI tests and helpers for scroll-to-bottom button
ios/cmuxUITests/cmuxUITests.swift
Adds scroll-to-bottom UI tests, a streaming preview app launcher, transcript metric parsing updates, idle-state detection, and synchronization helpers for button visibility and hittability assertions.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#7109: Both PRs modify iOS ChatTranscriptTableView.Coordinator scroll-to-bottom and restoration behavior in response to active user scroll momentum.

Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (1 error)

Check name Status Explanation Resolution
Cmux No Test Or Debug Seam In Production Source ❌ Error Adds DEBUG-only bottom-follow metrics/accessors in production ChatTranscriptUITableView.swift for UI tests (recordBottom…, debugBottomUserVisible…), creating a test seam in shipping source. Move the observation into the test target via @testable import (widen private→internal if needed); if it must stay debug-only, isolate it in a dedicated debug file/folder.
✅ Passed checks (24 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed Touched production types are UI-bound or explicitly isolated (@MainActor store/preview model, actor source); no new implicit MainActor models or unsafe shared mutable Sendables.
Cmux Swift Blocking Runtime ✅ Passed Changed production Swift files add no blocking waits, sleeps, syncs, or locks; the new scroll-follow logic is event-driven, with timing only in DEBUG/test scaffolding.
Cmux Browser Automation Off-Main ✅ Passed This PR only changes iOS chat UI and UI tests; it doesn’t touch browser.* routing, socket-worker dispatch, or main-actor policy code.
Cmux Expensive Synchronous Load ✅ Passed No new synchronous agent-history/file load was added; the new main-actor .task paths only build in-memory fixture messages, and transcript code just reworks scrolling/state.
Cmux Cache Substitution Correctness ✅ Passed Cached scroll metrics are only transient UI follow hints; bottom state still comes from measured geometry, with optional cold-cache guards.
Cmux No Hacky Sleeps ✅ Passed No touched non-Swift runtime/build scripts; changes are Swift/iOS code and tests, and the diff adds no fixed sleeps or polling.
Cmux Algorithmic Complexity ✅ Passed Only linear per-update item building and DEBUG/test fixture repetition were added; no nested rescans or repeated hot-path sort/filter regressions in production code.
Cmux Swift Concurrency ✅ Passed No new DispatchQueue/Combine/completion-handler patterns were introduced; remaining Tasks are SwiftUI/preview/test boundaries or existing debug lifecycle code.
Cmux Swift @Concurrent ✅ Passed No touched Swift change introduces invalid @concurrent usage or a missing @concurrent on nonisolated async work; the new async code is UI-bound or runs via explicit Task hops.
Cmux Swift File And Package Boundaries ✅ Passed Touched production Swift files are all under 500 lines; the only large file is test code, and the budget TSV shrank ChatTranscriptTableView from 513 to 494.
Cmux Swiftpm Lockfiles ✅ Passed No Package.swift, .gitignore, or Package.resolved files changed; the PR only touches source/UI files and one TSV budget file.
Cmux Swift Logging ✅ Passed No added print/debugPrint/dump/NSLog/Logger in touched production Swift files; the modified hunks are scroll-state logic only.
Cmux User-Facing Error Privacy ✅ Passed The new user-facing error/recovery copy is generic (“Couldn't load this conversation”, “Retry”, “Earlier history is on your Mac”) and exposes no internals.
Cmux Full Internationalization ✅ Passed Production Swift text is localized via String(localized:defaultValue:) and matching en/ja catalog entries exist; remaining new copy is debug-only/test-only.
Cmux Swiftui State Layout ✅ Passed PASS: No new ObservableObject/@Published/GeometryReader patterns were introduced; the only SwiftUI view change reuses existing onGeometryChange, and the rest is UIKit bridge/test code.
Cmux Architecture Rethink ✅ Passed The diff keeps scroll ownership in the coordinator with measured geometry; timing/sleeps are confined to DEBUG preview and UI tests, which the rule allows.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: the diff only touches chat transcript views, preview seeding, and UI tests; it adds no NSWindow/WindowGroup/NSPanel/NSWindowController code or cmuxAuxiliaryWindowIdentifiers changes.
Cmux Source Artifacts ✅ Passed All changed paths are intentional source/config/test files; no screenshots, logs, temp dirs, caches, downloads, or other forbidden artifacts appear in the diff.
Cmux No Ambient Global State ✅ Passed The patch adds only instance methods/nested state on owning types; no new file-scope funcs, mutable globals, or singleton/namespace helpers were introduced.
Title check ✅ Passed The title is concise and accurately reflects the main iOS scroll-to-bottom fix.
Description check ✅ Passed The description covers the fix, repro, and verification, so it is mostly complete despite some template sections being omitted.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-ios-scroll-bottom

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the iOS chat scroll-to-bottom bug where tapping the button during streaming layout churn or active scroll momentum would not reliably reach the true bottom. The fix replaces the previous optimistic isAtBottom = true write with a durable isExplicitBottomFollowActive state machine that survives reloads, inset changes, and content growth until measured geometry confirms the scroll is complete.

  • State machine: Introduces isExplicitBottomFollowActive, isUserVisibleBottomFollowActive, and associated helpers in the ChatTranscriptTableView.Coordinator; a staged two-phase scroll (invisible jump + visible animated glide) delivers a smooth user-visible follow-to-bottom experience while re-pinning after every layout change.
  • Refactor: Extracts ChatTranscriptTableConfiguration and ChatTranscriptTableItem from the main table view file into dedicated files, and moves scroll-geometry helpers into a +ScrollGeometry extension.
  • Tests: Adds three new UI test cases covering static history scroll-to-bottom across fixture sizes, streaming-churn follow, and cancellation of active scroll momentum; extends the debug accessibility channel in ChatTranscriptUITableView with follow counters used to assert test invariants.

Confidence Score: 4/5

Safe to merge for the scroll bug fix itself; one new debug-only file and three new debug methods land in production Sources paths guarded only by #if DEBUG, violating the team's policy against test seams in production source.

The scroll-state machine change is well-tested and the core logic holds up — staging, animated glide, layout-change re-pinning, and cancellation all operate on measured geometry rather than timing guesses. The concern is that StreamingPreviewSeedConversation.swift is a new #if DEBUG-only file committed to production Sources/, and ChatTranscriptUITableView gains three new #if DEBUG-guarded methods whose sole consumers are UI tests, both violating the established cmux policy that such facilities belong in a test-support module or dedicated debug package rather than main Sources/.

Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift (new debug-only file in production Sources) and Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift (new #if DEBUG record* methods extending the test-instrumentation channel).

Important Files Changed

Filename Overview
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift Core scroll-state-machine change; introduces six new coordinator flags and two scrollToBottom entry points. isExplicitBottomFollowActive has no success-based terminal clear (pre-existing flag in previous threads), but layout-change and no-reload paths all guard correctly. Logic appears sound overall.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView+ScrollGeometry.swift New extension file extracting geometry helpers from the coordinator. Pure refactor with no logic changes; all helpers look correct.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift Extends the existing #if DEBUG debug-accessibility-value channel with four new scroll-follow counter fields and three new record* methods. These are new debug seams in production Sources (see comment).
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift New #if canImport(UIKit) && DEBUG-guarded file in production Sources providing UI-test fixture seeding with a uiTestFixtureRepeatCount() method; per policy, debug-only facilities should not live in the main Sources path (see comment).
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableConfiguration.swift Extracted from ChatTranscriptTableView.swift; access widened from private to internal as a natural consequence of file separation. Logic is unchanged.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableItem.swift Extracted from ChatTranscriptTableView.swift; access widened from private enum to internal enum. Pure refactor, logic unchanged.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift Entire file is #if DEBUG-guarded; new uiTestMessagesIfNeeded helpers with UITest in their names are acceptable because they never compile into a release build.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift Refactored to delegate fixture seeding to StreamingPreviewSeedConversation; file is entirely #if canImport(UIKit) && DEBUG guarded so no production impact.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift One-line fix removing the optimistic isAtBottom = true write on tap; correct change that lets measured geometry drive the binding instead.
ios/cmuxUITests/cmuxUITests.swift Three new UI test cases plus helper methods; uses short polling loops (20 ms RunLoop intervals) that are fine in a test target. Test fixtures and helper structs are well-scoped.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    TAP["User taps scroll-to-bottom button\n(scrollToBottomRequest += 1)"]
    SET_FLAGS["Set isExplicitBottomFollowActive = true\nisUserVisibleBottomFollowActive = true\nneedsInitialUserVisibleBottomStaging = true"]
    RELOAD{shouldReload?}
    STAGE{distance > visibleDistance?}
    JUMP["Instant jump to targetY − visibleDistance\n(invisible staging)"]
    ANIMATE["setContentOffset(targetY, animated: true)\n→ isUserVisibleBottomAnimationRunning = true"]
    ANIM_END["scrollViewDidEndScrollingAnimation"]
    AT_BOTTOM{distanceFromBottom ≤ threshold?}
    REPIN["handleLayoutChange / data reload\n→ scrollToBottomForFollow (non-animated)"]
    DRAG["scrollViewWillBeginDragging\n→ clear all follow flags"]
    UPWARD["cancelBottomFollowForStableContentUpwardScroll\n(stable content + offset moved up)\n→ clear all follow flags"]
    DONE["updateBottomState → isAtBottom = true\nisUserVisibleBottomFollowActive = false"]

    TAP --> SET_FLAGS --> RELOAD
    RELOAD -- "yes (layout churn)" --> REPIN --> STAGE
    RELOAD -- "no" --> STAGE
    STAGE -- "yes" --> JUMP --> ANIMATE
    STAGE -- "no" --> ANIMATE
    ANIMATE --> ANIM_END --> AT_BOTTOM
    AT_BOTTOM -- "no (content grew)" --> REPIN
    AT_BOTTOM -- "yes" --> DONE
    REPIN -- "layout change while follow active" --> REPIN
    DRAG --> DONE
    UPWARD --> DONE
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    TAP["User taps scroll-to-bottom button\n(scrollToBottomRequest += 1)"]
    SET_FLAGS["Set isExplicitBottomFollowActive = true\nisUserVisibleBottomFollowActive = true\nneedsInitialUserVisibleBottomStaging = true"]
    RELOAD{shouldReload?}
    STAGE{distance > visibleDistance?}
    JUMP["Instant jump to targetY − visibleDistance\n(invisible staging)"]
    ANIMATE["setContentOffset(targetY, animated: true)\n→ isUserVisibleBottomAnimationRunning = true"]
    ANIM_END["scrollViewDidEndScrollingAnimation"]
    AT_BOTTOM{distanceFromBottom ≤ threshold?}
    REPIN["handleLayoutChange / data reload\n→ scrollToBottomForFollow (non-animated)"]
    DRAG["scrollViewWillBeginDragging\n→ clear all follow flags"]
    UPWARD["cancelBottomFollowForStableContentUpwardScroll\n(stable content + offset moved up)\n→ clear all follow flags"]
    DONE["updateBottomState → isAtBottom = true\nisUserVisibleBottomFollowActive = false"]

    TAP --> SET_FLAGS --> RELOAD
    RELOAD -- "yes (layout churn)" --> REPIN --> STAGE
    RELOAD -- "no" --> STAGE
    STAGE -- "yes" --> JUMP --> ANIMATE
    STAGE -- "no" --> ANIMATE
    ANIMATE --> ANIM_END --> AT_BOTTOM
    AT_BOTTOM -- "no (content grew)" --> REPIN
    AT_BOTTOM -- "yes" --> DONE
    REPIN -- "layout change while follow active" --> REPIN
    DRAG --> DONE
    UPWARD --> DONE
Loading

Reviews (4): Last reviewed commit: "Fix iOS chat scroll-to-bottom glide" | Re-trigger Greptile

Comment on lines +123 to +140
private func streamingPreviewRepeatedMessages(_ messages: [ChatMessage], repeatCount: Int) -> [ChatMessage] {
var repeatedMessages: [ChatMessage] = []
repeatedMessages.reserveCapacity(messages.count * repeatCount)
for repeatIndex in 0..<repeatCount {
for message in messages {
repeatedMessages.append(
ChatMessage(
id: "\(message.id)-repeat-\(repeatIndex)",
seq: repeatedMessages.count,
role: message.role,
timestamp: message.timestamp.addingTimeInterval(TimeInterval(repeatIndex * messages.count)),
kind: message.kind
)
)
}
}
return repeatedMessages
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Duplicated fixture-repeat algorithm

streamingPreviewRepeatedMessages is functionally identical to AgentChatDemoScreen.repeatedUITestMessagesIfNeeded (same ID/seq/timestamp construction logic). Both live in separate #if DEBUG production-source files and must be kept in sync by hand. A change to ID-namespacing or timestamp offsets would need to land in two places, and the two implementations are already subtly different in their outer contract (AgentChatDemoScreen reads the env-var and guards inside the method; StreamingChatPreviewView exposes a separate streamingPreviewUITestFixtureRepeatCount() accessor for the same env-var). Factoring the shared algorithm into a single file-private helper used by both callers would remove the drift surface.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines 302 to 314
private func scrollToBottom(in tableView: UITableView, animated: Bool) {
tableView.layoutIfNeeded()
cancelUserScrollMomentumIfNeeded(in: tableView)
let targetY = maxOffsetY(in: tableView)
tableView.setContentOffset(CGPoint(x: tableView.contentOffset.x, y: targetY), animated: animated)
(tableView as? ChatTranscriptUITableView)?.recordCurrentViewport()
setAtBottom(true)
updateBottomState(from: tableView)
}

private func cancelUserScrollMomentumIfNeeded(in tableView: UITableView) {
guard tableView.isTracking || tableView.isDragging || tableView.isDecelerating else { return }
tableView.setContentOffset(tableView.contentOffset, animated: false)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 cancelUserScrollMomentumIfNeeded name vs guard scope mismatch

The name implies the function cancels in-flight deceleration, but the guard also triggers on isTracking and isDragging — states where the user's finger is still actively on the table. Setting contentOffset to cancel those touch states is valid UIKit (it terminates the internal pan recognizer), but callers reading this name expect it to be a no-op when the user has an active touch. The broader guard is intentional for the button-tap-during-active-drag scenario, so at minimum the name and/or the inline comment should reflect that it also cancels live-touch gestures, not just decelerating momentum.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +86 to +88
private var isExplicitBottomFollowActive = false
private var lastObservedScrollContentHeight: CGFloat?
private var lastObservedScrollOffsetY: CGFloat?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 isExplicitBottomFollowActive has no success-based terminal state

Once set, isExplicitBottomFollowActive is only cleared by scrollViewWillBeginDragging or cancelBottomFollowForStableContentUpwardScroll; there is no path that clears it when the table actually arrives and remains stably at the bottom. After streaming ends and the content is stable at the bottom, the flag stays true indefinitely. Every subsequent layout change — cell-size churn, rotation, keyboard show/hide — enters the isExplicitBottomFollowActive branch in handleLayoutChange and calls scrollToBottom instead of the normal restoreKeyboardViewport/wasAtBottom path. Functionally these produce the same offset when wasAtBottom == true, so there is no correctness bug today, but the state machine has no clean exit once success is reached. Clearing the flag inside updateBottomState when it observes distanceFromBottom == 0 would make the lifecycle explicit and remove the redundant scrollToBottom calls on stable-at-bottom layout events.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with 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.

Inline comments:
In `@ios/cmuxUITests/cmuxUITests.swift`:
- Around line 2007-2024: The streaming chat preview test flag is set in
launchStreamingChatPreviewApp, but the app root never consumes
CMUX_UITEST_STREAMING_CHAT_PREVIEW, so the intended preview path is never
activated. Update the app entry point/root scene selection to read this launch
environment value and route into the StreamingChatPreviewView branch when it is
present, using the existing launchApp and app root wiring as the place to add
the check; if that branch is not meant to exist, remove the unused flag from the
test helper instead.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 86-88: `isExplicitBottomFollowActive` is never reset when the
transcript actually reaches the bottom, so the bottom-follow state can stay
stuck on and keep forcing `scrollToBottom`/momentum cancellation during later
idle reloads. Update the state flow in `ChatTranscriptTableView` so the flag is
explicitly cleared when `updateBottomState` or the `scrollToBottom` path
confirms the view has settled at the measured bottom, while still clearing it on
user-initiated scroll changes via `scrollViewWillBeginDragging` and
`cancelBottomFollowForStableContentUpwardScroll`. Make the transition match the
intended invariant: active until bottom is reached or the user scrolls back.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`:
- Around line 191-217: The fixture-repeat logic in AgentChatDemoScreen is
duplicated with StreamingChatPreviewView and should be centralized. Extract the
shared repeat behavior from repeatedUITestMessagesIfNeeded and
streamingPreviewRepeatedMessages into one helper or ChatFixtureConversation
extension that handles the CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT env var,
id/seq generation, and timestamp offset. Update both preview screens to call
that shared helper so the repeat contract stays consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e4d9a962-7966-47be-ad8f-45aba5f29423

📥 Commits

Reviewing files that changed from the base of the PR and between e9c1d3c and 483dd4e.

📒 Files selected for processing (5)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift
  • ios/cmuxUITests/cmuxUITests.swift
💤 Files with no reviewable changes (1)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift

Comment on lines +2007 to +2024
@MainActor
private func launchStreamingChatPreviewApp(environment: [String: String] = [:]) -> XCUIApplication {
var launchEnvironment = [
"CMUX_UITEST_STREAMING_CHAT_PREVIEW": "1",
]
for (key, value) in environment {
launchEnvironment[key] = value
}
let app = launchApp(mockData: false, environment: launchEnvironment)
let table = app.tables["ChatTranscriptTableView"]
XCTAssertTrue(table.waitForExistence(timeout: 8))
XCTAssertTrue(
settleChatPreviewKeyboardDown(in: app, table: table),
"Streaming chat preview must start keyboard-down before scroll evidence is collected. metrics=\(String(describing: transcriptMetrics(from: table)))"
)
return app
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'CMUX_UITEST_STREAMING_CHAT_PREVIEW' --type=swift

Repository: manaflow-ai/cmux

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n-- streaming preview symbol search --\n'
rg -n 'StreamingChatPreviewView|CMUX_UITEST_STREAMING_CHAT_PREVIEW|ChatTranscriptTableView' ios -g '*.swift'

printf '\n-- probable app entry files --\n'
fd -a 'App.swift|main.swift|Scene.swift|ContentView.swift' ios

printf '\n-- inspect app entry candidates (first 250 lines each) --\n'
for f in $(fd -a 'App.swift|main.swift|Scene.swift|ContentView.swift' ios); do
  echo "### $f"
  sed -n '1,250p' "$f"
done

Repository: manaflow-ai/cmux

Length of output: 17210


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n-- search for any streaming-preview or env plumbing outside the earlier hits --\n'
rg -n 'StreamingChatPreviewView|CMUX_UITEST_STREAMING_CHAT_PREVIEW|ChatTranscriptTableView|UITestConfig|launchApp\(' ios cmux -g '*.swift'

printf '\n-- inspect the rest of the mobile root scene branch --\n'
sed -n '250,420p' /home/jailuser/git/ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift

printf '\n-- inspect launchApp helper in UI tests --\n'
sed -n '1,220p' ios/cmuxUITests/cmuxUITests.swift

Repository: manaflow-ai/cmux

Length of output: 3999


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n-- UITestConfig definitions and env keys --\n'
rg -n 'struct UITestConfig|enum UITestConfig|workspaceListLayoutPreviewEnabled|mockDataEnabled|dogfoodAttachURL|attachURL|CMUX_ZOOM_STRESS|CMUX_BOTTOM_SCROLL_STRESS|STREAMING_CHAT' ios cmuxPackage -g '*.swift'

printf '\n-- inspect UITestConfig and mobile root scene around the branching logic --\n'
for f in \
  ios/cmux/AppCompositionRoot.swift \
  ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift \
  ios/cmuxUITests/cmuxUITests.swift
do
  echo "### $f"
  wc -l "$f"
done

printf '\n-- cmuxUITests launch helper --\n'
sed -n '2050,2125p' ios/cmuxUITests/cmuxUITests.swift

printf '\n-- CMUXMobileRootScene branching around the debug previews --\n'
sed -n '230,340p' ios/cmuxPackage/Sources/cmuxFeature/CMUXMobileRootScene.swift

Repository: manaflow-ai/cmux

Length of output: 7278


Wire CMUX_UITEST_STREAMING_CHAT_PREVIEW into the app root. The test launcher sets this flag, but the app entry point never reads it and there’s no StreamingChatPreviewView branch, so this path won’t enter the intended preview state. Add the matching root-scene branch or remove the unused flag.

🤖 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 2007 - 2024, The streaming
chat preview test flag is set in launchStreamingChatPreviewApp, but the app root
never consumes CMUX_UITEST_STREAMING_CHAT_PREVIEW, so the intended preview path
is never activated. Update the app entry point/root scene selection to read this
launch environment value and route into the StreamingChatPreviewView branch when
it is present, using the existing launchApp and app root wiring as the place to
add the check; if that branch is not meant to exist, remove the unused flag from
the test helper instead.

@azooz2003-bit
azooz2003-bit force-pushed the fix-ios-scroll-bottom branch from 483dd4e to 8ab674a Compare July 3, 2026 23:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift (1)

191-217: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fixture-repeat logic still duplicated (now in StreamingPreviewSeedConversation.swift).

repeatedUITestMessagesIfNeeded is functionally identical to uiTestFixtureRepeatCount()/repeatedMessages(_:repeatCount:) in the new StreamingPreviewSeedConversation.swift (same env var, same id/seq/timestamp derivation). A previous review already flagged this duplication when the logic lived inline in StreamingChatPreviewView.swift; it has since moved rather than been consolidated. Both files are in the same target — extract one shared helper (e.g. a ChatFixtureConversation extension or a small file-scope utility struct) so the repeat contract can't drift between the demo screen and the streaming preview.

♻️ Suggested consolidation direction
-    private func repeatedUITestMessagesIfNeeded(_ messages: [ChatMessage]) -> [ChatMessage] {
-        let env = ProcessInfo.processInfo.environment
-        guard let rawRepeatCount = env["CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT"]?
-            .trimmingCharacters(in: .whitespacesAndNewlines),
-              let repeatCount = Int(rawRepeatCount),
-              repeatCount > 1
-        else {
-            return messages
-        }
-        var repeatedMessages: [ChatMessage] = []
-        repeatedMessages.reserveCapacity(messages.count * repeatCount)
-        for repeatIndex in 0..<repeatCount {
-            for message in messages {
-                repeatedMessages.append(
-                    ChatMessage(
-                        id: "\(message.id)-repeat-\(repeatIndex)",
-                        seq: repeatedMessages.count,
-                        role: message.role,
-                        timestamp: message.timestamp.addingTimeInterval(TimeInterval(repeatIndex * messages.count)),
-                        kind: message.kind
-                    )
-                )
-            }
-        }
-        return repeatedMessages
-    }
+    private func repeatedUITestMessagesIfNeeded(_ messages: [ChatMessage]) -> [ChatMessage] {
+        UITestFixtureRepeater.repeatedIfNeeded(
+            messages,
+            environment: ProcessInfo.processInfo.environment
+        )
+    }
🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`
around lines 191 - 217, The fixture-repeat logic in
repeatedUITestMessagesIfNeeded duplicates the same
CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT handling and repeated message
construction already present in
uiTestFixtureRepeatCount()/repeatedMessages(_:repeatCount:), so consolidate it
into one shared helper used by AgentChatDemoScreen and
StreamingPreviewSeedConversation. Extract the repeat-count parsing and
ChatMessage cloning logic into a common utility or extension in the shared
target, then have repeatedUITestMessagesIfNeeded call that helper instead of
maintaining a separate implementation.
🤖 Prompt for all review comments with 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.

Duplicate comments:
In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`:
- Around line 191-217: The fixture-repeat logic in
repeatedUITestMessagesIfNeeded duplicates the same
CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT handling and repeated message
construction already present in
uiTestFixtureRepeatCount()/repeatedMessages(_:repeatCount:), so consolidate it
into one shared helper used by AgentChatDemoScreen and
StreamingPreviewSeedConversation. Extract the repeat-count parsing and
ChatMessage cloning logic into a common utility or extension in the shared
target, then have repeatedUITestMessagesIfNeeded call that helper instead of
maintaining a separate implementation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1acd5f2c-e072-45ba-a0f3-e15193438ff2

📥 Commits

Reviewing files that changed from the base of the PR and between 483dd4e and 8ab674a.

📒 Files selected for processing (6)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingChatPreviewView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift
  • ios/cmuxUITests/cmuxUITests.swift
💤 Files with no reviewable changes (1)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift

@azooz2003-bit
azooz2003-bit force-pushed the fix-ios-scroll-bottom branch from 8ab674a to 9f4cf66 Compare July 3, 2026 23:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift (1)

231-276: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate the repeated isExplicitBottomFollowActive check in handleLayoutChange.

The same if isExplicitBottomFollowActive { scrollToBottom(in: tableView, animated: false) } appears three times (Lines 247-249, 255-258, 263-264) with subtly different follow-up behavior around pendingContentUpdateAnchor clearing and early return. The third occurrence doesn't clear pendingContentUpdateAnchor, unlike the other branches — please double check that a stale anchor from an earlier non-follow restore can't later be replayed by the contentChanged, let pendingContentUpdateAnchor branch (Line 267) once follow is cancelled.

♻️ Sketch of consolidation
 private func handleLayoutChange(...) {
     guard !isHandlingLayout else { return }
     ...
     isHandlingLayout = true
     defer { isHandlingLayout = false }

+    if isExplicitBottomFollowActive {
+        scrollToBottom(in: tableView, animated: false)
+        pendingContentUpdateAnchor = nil
+        updateBottomState(from: tableView)
+        return
+    }
     if tableView.isUserScrollMomentumActive {
-        if isExplicitBottomFollowActive {
-            scrollToBottom(in: tableView, animated: false)
-        }
         pendingContentUpdateAnchor = nil
         updateBottomState(from: tableView)
         return
     }
     if tableView.isViewportInsetsExternallyDriven || isApplyingDataUpdate {
-        if isExplicitBottomFollowActive {
-            scrollToBottom(in: tableView, animated: false)
-            return
-        }
         updateBottomState(from: tableView)
         return
     }

-    if isExplicitBottomFollowActive {
-        scrollToBottom(in: tableView, animated: false)
-    } else if boundsChanged, let oldViewport {
+    if boundsChanged, let oldViewport {
         restoreKeyboardViewport(snapshot: oldViewport, in: tableView)
     } else if contentChanged, let pendingContentUpdateAnchor {
         ...
🤖 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
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`
around lines 231 - 276, The handleLayoutChange logic in ChatTranscriptTableView
repeats the isExplicitBottomFollowActive scrollToBottom path in multiple
branches, and the branch after isViewportInsetsExternallyDriven or
isApplyingDataUpdate does not clear pendingContentUpdateAnchor. Consolidate the
bottom-follow handling into a single consistent path in handleLayoutChange, and
make sure pendingContentUpdateAnchor is cleared whenever you scroll to bottom in
explicit follow mode so a stale anchor cannot be restored later by the
contentChanged/pendingContentUpdateAnchor branch.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 231-276: The handleLayoutChange logic in ChatTranscriptTableView
repeats the isExplicitBottomFollowActive scrollToBottom path in multiple
branches, and the branch after isViewportInsetsExternallyDriven or
isApplyingDataUpdate does not clear pendingContentUpdateAnchor. Consolidate the
bottom-follow handling into a single consistent path in handleLayoutChange, and
make sure pendingContentUpdateAnchor is cleared whenever you scroll to bottom in
explicit follow mode so a stale anchor cannot be restored later by the
contentChanged/pendingContentUpdateAnchor branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5d5a0c30-92ab-4b5b-ab4b-5d2308de946f

📥 Commits

Reviewing files that changed from the base of the PR and between 8ab674a and 9f4cf66.

📒 Files selected for processing (4)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableConfiguration.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableItem.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
💤 Files with no reviewable changes (1)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift (1)

237-254: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Track scroll origin explicitly instead of inferring user takeover from offset deltas. scrollViewWillBeginDragging already clears follow for real drags, so this fallback is only handling non-drag scroll-origin changes. The contentSize-stable + contentOffset.y delta check is still an approximation and can disable follow on other upward offset changes; thread a dedicated programmatic-scroll/origin flag through the setContentOffset paths instead.

🤖 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
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`
around lines 237 - 254, The fallback in
cancelBottomFollowForStableContentUpwardScroll(in:) is still inferring user
takeover from contentOffset/contentSize deltas, which can clear bottom-follow
for non-drag upward movements. Replace that heuristic with an explicit
scroll-origin flag threaded through the setContentOffset-related paths in
ChatTranscriptTableView, and use it here to only disable follow for genuine
non-drag programmatic-origin transitions; keep scrollViewWillBeginDragging as
the direct path for real user drags.

Source: Path instructions

♻️ Duplicate comments (4)
ios/cmuxUITests/cmuxUITests.swift (1)

2118-2135: 🎯 Functional Correctness | 🟠 Major

Verify CMUX_UITEST_STREAMING_CHAT_PREVIEW is actually wired into the app root.

A prior review on this exact helper found the launch environment flag was set here but never consumed by the app entry point, with no StreamingChatPreviewView branch to enter — meaning testAgentChatScrollToBottomButtonReturnsDuringStreamingLayoutChurn (which depends on this launcher) may not actually exercise real streaming/layout churn. Unlike the other flagged item in this stack, this one has no "Addressed" annotation from a later commit.

#!/bin/bash
# Description: confirm the streaming-preview launch flag is consumed by the app root.
rg -n 'CMUX_UITEST_STREAMING_CHAT_PREVIEW|StreamingChatPreviewView' --type=swift
🤖 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 2118 - 2135, The launch flag
set by launchStreamingChatPreviewApp is not enough unless the app root actually
reads it and routes into StreamingChatPreviewView. Update the app entry/root
flow to consume CMUX_UITEST_STREAMING_CHAT_PREVIEW and branch into the streaming
chat preview path so
testAgentChatScrollToBottomButtonReturnsDuringStreamingLayoutChurn exercises
real streaming/layout churn. Use the existing symbols
launchStreamingChatPreviewApp, CMUX_UITEST_STREAMING_CHAT_PREVIEW, and
StreamingChatPreviewView to locate and wire the missing app-root entry point.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift (1)

166-172: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

isExplicitBottomFollowActive still never clears when the transcript actually reaches bottom.

The reset happens only in scrollViewWillBeginDragging (line 216) and cancelBottomFollowForStableContentUpwardScroll (249) — i.e. only on the "user scrolls back" half of the invariant. updateBottomState (430-438) clears the sibling isUserVisibleBottomFollowActive/needsInitialUserVisibleBottomStaging when isBottom but leaves isExplicitBottomFollowActive untouched, and scrollViewDidEndScrollingAnimation's "settled" else-branch (230-233) does the same omission. A prior review flagged exactly this and was marked addressed, but the reset is still absent here.

Concretely, this means once a scroll-to-bottom tap fires, isExplicitBottomFollowActive stays true indefinitely at rest, and every future reload while idling at the bottom takes the isExplicitBottomFollowActive || (wasAtBottom && …) branch (166) purely because of the stale flag rather than the intended wasAtBottom-driven condition — an invalid-but-representable state the invariant is supposed to prevent.

♻️ Suggested fix: clear the flag at the single canonical "reached bottom" point
 private func updateBottomState(from tableView: UITableView) {
     let isBottom = distanceFromBottom(in: tableView) <= chatTranscriptAtBottomThreshold
     setAtBottom(isBottom)
     if isBottom, !isUserVisibleBottomAnimationRunning {
+        isExplicitBottomFollowActive = false
         isUserVisibleBottomFollowActive = false
         needsInitialUserVisibleBottomStaging = false
     }
     syncDebugBottomFollowState(in: tableView)
 }

As per path instructions, "Ensure there is exactly one authoritative source of truth for whether follow mode is active... Avoid any correctness-critical fallback branches based on heuristics... prefer prompt state reads and fail-closed behavior."

Also applies to: 223-235, 430-438

🤖 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
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`
around lines 166 - 172, `isExplicitBottomFollowActive` is not being cleared when
the transcript actually reaches the bottom, leaving follow mode stuck active
after a scroll-to-bottom completes. Update the canonical “reached bottom”
handling in `ChatTranscriptTableView`—specifically `updateBottomState` and the
settled path in `scrollViewDidEndScrollingAnimation`—to reset
`isExplicitBottomFollowActive` alongside the existing bottom-follow flags when
`isBottom` is true. Keep `scrollViewWillBeginDragging` and
`cancelBottomFollowForStableContentUpwardScroll` as the user-cancel paths, but
make the bottom-settled path the single authoritative place that clears this
state.

Source: Path instructions

Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift (1)

191-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fixture limit/repeat logic still duplicated with StreamingPreviewSeedConversation.swift, and duplication has grown.

A prior review flagged repeatedUITestMessagesIfNeeded as duplicate of the repeat logic in StreamingPreviewSeedConversation.swift and marked it addressed, but the duplication is still present here. This diff additionally introduces limitedUITestMessagesIfNeeded, which duplicates the new limitedMessages helper in StreamingPreviewSeedConversation.swift — same env var, same guard conditions, same prefix logic. Both files live in the same target; extracting a shared helper (e.g., a small file-scope utility or extension on ChatFixtureConversation) would prevent the limit/repeat contract from silently drifting between the two preview/demo screens as it has now doubled in surface area.

🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`
around lines 191 - 234, The fixture limit/repeat behavior is duplicated between
AgentChatDemoScreen and StreamingPreviewSeedConversation, including both the
repeat and new limit helpers. Move the shared environment-driven logic out of
uiTestMessagesIfNeeded, limitedUITestMessagesIfNeeded, and
repeatedUITestMessagesIfNeeded into one common helper or extension used by both
screens, so the CMUX_UITEST_AGENT_CHAT_FIXTURE_MESSAGE_COUNT and
CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT handling stays consistent in one
place.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift (1)

30-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Counterpart of the duplicated fixture limit/repeat logic.

limitedMessages/uiTestFixtureRepeatCount/repeatedMessages here mirror limitedUITestMessagesIfNeeded/repeatedUITestMessagesIfNeeded in AgentChatDemoScreen.swift almost line-for-line. See the consolidated recommendation left on that file.

🤖 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
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift`
around lines 30 - 65, The fixture limiting/repeating logic in limitedMessages,
uiTestFixtureRepeatCount, and repeatedMessages is duplicated almost exactly with
the helpers in AgentChatDemoScreen.swift. Consolidate this behavior into a
shared utility or reusable helper so both StreamingPreviewSeedConversation and
AgentChatDemoScreen call the same implementation, keeping the environment
parsing and message repetition logic in one place.
🤖 Prompt for all review comments with 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.

Outside diff comments:
In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 237-254: The fallback in
cancelBottomFollowForStableContentUpwardScroll(in:) is still inferring user
takeover from contentOffset/contentSize deltas, which can clear bottom-follow
for non-drag upward movements. Replace that heuristic with an explicit
scroll-origin flag threaded through the setContentOffset-related paths in
ChatTranscriptTableView, and use it here to only disable follow for genuine
non-drag programmatic-origin transitions; keep scrollViewWillBeginDragging as
the direct path for real user drags.

---

Duplicate comments:
In `@ios/cmuxUITests/cmuxUITests.swift`:
- Around line 2118-2135: The launch flag set by launchStreamingChatPreviewApp is
not enough unless the app root actually reads it and routes into
StreamingChatPreviewView. Update the app entry/root flow to consume
CMUX_UITEST_STREAMING_CHAT_PREVIEW and branch into the streaming chat preview
path so testAgentChatScrollToBottomButtonReturnsDuringStreamingLayoutChurn
exercises real streaming/layout churn. Use the existing symbols
launchStreamingChatPreviewApp, CMUX_UITEST_STREAMING_CHAT_PREVIEW, and
StreamingChatPreviewView to locate and wire the missing app-root entry point.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 166-172: `isExplicitBottomFollowActive` is not being cleared when
the transcript actually reaches the bottom, leaving follow mode stuck active
after a scroll-to-bottom completes. Update the canonical “reached bottom”
handling in `ChatTranscriptTableView`—specifically `updateBottomState` and the
settled path in `scrollViewDidEndScrollingAnimation`—to reset
`isExplicitBottomFollowActive` alongside the existing bottom-follow flags when
`isBottom` is true. Keep `scrollViewWillBeginDragging` and
`cancelBottomFollowForStableContentUpwardScroll` as the user-cancel paths, but
make the bottom-settled path the single authoritative place that clears this
state.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`:
- Around line 191-234: The fixture limit/repeat behavior is duplicated between
AgentChatDemoScreen and StreamingPreviewSeedConversation, including both the
repeat and new limit helpers. Move the shared environment-driven logic out of
uiTestMessagesIfNeeded, limitedUITestMessagesIfNeeded, and
repeatedUITestMessagesIfNeeded into one common helper or extension used by both
screens, so the CMUX_UITEST_AGENT_CHAT_FIXTURE_MESSAGE_COUNT and
CMUX_UITEST_AGENT_CHAT_FIXTURE_REPEAT_COUNT handling stays consistent in one
place.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift`:
- Around line 30-65: The fixture limiting/repeating logic in limitedMessages,
uiTestFixtureRepeatCount, and repeatedMessages is duplicated almost exactly with
the helpers in AgentChatDemoScreen.swift. Consolidate this behavior into a
shared utility or reusable helper so both StreamingPreviewSeedConversation and
AgentChatDemoScreen call the same implementation, keeping the environment
parsing and message repetition logic in one place.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0e17298e-b27d-4c9b-8186-e892861b5e4a

📥 Commits

Reviewing files that changed from the base of the PR and between 9f4cf66 and 2f34f80.

⛔ Files ignored due to path filters (1)
  • .github/swift-file-length-budget.tsv is excluded by !**/*.tsv
📒 Files selected for processing (6)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView+ScrollGeometry.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/StreamingPreviewSeedConversation.swift
  • ios/cmuxUITests/cmuxUITests.swift

@lawrencecchen lawrencecchen added the stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening. label Sep 23, 2026
@github-project-automation github-project-automation Bot moved this from Todo to Done in cmux backlog Sep 23, 2026

This branch was successfully deployed

1 active deployment
Preview – cmux — 2f34f80f Deployed Jul 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

stale-revisit Closed after 30+ days without activity; preserved for possible revisit or reopening.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants