Skip to content

Improve iOS GUI chat sending focus - #7068

Closed
azooz2003-bit wants to merge 6 commits into
mainfrom
feat-ios-gui-sending-ux
Closed

azooz2003-bit wants to merge 6 commits into
mainfrom
feat-ios-gui-sending-ux

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • replace the iOS GUI chat dotted/timer working bubble with a compact animated trace
  • focus the just-sent outbound row near the transcript top by carrying a row-id signal from the pending item to the echoed message
  • add regression coverage for pending-to-echo focus and iOS send positioning

Verification

  • swift test --package-path Packages/iOS/CmuxAgentChatUI
  • swift test --package-path Packages/Shared/CmuxAgentChat
  • git diff --check
  • xcodebuild test -workspace ios/cmux.xcworkspace -scheme cmux-ios -configuration Debug -destination "platform=iOS Simulator,name=iPhone 17" -derivedDataPath /Users/abdulazizalbahar/Library/Developer/Xcode/DerivedData/cmux-ios-igui -only-testing:cmuxUITests/cmuxUITests/testAgentChatSendFocusesOutgoingBubbleNearTop PRODUCT_BUNDLE_IDENTIFIER=dev.cmux.ios.igui PRODUCT_DISPLAY_NAME="cmux DEV igui" CMUX_GIT_SHA=d268e809ca+ CMUX_DEV_TAG=igui CMUX_PRESENCE_BASE_URL= EXCLUDED_SOURCE_FILE_NAMES=Info.plist CODE_SIGNING_ALLOWED=NO
  • ./scripts/reload-cloud.sh --tag igui, fell back to local reload and succeeded
  • ios/scripts/reload.sh --tag igui, simulator install and auto-sign-in succeeded

Dogfood

Tag: igui
Mac deeplink: http://127.0.0.1:17320/igui
iOS simulator tag: igui
Physical iPhone reload: blocked by missing ASC credentials and local signing team

Do not merge until the iOS GUI dogfood checks pass.


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


Summary by cubic

Improves iOS chat sending UX with a compact animated bloom indicator and reliable send focus that keeps the just-sent message near the top with room below, even under floating top chrome and the keyboard.

  • New Features

    • Replaced the dots+timer bubble with a compact animated bloom in ChatTypingIndicatorView.
    • Added send-focus: carry a row-id from the pending outbound to the echoed row via latestOutboundFocusRowID, scroll it near the top with a temporary spacer, and wired it through ChatScreen → ChatTranscriptListView → ChatTranscriptTableView.
    • Preserves draft text in the demo chat (AgentChatDemoScreen).
  • Bug Fixes

    • Fixed focus under iOS 26 floating navigation chrome and during keyboard animations by computing a top-chrome overlay inset in ChatKeyboardTrackingViewController, using presentation frames in ChatTranscriptUITableView, and anchoring the focus accordingly; added a UI test that verifies top-focused positioning and safe gaps.
    • Fixed focus handoff for terminal echoes: when a pending send reconciles into a terminal command, transfer outbound focus to the command row; added unit tests.

Written for commit 94248eb. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Outgoing messages (including terminal commands) now request targeted focus so the sent bubble stays near the top, with layout-aware insets and a dedicated spacing adjustment to reduce jumping.
    • Updated the demo chat to preserve draft text while composing.
    • The chat “working” indicator now uses a compact breathing/trace style (static when reduced motion is enabled).
  • Bug Fixes
    • Fixed focus/scroll behavior when optimistic pending items reconcile into delivered transcript rows, including terminal command reconciliation.
  • Tests
    • Expanded unit tests for outbound focus transfer and added a UI test validating near-top bubble positioning and spacing.

@vercel

vercel Bot commented Jun 29, 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 Jun 30, 2026 4:21am
cmux-staging Building Building Preview, Comment Jun 30, 2026 4:21am

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 496d636f-c635-4369-8576-59eaf85e5a29

📥 Commits

Reviewing files that changed from the base of the PR and between c49fd13 and 94248eb.

📒 Files selected for processing (1)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift

📝 Walkthrough

Walkthrough

Adds outbound focus tracking through the chat store and transcript views, updates keyboard and table layout to position the sent message near the top, replaces the working indicator, adds focus and placement tests, and expands mobile chat and terminal send diagnostics.

Changes

Outbound Focus and Transcript Layout

Layer / File(s) Summary
Store focus row lifecycle
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift, Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
ChatTranscriptRow adds reusable row-ID helpers. ChatConversationStore stores the latest outbound focus row on send and updates it when pending outbound rows reconcile to committed message or terminal-command rows.
View wiring and draft state
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift, Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift, Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
ChatScreen forwards the outbound focus row ID into ChatTranscriptListView; the list view stores and forwards it to the table view; AgentChatDemoScreen adds draft state and passes a draft binding into ChatScreen.
Transcript table outbound focus
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift, Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift, Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift
ChatTranscriptTableView adds outbound focus inputs, coordinator state, focus helpers, spacer-item handling, and layout-change behavior; ChatKeyboardTrackingViewController threads a dynamic top-chrome inset into viewport inset updates; ChatTranscriptUITableView exposes presentationFrameInWindow() outside #if DEBUG.
Thinking indicator redraw
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift
ChatTypingIndicatorView switches the working-state UI from dots-plus-elapsed-time to ChatThinkingBloomView with animated Canvas-based rendering.
Store and UI focus tests
Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift, ios/cmuxUITests/cmuxUITests.swift
Store tests assert the outbound focus row ID during send and echo reconciliation, and cmuxUITests verifies the sent bubble’s near-top placement and spacing below it.

Chat Send Diagnostics Logging

Layer / File(s) Summary
Mobile chat event logging
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift
MobileChatEventSource adds diagnostics imports, start/end logging for history and send, subscription and frame logging for events, and compact summary helpers for emitted debug messages.
Terminal chat send logging
Sources/TerminalController+MobileChat.swift
v2MobileChatSend adds debug logging around terminal parameter resolution, prompt clearing, attachment-only submission, and paste submission result handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • manaflow-ai/cmux#6910: Modifies the same iOS chat top-inset and viewport inset plumbing used by the outbound focus layout changes.

Poem

🐰 Hop, hop — the sent bubble found its place,
and tracing light now spins with grace.
Focus hops from draft to done,
while debug logs hum softly on.


Important

Pre-merge checks failed

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

❌ Failed checks (2 errors, 2 warnings)

Check name Status Explanation Resolution
Cmux Swift Blocking Runtime ❌ Error ChatConversationStore adds production reconnect backoff via an idleSleep default of Task.sleep and awaits it in run(), which the rule forbids. Replace the sleep-based backoff with a cancellation-aware scheduler/timer or explicit reconnect signal; keep Task.sleep only in test-only seams.
Cmux User-Facing Error Privacy ❌ Error New mobile.chat API errors echo session_id in error bodies (data: ["session_id": sessionID]), which the rule forbids exposing in user-facing errors. Remove session_id from user-visible error payloads; keep only generic messages and log the identifier in sanitized internal telemetry if needed.
Description check ⚠️ Warning The summary and testing sections are present, but the required Demo Video, Review Trigger, and Checklist sections are missing. Add the Demo Video link, the review-trigger paste block, and a completed checklist covering local testing and bot-review status.
Docstring Coverage ⚠️ Warning Docstring coverage is 12.96% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (21 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately reflects the main change: improved chat send focus in the iOS GUI.
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 The new focus row ID stays within the existing @MainActor store and main-thread UI path; no new implicit-MainActor models, Sendable refs, or background store access were introduced.
Cmux Browser Automation Off-Main ✅ Passed No changes hit the scoped browser-automation routing files, and the touched MobileChat source has no browser.*/WebKit worker-routing logic.
Cmux Expensive Synchronous Load ✅ Passed No touched production Swift file adds a synchronous history/file/JSON load; the PR only wires outbound focus/scroll behavior. The lone JSON parse is in a UI test.
Cmux Cache Substitution Correctness ✅ Passed latestOutboundFocusRowID is a transient UI focus hint, not a persistence/history/undo snapshot cache, and no authoritative read was replaced.
Cmux No Hacky Sleeps ✅ Passed No covered TS/JS/shell/runtime-script changes; touched code uses only Swift presentation animation and test waits, with no fixed sleeps or polling added.
Cmux Algorithmic Complexity ✅ Passed PASS: ChatTranscriptTableView’s new focusOutbound does one firstIndex over a capped transcript (maxWindowCount 600); no nested scans/sorts or batch rescans were added.
Cmux Swift Concurrency ✅ Passed Changed production Swift code adds focus plumbing and UI logs only; no new Combine, DispatchQueue, completion-handler, or unsafe Task patterns were introduced.
Cmux Swift @Concurrent ✅ Passed No touched Swift file adds @concurrent or nonisolated async work; async work stays UI-bound or actor-isolated, and new helpers are synchronous.
Cmux Swift File And Package Boundaries ✅ Passed PASS: Changes stay in package/UI glue and tests; no new oversized production file, no >250-line growth in 800+ file, and no app-target boundary violation.
Cmux Swiftpm Lockfiles ✅ Passed No cmux-owned .gitignore ignores Package.resolved; remote package pins have matching local Package.resolved files, and cmux.xcodeproj includes the root lockfile.
Cmux Swift Logging ✅ Passed PASS: new diagnostics use MobileDebugLog.anchormux/cmuxDebugLog, both DEBUG-gated; no raw print/debugPrint/dump/NSLog or file-scoped Logger added in runtime code.
Cmux Full Internationalization ✅ Passed The only new user-facing copy is a localized accessibility label, and its xcstrings entry includes translated en/ja values; other touched files are behavior, tests, or debug logs.
Cmux Swiftui State Layout ✅ Passed Touched views keep snapshot rows/closures and use @Observable store state; no new ObservableObject/@published, GeometryReader, or render-time state writes were introduced.
Cmux Architecture Rethink ✅ Passed PASS: the new focus ID is a one-way, clearly owned signal; no added sleeps, locks, polling, or split lifecycle ownership beyond required UIKit bridge code.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PASS: The PR only touches chat views, tests, and insets; no NSWindow/NSPanel/WindowGroup code or cmuxAuxiliaryWindowIdentifiers changes, so the auxiliary-window shortcut rule isn’t triggered.
Cmux Source Artifacts ✅ Passed All changed paths are source/test files; none are in artifact, cache, temp, build, log, or screenshot/recording directories.
Cmux No Test Or Debug Seam In Production Source ✅ Passed No new test-only/debug-only seam appears in production Sources; the changes are product focus plumbing, and existing DEBUG helpers remain unchanged.
Cmux No Ambient Global State ✅ Passed No new ambient globals/singletons; focus state is owned by ChatConversationStore and the new row-id helpers live on an existing row enum.
✨ 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 feat-ios-gui-sending-ux

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 Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR replaces the typing indicator dots+timer bubble with a compact animated bloom (ChatThinkingBloomView), adds end-to-end send-focus plumbing that scrolls the just-sent outbound bubble near the top of the transcript, and fixes positioning under iOS 26's floating navigation chrome and the keyboard.

  • Send-focus signal: latestOutboundFocusRowID is threaded from ChatConversationStore through ChatScreen → ChatTranscriptListView → ChatTranscriptTableView, where a coordinator-owned activeOutboundFocusRowID drives focusOutbound on both the initial send and subsequent layout-change events; pending-to-message and pending-to-terminal-command transfers are handled atomically and covered by new unit tests.
  • iOS 26 chrome: topChromeOverlayInset() computes a top-chrome reservation applied via applyTranscriptViewportInsets, with focusOutbound reading topChromeOverlayInset to land the row below the pill chrome; the value uses hardcoded +72/floor-132 constants rather than the navigation bar's actual measured frame.
  • Diagnostic logging: MobileChatEventSource gains always-on MobileDebugLog.anchormux traces for history, subscription, and send milestones; TerminalController+MobileChat gains #if DEBUG-gated cmuxDebugLog traces at send-path checkpoints.

Confidence Score: 4/5

The send-focus happy path works correctly, but a scroll-position jitter during keyboard animations after send remains unremedied from the previous review round, and activeOutboundFocusRowID is not cleared by user-initiated scrolling.

The core store logic and reconciliation transfers are correct and well-tested. The known edge cases from prior reviews — anchor restoration overwriting an active focus during spacer-height-change reloads, and focus re-snapping on every keyboard/inset transition after send — are still present in ChatTranscriptTableView and make positioning less reliable during keyboard animation.

ChatTranscriptTableView.swift — the update() reload path and handleLayoutChange interaction around spacer-height changes; ChatKeyboardTrackingViewController.swift — hardcoded chrome-height constants.

Important Files Changed

Filename Overview
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift Adds latestOutboundFocusRowID to carry the focus signal from pending send through reconciliation into the echoed message or terminal command row; logic is correct and both reconcile paths transfer the ID atomically.
Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift Factors row ID construction into public static helpers so ChatConversationStore can build matching IDs without duplicating format strings; straightforward refactor with no logic change.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift Core focus logic: activeOutboundFocusRowID, outboundFocusSpacer, and focusOutbound are all added here. Contains known edge-case bugs around spacer-height-change reloads restoring the old anchor and activeOutboundFocusRowID not being cleared on user-initiated scroll (both flagged in prior review rounds and not yet addressed).
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift Adds topChromeOverlayInset() for iOS 26 floating chrome and threads it into updateTranscriptViewportInsets; the constant max(132, ceil(safeTop + 72)) is a hardcoded approximation that may not match the real chrome height on all devices and orientations.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift Moves presentationFrameInWindow() from private+#if DEBUG to internal and production scope; the only existing caller remains inside #if DEBUG, leaving the method as dead module-internal API in release builds.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift Replaces dots+timer bubble with ChatThinkingBloomView using SwiftUI Canvas; reduce-motion path is handled and accessibility label is preserved.
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift Adds diagnostic MobileDebugLog.anchormux traces for history, event subscription, and send paths; IDs are redacted to 8 chars and logging goes through the CmuxMobileDiagnostics unified-logging facade.
Sources/TerminalController+MobileChat.swift Adds #if DEBUG-only cmuxDebugLog traces for send path milestones; all new logging is correctly gated and does not alter production behavior.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift One-line threading of latestOutboundFocusRowID into ChatTranscriptListView; no issues.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift Passes outboundFocusRowID through to ChatTranscriptTableView; straightforward prop threading.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift Adds @State private var draft and wires it to ChatScreen so draft text survives demo-stack rebuilds; clean change.
Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift Adds unit tests for pending-to-echo focus transfer and terminal command reconcile; one assertion hardcodes "pending-local-1" instead of using the static helper, but the logic is otherwise solid.
ios/cmuxUITests/cmuxUITests.swift New UI test verifies the sent bubble lands near the top with top-chrome and bottom-gap constraints after keyboard settles; consistent with the existing test harness.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant U as User
    participant CS as ChatConversationStore
    participant CV as ChatScreen/ListV
    participant TV as ChatTranscriptTableView Coordinator
    participant UT as ChatTranscriptUITableView

    U->>CS: send(text:)
    CS->>CS: "latestOutboundFocusRowID = pending-X"
    CS-->>CV: Observable change propagates
    CV->>TV: update(outboundFocusRowID: pending-X)
    TV->>TV: "shouldFocusOutbound = true, activeOutboundFocusRowID = pending-X"
    TV->>TV: makeItems(spacerHeight: viewport x 1.05)
    TV->>UT: reloadData() + layoutIfNeeded()
    UT-->>TV: handleLayoutChange → focusOutbound(pending-X)
    TV->>UT: setContentOffset(nearTop, animated: false)

    Note over CS,TV: Agent responds — reconciliation

    CS->>CS: "latestOutboundFocusRowID = msg-Y"
    CS-->>CV: Observable change propagates
    CV->>TV: update(outboundFocusRowID: msg-Y)
    TV->>TV: "shouldFocusOutbound = true, activeOutboundFocusRowID = msg-Y"
    TV->>UT: reloadData() + layoutIfNeeded()
    TV->>UT: focusOutbound(msg-Y, animated: false)

    Note over TV,UT: Keyboard animation — inset changes
    UT-->>TV: handleLayoutChange (isViewportInsetsExternallyDriven)
    TV->>UT: focusOutbound(activeOutboundFocusRowID, animated: false)
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"}}}%%
sequenceDiagram
    participant U as User
    participant CS as ChatConversationStore
    participant CV as ChatScreen/ListV
    participant TV as ChatTranscriptTableView Coordinator
    participant UT as ChatTranscriptUITableView

    U->>CS: send(text:)
    CS->>CS: "latestOutboundFocusRowID = pending-X"
    CS-->>CV: Observable change propagates
    CV->>TV: update(outboundFocusRowID: pending-X)
    TV->>TV: "shouldFocusOutbound = true, activeOutboundFocusRowID = pending-X"
    TV->>TV: makeItems(spacerHeight: viewport x 1.05)
    TV->>UT: reloadData() + layoutIfNeeded()
    UT-->>TV: handleLayoutChange → focusOutbound(pending-X)
    TV->>UT: setContentOffset(nearTop, animated: false)

    Note over CS,TV: Agent responds — reconciliation

    CS->>CS: "latestOutboundFocusRowID = msg-Y"
    CS-->>CV: Observable change propagates
    CV->>TV: update(outboundFocusRowID: msg-Y)
    TV->>TV: "shouldFocusOutbound = true, activeOutboundFocusRowID = msg-Y"
    TV->>UT: reloadData() + layoutIfNeeded()
    TV->>UT: focusOutbound(msg-Y, animated: false)

    Note over TV,UT: Keyboard animation — inset changes
    UT-->>TV: handleLayoutChange (isViewportInsetsExternallyDriven)
    TV->>UT: focusOutbound(activeOutboundFocusRowID, animated: false)
Loading

Reviews (5): Last reviewed commit: "Give iOS chat thinking indicator more ch..." | Re-trigger Greptile

Comment on lines +122 to +131
let shouldScrollToBottom = scrollToBottomRequest != lastScrollToBottomRequest
let shouldFocusOutbound = outboundFocusRowID != nil
&& outboundFocusRowID != lastOutboundFocusRowID
lastScrollToBottomRequest = scrollToBottomRequest
if shouldScrollToBottom {
activeOutboundFocusRowID = nil
}
if shouldFocusOutbound {
lastOutboundFocusRowID = outboundFocusRowID
activeOutboundFocusRowID = outboundFocusRowID

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.

P1 Stale focus jump when chat screen is re-entered

Coordinator.lastOutboundFocusRowID resets to nil every time the UIViewRepresentable is recreated (e.g., a NavigationStack pop/push back to the same chat). ChatConversationStore.latestOutboundFocusRowID is never cleared, so the first update call into the fresh coordinator evaluates shouldFocusOutbound = true and scrolls to the previously sent message — overriding normal scroll restoration and disorienting the user who expected to land where they left off.

A minimal fix is to treat a coordinator's initial call specially: on the very first update, set lastOutboundFocusRowID = outboundFocusRowID without acting on it (consuming the stale signal), or add a dedicated reset path in ChatConversationStore that clears latestOutboundFocusRowID when the surface attaches.

@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: 2

🤖 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
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift`:
- Around line 64-78: The ChatTypingIndicatorView currently still flips rotating
to true on appear even when accessibilityReduceMotion is enabled, causing the
arc to snap into its rotated state. Update the onAppear logic in
ChatTypingIndicatorView so rotating only becomes true when reduceMotion is
false, or otherwise keep the initial pose unchanged while motion reduction is
active. Keep the existing animation setup and use the rotating state plus
reduceMotion check to preserve the still appearance without the first-render
jump.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`:
- Around line 647-650: The focus-row handling in ChatConversationStore is only
remapping the pending-to-echo case, so authoritative removals can leave
latestOutboundFocusRowID pointing at a deleted row. Centralize the focus
transition/clear logic around the relevant ChatConversationStore paths,
especially discard(pendingID:), reset, and reconcileTerminalPending(against:),
and ensure they clear or remap the focus when the target is removed. Add
coverage in tests for each authoritative removal path so the transcript fails
closed when the focused row disappears.
🪄 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: f5e854bb-a931-44a0-b0c1-4cddfd950907

📥 Commits

Reviewing files that changed from the base of the PR and between 0eb308e and bdacba8.

📒 Files selected for processing (9)
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift
  • Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptListView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTypingIndicatorView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
  • ios/cmuxUITests/cmuxUITests.swift

Comment on lines +647 to +650
let removedRowID = ChatTranscriptRow.pendingOutboundRowID(for: removed.id)
if latestOutboundFocusRowID == removedRowID {
latestOutboundFocusRowID = ChatTranscriptRow.messageRowID(for: message.id)
}

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 | 🟡 Minor | ⚡ Quick win

Clear the focus row on every authoritative removal path.

This only remaps the focus id for the pending→echo case. discard(pendingID:), .reset, and reconcileTerminalPending(against:) can still remove the focused row without clearing/remapping latestOutboundFocusRowID, which leaves the iOS transcript in a stale focused state with no valid target row. Please centralize the focus-row transition/clear logic and cover those paths in tests. As per path instructions, derived focus signals must be updated from authoritative state transitions and fail closed when the reliable signal disappears.

🤖 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/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`
around lines 647 - 650, The focus-row handling in ChatConversationStore is only
remapping the pending-to-echo case, so authoritative removals can leave
latestOutboundFocusRowID pointing at a deleted row. Centralize the focus
transition/clear logic around the relevant ChatConversationStore paths,
especially discard(pendingID:), reset, and reconcileTerminalPending(against:),
and ensure they clear or remap the focus when the target is removed. Add
coverage in tests for each authoritative removal path so the transcript fails
closed when the focused row disappears.

Source: Path instructions

@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: 1

🤖 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
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 304-314: The visible-top calculation in ChatTranscriptTableView is
mixing coordinate spaces by treating
ChatTranscriptUITableView.topChromeOverlayInset like a window-space Y value.
Update the logic around the targetY computation to keep the chrome inset in
table-frame coordinates, using the current table frame (tableFrameInWindow) plus
the larger of adjustedContentInset.top and topChromeOverlayInset, so the
outbound row cannot be focused under the floating chrome.
🪄 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: b5ba3e7f-7089-4103-849f-75519a6a1544

📥 Commits

Reviewing files that changed from the base of the PR and between bdacba8 and 765217e.

📒 Files selected for processing (4)
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift
  • ios/cmuxUITests/cmuxUITests.swift

Comment on lines +304 to +314
let tableFrameInWindow = tableView.window.map { tableView.convert(tableView.bounds, to: $0) }
?? tableView.frame
let presentationMinY = (tableView as? ChatTranscriptUITableView)?
.presentationFrameInWindow()?
.minY
?? tableFrameInWindow.minY
let insetTopY = tableFrameInWindow.minY + tableView.adjustedContentInset.top
let chromeTopY = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0
let visibleTopY = max(insetTopY, chromeTopY)
let targetY = clampedOffsetY(
rect.minY + presentationMinY - visibleTopY - visibleTopPadding,

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

Keep the chrome inset in the same coordinate space as the table frame.

Line 312 compares topChromeOverlayInset as if it were a window-space Y, but this value is plumbed through applyTranscriptViewportInsets(...) as a content inset. When the transcript table itself is shifted in window coordinates, visibleTopY becomes too small and the outbound row can still focus under the floating chrome. Compute the visible top from the table’s current frame plus the larger inset instead.

Proposed fix
             let visibleTopPadding: CGFloat = 18
             let tableFrameInWindow = tableView.window.map { tableView.convert(tableView.bounds, to: $0) }
                 ?? tableView.frame
             let presentationMinY = (tableView as? ChatTranscriptUITableView)?
                 .presentationFrameInWindow()?
                 .minY
                 ?? tableFrameInWindow.minY
-            let insetTopY = tableFrameInWindow.minY + tableView.adjustedContentInset.top
-            let chromeTopY = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0
-            let visibleTopY = max(insetTopY, chromeTopY)
+            let chromeTopInset = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0
+            let visibleTopY = presentationMinY + max(tableView.adjustedContentInset.top, chromeTopInset)
             let targetY = clampedOffsetY(
                 rect.minY + presentationMinY - visibleTopY - visibleTopPadding,
                 in: tableView
             )
📝 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 tableFrameInWindow = tableView.window.map { tableView.convert(tableView.bounds, to: $0) }
?? tableView.frame
let presentationMinY = (tableView as? ChatTranscriptUITableView)?
.presentationFrameInWindow()?
.minY
?? tableFrameInWindow.minY
let insetTopY = tableFrameInWindow.minY + tableView.adjustedContentInset.top
let chromeTopY = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0
let visibleTopY = max(insetTopY, chromeTopY)
let targetY = clampedOffsetY(
rect.minY + presentationMinY - visibleTopY - visibleTopPadding,
let visibleTopPadding: CGFloat = 18
let tableFrameInWindow = tableView.window.map { tableView.convert(tableView.bounds, to: $0) }
?? tableView.frame
let presentationMinY = (tableView as? ChatTranscriptUITableView)?
.presentationFrameInWindow()?
.minY
?? tableFrameInWindow.minY
let chromeTopInset = (tableView as? ChatTranscriptUITableView)?.topChromeOverlayInset ?? 0
let visibleTopY = presentationMinY + max(tableView.adjustedContentInset.top, chromeTopInset)
let targetY = clampedOffsetY(
rect.minY + presentationMinY - visibleTopY - visibleTopPadding,
🤖 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 304 - 314, The visible-top calculation in ChatTranscriptTableView
is mixing coordinate spaces by treating
ChatTranscriptUITableView.topChromeOverlayInset like a window-space Y value.
Update the logic around the targetY computation to keep the chrome inset in
table-frame coordinates, using the current table frame (tableFrameInWindow) plus
the larger of adjustedContentInset.top and topChromeOverlayInset, so the
outbound row cannot be focused under the floating chrome.

@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/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift (1)

647-650: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Don't promote heuristic reconcile matches into authoritative focus targets.

latestOutboundFocusRowID is now correctness-critical UI state, but these branches still inherit reconcilePending(against:) / reconcileTerminalPending(against:)'s best-effort text matching. A false-positive reconcile now focuses the wrong committed row instead of failing closed. Please gate the handoff on a structured pending→echo mapping from the send path, or clear focus when only a heuristic match is available. As per path instructions, "treat the focus target as correctness-critical UI state" with "an authoritative, structured source of truth" and "Prefer fail-closed behavior (don’t attempt focus) when the reliable mapping isn’t available."

Also applies to: 723-727

🤖 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/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`
around lines 647 - 650, The focus handoff in ChatConversationStore is still
promoting heuristic reconcile matches into latestOutboundFocusRowID, which can
point UI focus at the wrong committed message. Update the reconcile paths around
the pending→echo logic (including the removedRowID checks in the affected
branches and the later matching branch) so they only assign
latestOutboundFocusRowID when there is an authoritative structured mapping from
the send path, not when reconcilePending(against:) or
reconcileTerminalPending(against:) only produced a best-effort text match. If
that reliable mapping is unavailable, clear or leave focus unset rather than
guessing.

Source: Path instructions

🤖 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/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`:
- Around line 647-650: The focus handoff in ChatConversationStore is still
promoting heuristic reconcile matches into latestOutboundFocusRowID, which can
point UI focus at the wrong committed message. Update the reconcile paths around
the pending→echo logic (including the removedRowID checks in the affected
branches and the later matching branch) so they only assign
latestOutboundFocusRowID when there is an authoritative structured mapping from
the send path, not when reconcilePending(against:) or
reconcileTerminalPending(against:) only produced a best-effort text match. If
that reliable mapping is unavailable, clear or leave focus unset rather than
guessing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c3cc3c7a-213e-4ce8-9eee-3ecf838b17ab

📥 Commits

Reviewing files that changed from the base of the PR and between 765217e and f2a9edf.

📒 Files selected for processing (5)
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
  • Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatTranscriptRow.swift
  • Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
  • Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift
  • Sources/TerminalController+MobileChat.swift

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Closing: the v1 mobile chat surface this polishes is being removed wholesale in #7660 ahead of a from-scratch GUI rebuild. The uncommitted local edits from this branch's worktree are preserved at /tmp/fable-feat-scrap-agent-gui/sending-ux-uncommitted.patch.

This branch was successfully deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant