Skip to content

Fix iOS chat keyboard scroll edge bleed - #7070

Closed
azooz2003-bit wants to merge 44 commits into
mainfrom
feat-ios-keyboard-tracking
Closed

azooz2003-bit wants to merge 44 commits into
mainfrom
feat-ios-keyboard-tracking

Conversation

@azooz2003-bit

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

Copy link
Copy Markdown
Collaborator

Fixes the iOS chat keyboard scroll-edge bleed shown when the keyboard is active.\n\nChanged:\n- Adds regression coverage that exposes whether the iOS 26 top scroll-edge effect is forced while the chat keyboard viewport is clipped.\n- Leaves the transcript top edge on UIKit automatic during keyboard motion and while the keyboard is up, while keeping the bottom edge effect for the composer edge.\n\nVerified:\n- swift test --package-path Packages/iOS/CmuxAgentChatUI\n- xcodebuild test -workspace ios/cmux.xcworkspace -scheme cmux-ios -destination 'platform=iOS Simulator,id=C9D82663-886D-4EBD-92A0-96CF38A42FF3' -derivedDataPath /tmp/cmux-ios-scroll-edge -only-testing:cmuxUITests/cmuxUITests/testAgentChatTranscriptFrameMovesUpWithKeyboardAcrossScrollPositions\n- ./scripts/reload-cloud.sh --tag edge (fell back to local ./scripts/reload.sh --tag edge; succeeded)\n- ./ios/scripts/reload.sh --tag edge --simulator 'iPhone 17' (succeeded)\n\nDogfood:\n- macOS tag: http://127.0.0.1:17320/edge\n- iOS tag: edge\n- Physical iPhone reload was unavailable: cloud signing credentials missing, and device Aziz reported unavailable.


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


Summary by cubic

Fixes the iOS chat scroll-edge “bleed” by moving chat to a UIKit keyboard host that tracks the keyboard frame and animation. The composer now stays pinned above the keyboard and the top overscroll is suppressed while typing on iOS 26.

  • Bug Fixes

    • Added a ChatKeyboardTrackingViewController that drives insets and offsets from UIResponder.keyboardWillChangeFrame, keeping content stable during animations.
    • Disabled the top scroll-edge effect while the keyboard is active; kept a soft bottom edge for the composer (iOS 26 only).
    • Treat floating/split keyboards as visible but non-docking; docked keyboards lift the composer; taps on the scroll-to-bottom button no longer dismiss the keyboard.
    • Added regression tests: unit tests for keyboard math and XCUITests using debug chat previews to validate viewport and edge behavior.
  • Refactors

    • Introduced CmuxMobileSupport (keyboard transition/visibility, scroll viewport snapshot, shared composer controls) and adopted it in CmuxAgentChatUI and CmuxMobileTerminal.
    • Split the transcript into ChatTranscriptUITableView for deterministic insets; disabled nested SwiftUI keyboard avoidance inside chat.
    • Added ChatAccessoryShortcut and a “Shared Shortcuts” settings scope reused from the terminal.
    • New DEBUG-only chat previews toggled by CMUX_UITEST_AGENT_CHAT_PREVIEW and CMUX_UITEST_AGENT_CHAT_INLINE_PREVIEW for UI testing.

Written for commit 676631b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added configurable chat composer shortcuts, including support for custom actions and quick access to paste/dismiss keyboard.
    • Improved mobile composer and terminal UI with shared glass-style controls, better attachment handling, and updated send/stop/mic buttons.
    • Added new chat preview/demo modes for standalone and inline workspace layouts.
  • Bug Fixes

    • Improved keyboard tracking so chat and terminal views stay aligned during show/hide transitions and scrolling.
    • Fixed attachment removal and keyboard focus behavior in the chat composer.
  • Tests

    • Added broader UI and keyboard-behavior coverage for chat, terminal, and preview flows.

@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 Building Building Preview, Comment Jun 29, 2026 11:27pm
cmux-staging Building Building Preview, Comment Jun 29, 2026 11:27pm

@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a configurable accessory shortcut row to the iOS chat composer (ChatAccessoryShortcut model, updated ChatAccessoryChipRow), introduces a ChatKeyboardTrackingViewController that animates the transcript and composer in response to keyboard frame changes, refactors GhosttySurfaceView to use a display-link-driven keyboard height animation via MobileKeyboardTransition, and publishes keyboard/composer primitives from CmuxMobileSupport. Terminal toolbar shortcuts are bridged into the agent chat accessory row via WorkspaceChatPane, and extensive UI tests for keyboard-tracking geometry invariants are added.

Changes

CmuxMobileSupport: New Public Keyboard/Composer Primitives

Layer / File(s) Summary
Keyboard geometry value types
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift, MobileKeyboardReservation.swift, MobileKeyboardVisibility.swift, MobileScrollViewportSnapshot.swift
Adds MobileKeyboardTransition (normalizes UIKit notification into frame + animation timing, overlap/visibility helpers, animate method), MobileKeyboardReservation/MobileKeyboardVisibility (geometric intersection structs), and MobileScrollViewportSnapshot (captures visible bottom Y + wasAtBottom, computes restoredOffsetY after inset changes).
Shared composer UI components
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift, MobileComposerIconButton.swift, MobileComposerIconLabel.swift, View+MobileGlass.swift
Adds MobileComposerFieldContainer (generic glass-styled field+trailing HStack shell), MobileComposerIconButton/MobileComposerIconLabel (circular glass icon button/label with pulse, active state, accessibility), and makes View+MobileGlass helpers public.
Promote dictation types to public
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift, ComposerDictationTextMerger.swift, Tests/.../ComposerDictationTests.swift
Changes ComposerDictationController, ComposerDictationState, and ComposerDictationTextMerger from internal to public; updates test import to CmuxMobileSupport.
UITest environment config
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift, UITestConfig.swift, Tests/.../UITestConfigTests.swift
Adds UITestEnvironmentConfig struct with agentChatPreviewEnabled/agentChatInlinePreviewEnabled DEBUG-gated flags; wires into UITestConfig static accessors; adds corresponding tests.
Keyboard geometry unit tests
Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift
Tests MobileKeyboardReservation, MobileKeyboardVisibility, and MobileScrollViewportSnapshot.restoredOffsetY across docked/floating keyboards and bottom/middle/top scroll positions.
Package manifests
Packages/iOS/CmuxAgentChatUI/Package.swift, Packages/iOS/CmuxMobileTerminal/Package.swift
Adds CmuxMobileSupport as a local dependency in both package manifests.

Chat Composer: Accessory Shortcut Row

Layer / File(s) Summary
ChatAccessoryShortcut model
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryShortcut.swift, ChatAccessoryShortcutSemanticAction.swift
Adds ChatAccessoryShortcut (Identifiable with title/icon/tint/semanticAction, perform(), replacingAction(_:)) and ChatAccessoryShortcutSemanticAction enum (paste, dismissKeyboard).
Configurable ChatAccessoryChipRow
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryChipRow.swift
Refactors chip row to accept leadingShortcuts/shortcuts arrays, injects Stop chip when agent is working, falls back to legacy Esc/Ctrl-C/Terminal chips when arrays are empty, and renders via chip(_:)/chipContent(_:) with iOS glass styling.
ChatComposerView + ChatScreen shortcut wiring
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift, ChatComposerMaterialBackground.swift, ChatComposerPasteboard.swift, Screen/ChatScreen.swift
Adds accessoryLeadingShortcuts/accessoryShortcuts parameters to ChatComposerView and ChatScreen, adds iOS FocusState/dictation/paste/keyboard-dismiss helpers, mic button, updated send button visuals, and iOS pasteboard attachment helpers. ChatScreen.body delegates to chatLayout which wraps iOS in ChatKeyboardTrackingContainer.

iOS Chat Keyboard Tracking ViewController

Layer / File(s) Summary
ChatKeyboardTrackingViewController core
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift, ChatKeyboardNotificationToken.swift, ChatKeyboardTrackedRoot.swift
Adds ChatKeyboardTrackingViewController (generic UIViewController hosting transcript/composer SwiftUI views), keyboard transition animation (duration computation, transform animation, geometry constants), scroll edge effect wiring, and tap-to-dismiss gesture.
Container + debug extension
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingContainer.swift, ChatKeyboardTrackingViewController+Debug.swift
Adds ChatKeyboardTrackingContainer (UIViewControllerRepresentable bridge to the VC) and a DEBUG extension computing animation progress, layout-guide overlap, presentation delta, and per-table frame providers.
ChatTranscriptUITableView + ChatTranscriptTableView
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift, ChatTranscriptTableView.swift, ChatTranscriptListView.swift
Extracts ChatTranscriptUITableView with keyboardViewportSnapshot()/restoreKeyboardViewport(_:)/applyComposerOverlayBottomInset(_:) APIs; updates ChatTranscriptTableView Coordinator to use viewport restoration instead of NotificationCenter keyboard events; adds DEBUG diagnostic accessibility value.
DEBUG autofocus bridge
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerDebugAutofocusBridge.swift
Adds ChatComposerDebugAutofocusBridge (DEBUG+iOS UIViewRepresentable) that reads environment variables to schedule delayed focus/dismiss/refocus of the chat composer for UI testing.

Terminal Surface: Keyboard Height Animation Refactor

Layer / File(s) Summary
GhosttySurfaceView keyboard animation
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, TerminalKeyboardHeightAnimation.swift
Replaces separate show/hide keyboard observers with handleKeyboardWillChangeFrame using MobileKeyboardTransition, adds keyboardVisible boolean, introduces TerminalKeyboardHeightAnimation display-link-driven height animation (start/stop/advance/apply), and refactors layoutRenderedTerminalForCurrentViewport to derive height from live dock presentation frame.
Public TerminalInputAccessoryAction API + reorderItems
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, TerminalAccessoryConfiguration.swift
Makes TerminalInputAccessoryAction.title/accessibilityIdentifier/accessibilityLabel/symbolName/isModifier public; adds reorderItems(_:) to TerminalAccessoryConfiguration.
TerminalArrowNubView extraction
Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalArrowNubView.swift, TerminalArrowNubDirection.swift
Extracts TerminalArrowNubView (pan-driven direction, dead-zone, haptics, async repeat via TerminalArrowRepeatService) and TerminalArrowNubDirection enum into separate files.

MobileShellUI: Chat Pane Shortcuts and Demo Screen

Layer / File(s) Summary
Terminal shortcut scope + scoped settings
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalAccessoryChatCompatibility.swift, TerminalShortcutsSettingsView.swift, ios/cmux/Resources/Localizable.xcstrings
Adds TerminalShortcutsSettingsScope with isSupportedInAgentChat filtering; scopes TerminalShortcutsSettingsView to show/reorder only visible items per scope; adds localized chat shortcut title/footer strings.
WorkspaceChatPane shortcut generation
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift
Builds chatAccessoryLeadingShortcuts/chatAccessoryShortcuts from enabled toolbar items, handles semantic actions (dismiss keyboard, paste forwarding), submits terminal raw input asynchronously, and shows TerminalShortcutsSettingsView(scope: .agentChat) sheet.
TerminalComposerView adopt shared components
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
Replaces manual attach/mic button implementations with MobileComposerIconButton; moves send control into MobileComposerFieldContainer trailing closure; removes now-redundant local glass/padding modifiers.
AgentChatDemoScreen styles + root view routing
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift, AgentChatDemoScreenStyle.swift, CMUXMobileRootView+AgentChatPreview.swift, CMUXMobileRootView.swift
Adds AgentChatDemoScreenStyle (standalone/inlineWorkspace), style-driven chrome in AgentChatDemoScreen, shouldShowAgentChatDemoPreview/agentChatDemoPreview extension on CMUXMobileRootView, and inserts the agent chat preview branch before terminal layout preview in rootContent.

UI Tests, Swift Concurrency Fixes, and Script Update

Layer / File(s) Summary
Agent chat keyboard tracking UI tests
ios/cmuxUITests/cmuxUITests.swift
Adds testAgentChatTranscriptFrameMovesUpWithKeyboardAcrossScrollPositions, multiple video-evidence keyboard transition tests, ChatTranscriptMetrics parsing, timed sampling/assertion helpers, and settleChatPreviewKeyboardDown/dismiss interaction utilities.
Terminal render bottom UI test
ios/cmuxUITests/cmuxUITests.swift
Adds testTerminalPreviewRenderBottomTracksSyntheticKeyboardViewport and extends assertDockCoherent with assertTerminalRenderBottomAttachedToViewport.
Swift 6 concurrency fixes + policy script
ios/cmux/CmuxAppDelegate.swift, ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift, MobileFeedbackStamp+Current.swift, scripts/check-package-resolved-policy.py, Packages/iOS/CmuxMobileShellUI/.../WorkspaceDetailView.swift
Annotates CmuxAppDelegate with @preconcurrency, isolates handleRemoteDismiss to @MainActor, adds @MainActor to analytics init and feedback stamp properties, and updates check-package-resolved-policy.py to build previous-state manifests/graph from merge-base for accurate lockfile diff detection.

Sequence Diagram(s)

sequenceDiagram
  participant UIKit as UIKit Keyboard Notification
  participant VC as ChatKeyboardTrackingViewController
  participant TranscriptTable as ChatTranscriptUITableView
  participant Composer as Composer UIHostingController

  UIKit->>VC: keyboardWillChangeFrame
  VC->>VC: compute effective duration (in-flight vs. remaining distance)
  VC->>Composer: UIView.animate → translateY by keyboard overlap
  VC->>TranscriptTable: applyComposerOverlayBottomInset(_:)
  TranscriptTable->>TranscriptTable: snapshot viewport → update contentInset.bottom → restoreKeyboardViewport
  VC->>VC: updateEdgeEffects(overlap:)
  VC->>VC: completeKeyboardTransition → pin final geometry
Loading
sequenceDiagram
  participant Host as WorkspaceChatPane
  participant ChatScreen as ChatScreen
  participant ChipRow as ChatAccessoryChipRow
  participant TerminalSurface as GhosttySurfaceView

  Host->>Host: chatAccessoryShortcuts(for: conversation)
  Host->>ChatScreen: accessoryShortcuts: [ChatAccessoryShortcut]
  ChatScreen->>ChipRow: shortcuts: + leadingShortcuts:
  ChipRow-->>Host: shortcut.perform() → performChatAccessoryAction
  Host->>TerminalSurface: sendSessionTerminalInput (UTF-8 encoded)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • manaflow-ai/cmux#6197: Introduces ComposerDictationController and its dictation text-merge/state behavior, which this PR promotes to public and integrates into the chat composer mic button.
  • manaflow-ai/cmux#6488: Both PRs modify CMUXMobileRootView.swift around the rootContent preview branch and the workspaceListLayoutPreview debug gating.
  • manaflow-ai/cmux#6723: Directly related — adds MobileKeyboardTransition/MobileScrollViewportSnapshot and refactors ChatTranscriptTableView keyboard handling, which this PR builds upon and extends with ChatKeyboardTrackingViewController.

Suggested reviewers

  • lawrencecchen

🐇 Hoppity hop, the keyboard did soar,
Transcripts float up — they never ignore!
Glass chips align with shortcuts galore,
Dictation now public, a mic to adore,
The rabbit typed fast and tests kept the score! 🎉


Important

Pre-merge checks failed

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

❌ Failed checks (3 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error FAIL: updateTranscriptOverlayBottomInset() calls recursive trackedTranscriptTables(in:) on every layout/keyboard tick, rescanning the full subview tree in a hot UI path. Cache the transcript table(s) once, or update them via a stored reference/delegate, so keyboard/layout updates don’t recurse through the entire view hierarchy.
Cmux Swift Logging ❌ Error BrowserPanel.swift adds NSLog in WKWebView failure/download handlers, which is production runtime logging and violates the Swift logging rule. Remove those NSLog calls or route them through the existing sanitized diagnostics path; keep runtime Swift free of ad hoc logging.
Cmux No Test Or Debug Seam In Production Source ❌ Error FAIL: production Sources/ add debug/test seams (ChatComposerDebugAutofocusBridge, transcript debug hooks, setKeyboardHeightForTesting) guarded by #if DEBUG. Move these helpers into Tests/ or a dedicated debug-support target, or expose only internal state for @testable tests; keep shipping Sources/ free of #if DEBUG/ForTesting hooks.
Docstring Coverage ⚠️ Warning Docstring coverage is 20.12% 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 matches the main change: an iOS chat keyboard scroll-edge bug fix.
Description check ✅ Passed The description covers the change and verification, but it omits the template's Demo Video, Review Trigger, and Checklist sections.
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 PASS: The diff keeps pure models nonisolated, marks UI/store coordinators @MainActor, and uses explicit MainActor hops before touching shared stores.
Cmux Swift Blocking Runtime ✅ Passed The only changed production Swift lines toggle iOS 26 scroll-edge styles; no blocking primitives or waits appear in the diff.
Cmux Browser Automation Off-Main ✅ Passed browser.wait/eval/screenshot/cookies/storage/download.wait are in socketWorkerMethods, main switch only keeps direct focus/show ops, and tests assert the routing.
Cmux Expensive Synchronous Load ✅ Passed No diffed production path adds a sync agent-history load; history fetching stays async in ChatConversationStore, and new view code only builds fixtures/settings state.
Cmux Cache Substitution Correctness ✅ Passed The viewport snapshot is transient UI state, refreshed each layout and has a cold fallback; no persistence/history/undo path trusts a stale cache.
Cmux No Hacky Sleeps ✅ Passed The only changed non-Swift script is deterministic git-based policy logic; no fixed sleeps, timers, polling, or wall-clock waits were added.
Cmux Swift Concurrency ✅ Passed New Task calls are confined to SwiftUI/UIKit callback boundaries, and the only lifecycle task is stored/cancelled; no new Combine, detached, or background queues were added.
Cmux Swift @Concurrent ✅ Passed No changed Swift file introduces invalid @concurrent usage; heavy async paths either hop off-main explicitly or are UI-bound and no @concurrent appears in the diff.
Cmux Swift File And Package Boundaries ✅ Passed Cohesive UI/controller package types were added; the 486/498-line files stay under the oversized-file threshold, and the 3.7k-line GhosttySurfaceView only grew by 202 lines.
Cmux Swiftpm Lockfiles ✅ Passed No package .gitignore ignores Package.resolved; the root Xcode Package.resolved is committed, and the new deps are local path-only so no new external pins.
Cmux User-Facing Error Privacy ✅ Passed No new user-facing error/recovery text leaks vendor/internal details; added strings stay generic, and the raw-detail path is DEBUG-only.
Cmux Full Internationalization ✅ Passed All new production UI strings use localized APIs, and every touched catalog key has en/ja entries; the only untranslated text is in DEBUG-only code.
Cmux Swiftui State Layout ✅ Passed The changed SwiftUI files use @Observable/@State patterns and onGeometryChange, with no new ObservableObject/@published or GeometryReader misuse and no store refs in lazy row subtrees.
Cmux Architecture Rethink ✅ Passed The new keyboard/controller split uses explicit value snapshots and platform callbacks; the only timing hooks are DEBUG/test-only sync, which the rule allows.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed No new NSWindow/WindowGroup/NSPanel/NSWindowController code or cmuxAuxiliaryWindowIdentifiers changes were added; the diff only adds view-level previews and a sheet.
Cmux Source Artifacts ✅ Passed Diff contains only source, config, tests, localization, and intentional assets; no tmp/log/build/DerivedData/screenshots/recordings/caches or scratch dirs.
Cmux No Ambient Global State ✅ Passed The diff only adds instance-scoped keyboard state/methods; no new file-scope funcs, mutable globals, or singleton/namespace-only APIs appear.
✨ 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-keyboard-tracking
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch feat-ios-keyboard-tracking

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 SwiftUI preference-key–based keyboard-dismiss region with a UIKit UIViewController–backed keyboard tracking container that lifts the entire chat viewport via a CGAffineTransform translation, matching the WhatsApp-style keyboard-following pattern. The core fix gates the iOS 26 top UIScrollEdgeEffect to .automatic (rather than .soft) while the keyboard is overlapping or animating, which prevents the scroll-edge bleed in the clipped transcript view.

  • New ChatKeyboardTrackingViewController manages composer height, transcript clip-view, UIScrollEdgeElementContainerInteraction for the bottom composer edge, and viewport-snapshot–based scroll-position preservation across keyboard transitions.
  • MobileScrollViewportSnapshot / MobileKeyboardTransition / MobileKeyboardReservation / MobileKeyboardVisibility are extracted as clean, injectable Sendable value types in CmuxMobileSupport, each individually unit-tested.
  • Comprehensive UI test regression (testAgentChatTranscriptFrameMovesUpWithKeyboardAcrossScrollPositions) validates three scroll-position variants (bottom, middle, top) using a new inline workspace AgentChatDemoScreen, along with a video-evidence capture test for animation continuity.

Confidence Score: 4/5

Safe to merge; the keyboard-tracking architecture is sound and well-tested across three scroll-position variants.

The two findings are both style-level: one is a direct ProcessInfo env-var read in a debug helper that bypasses the injectable UITestEnvironmentConfig introduced in the same PR, and the other is a large #if DEBUG block in ChatTranscriptUITableView.swift (accessibility override + debug stored properties) that follows a different isolation pattern than the dedicated +Debug file used for ChatKeyboardTrackingViewController. Neither affects production behavior or release builds.

ChatTranscriptUITableView.swift (mixed production/debug code) and the #if DEBUG applyDebugInitialScrollIfNeeded block in ChatTranscriptTableView.swift (direct ProcessInfo access).

Important Files Changed

Filename Overview
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift New UIViewController-based keyboard tracking container. Animates a UITransform translation on the content view, manages scroll-edge effects, and installs a UIScrollEdgeElementContainerInteraction for the composer. Logic is sound; one minor inconsistency in the debug helpers.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift New UITableView subclass extracted from ChatTranscriptTableView. Contains a large #if DEBUG block overriding accessibilityValue to expose scroll/keyboard metrics for UI tests — substantial test-seam infrastructure mixed into a production source file rather than a separate +Debug file.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift Refactored to delegate keyboard-viewport preservation to MobileScrollViewportSnapshot; removes all keyboard notification observers. Adds a #if DEBUG applyDebugInitialScrollIfNeeded that reads ProcessInfo env vars directly, inconsistent with the UITestEnvironmentConfig abstraction introduced in this same PR.
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift New value type capturing visible-bottom-Y before a keyboard inset change and restoring it afterwards. Clean Sendable struct with correct offset clamping.
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift New public Sendable struct normalizing UIKit keyboard notification payloads. Well-structured with correct animation-curve extraction.
Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift Adds accessory shortcuts to the inline workspace chat, wiring TerminalAccessoryConfiguration.shared into the chat composer. Reuses existing localization keys; shortcut filtering and paste handling look correct.
Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift Refactored to use ChatKeyboardTrackingContainer on iOS; removes old preference-key-based dismiss-region approach cleanly. New accessoryLeadingShortcuts/accessoryShortcuts pass-through API is straightforward.
scripts/check-package-resolved-policy.py Policy script updated to skip Package.resolved requirement for pure local-path dependency edits; correctly reads baseline manifest at the merge-base ref and only requires a lockfile diff when the graph has or had external URL dependencies.
ios/cmuxUITests/cmuxUITests.swift Extensive new regression test for keyboard scroll-edge tracking. Three scroll-position variants (bottom, middle, top) plus a video-evidence capture test and a synthetic-keyboard terminal test. Assertions are specific and meaningful.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant KB as UIKit Keyboard
    participant VC as ChatKeyboardTrackingViewController
    participant Anim as UIView.animate
    participant Clip as transcriptClipView
    participant Table as ChatTranscriptUITableView
    participant Edge as UIScrollEdgeEffect

    KB->>VC: keyboardWillChangeFrameNotification
    VC->>VC: compute overlap(in: view)
    VC->>Clip: pinAnimationToVisibleOverlap(startOverlap)
    VC->>VC: "isKeyboardAnimationActive = true"
    VC->>Anim: "transition.animate { applyKeyboardOverlap(target) }"
    Anim->>Clip: CGAffineTransform(translationX:0, y:-overlap)
    Anim->>Table: applyComposerOverlayBottomInset(composerHeight)
    Table->>Table: restoreKeyboardViewport(snapshot)
    Anim->>Edge: "topEdgeEffect.style = .automatic (suppressed)"
    Anim->>Edge: "bottomEdgeEffect.style = .soft"
    Anim-->>VC: "completion { finishKeyboardAnimation() }"
    VC->>VC: "isKeyboardAnimationActive = false"
    VC->>Edge: "topEdgeEffect.style = .soft (restored if overlap == 0)"
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 KB as UIKit Keyboard
    participant VC as ChatKeyboardTrackingViewController
    participant Anim as UIView.animate
    participant Clip as transcriptClipView
    participant Table as ChatTranscriptUITableView
    participant Edge as UIScrollEdgeEffect

    KB->>VC: keyboardWillChangeFrameNotification
    VC->>VC: compute overlap(in: view)
    VC->>Clip: pinAnimationToVisibleOverlap(startOverlap)
    VC->>VC: "isKeyboardAnimationActive = true"
    VC->>Anim: "transition.animate { applyKeyboardOverlap(target) }"
    Anim->>Clip: CGAffineTransform(translationX:0, y:-overlap)
    Anim->>Table: applyComposerOverlayBottomInset(composerHeight)
    Table->>Table: restoreKeyboardViewport(snapshot)
    Anim->>Edge: "topEdgeEffect.style = .automatic (suppressed)"
    Anim->>Edge: "bottomEdgeEffect.style = .soft"
    Anim-->>VC: "completion { finishKeyboardAnimation() }"
    VC->>VC: "isKeyboardAnimationActive = false"
    VC->>Edge: "topEdgeEffect.style = .soft (restored if overlap == 0)"
Loading

Reviews (1): Last reviewed commit: "Disable chat top scroll edge while keybo..." | Re-trigger Greptile

Comment on lines 284 to +296
min(max(offsetY, -tableView.adjustedContentInset.top), maxOffsetY(in: tableView))
}

@objc private func keyboardWillChangeFrame(_ notification: Notification) {
guard let tableView else { return }
keyboardWasAtBottom = isAtBottom.wrappedValue
|| distanceFromBottom(in: tableView) <= Self.atBottomThreshold
keyboardVisibleBottomY = visibleBottomY(in: tableView)
keyboardBottomAnchor = bottomVisibleAnchor(in: tableView)
keyboardAnimationDuration = Self.keyboardAnimationDuration(from: notification)
keyboardAnimationOptions = Self.keyboardAnimationOptions(from: notification)
shouldPreserveKeyboardViewport = true
}

@objc private func keyboardDidChangeFrame(_ notification: Notification) {
guard let tableView, shouldPreserveKeyboardViewport else { return }
tableView.layoutIfNeeded()
preserveViewportAfterLayout(in: tableView)
shouldPreserveKeyboardViewport = false
keyboardWasAtBottom = false
keyboardVisibleBottomY = nil
keyboardBottomAnchor = nil
keyboardAnimationDuration = 0
keyboardAnimationOptions = []
}

private static let atBottomThreshold: CGFloat = 40

private static func keyboardAnimationDuration(from notification: Notification) -> TimeInterval {
notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0
#if DEBUG
private func applyDebugInitialScrollIfNeeded(in tableView: UITableView) {
guard !didApplyDebugInitialScroll,
ProcessInfo.processInfo.environment["CMUX_UITEST_CHAT_INITIAL_SCROLL"] == "middle",
tableView.bounds.height > 0,
tableView.contentSize.height > tableView.bounds.height * 1.4
else {
return
}
didApplyDebugInitialScroll = true

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 applyDebugInitialScrollIfNeeded bypasses UITestEnvironmentConfig

This function reads ProcessInfo.processInfo.environment["CMUX_UITEST_CHAT_INITIAL_SCROLL"] directly while the same PR introduces UITestEnvironmentConfig specifically as an injectable abstraction for these env vars. The raw ProcessInfo access makes the guard untestable in isolation and creates an inconsistency: all other new env-var checks go through UITestEnvironmentConfig, but CMUX_UITEST_CHAT_INITIAL_SCROLL does not. Thread through UITestEnvironmentConfig(environment: ProcessInfo.processInfo.environment).chatInitialScroll (adding the new property) to stay consistent with the rest of the PR's established pattern.

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 +42 to +56
set { super.accessibilityValue = newValue }
}
#endif

override func layoutSubviews() {
let oldBoundsSize = lastBoundsSize
let oldContentSize = lastContentSize
let oldViewport = lastViewport
super.layoutSubviews()
lastBoundsSize = bounds.size
lastContentSize = contentSize
recordViewport()
#if DEBUG
updateDebugAccessibilityValue()
#endif

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 Test-seam infrastructure mixed into production source

ChatTranscriptUITableView.swift contains ~120 lines of #if DEBUG code — keyboardDebug* stored properties, an accessibilityValue override that returns a formatted debug string, and updateDebugAccessibilityValue() — all of which exist solely to expose scroll/keyboard metrics to UI tests through the accessibility system. This is a test-observable state accessor in a production Sources/ file with no production callers.

By contrast, the keyboard-tracking debug helpers are cleanly isolated in ChatKeyboardTrackingViewController+Debug.swift. Moving the #if DEBUG block in this file to a companion ChatTranscriptUITableView+Debug.swift would match that established pattern and keep the production type's surface clean. The accessibilityValue override in particular changes observable accessibility behavior in debug builds (VoiceOver would speak raw metric strings rather than the table's natural value).

Rule Used: Flag Swift files under a production Sources path (... (source)

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!

@azooz2003-bit

Copy link
Copy Markdown
Collaborator Author

Superseded by #7072, which carries only the focused scroll-edge fix on current main.

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

Caution

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

⚠️ Outside diff range comments (1)
Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift (1)

210-230: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Don't expose the sleep-based graceful stop as public API.

Line 210 widens stop() across packages, but the implementation still resolves .stopping via a fixed 2.5 s Task.sleep watchdog. That turns an internal timing-repair path into a public contract and lets new callers depend on delay-based state recovery instead of an explicit completion signal. Keep stop() internal or refactor it to a signal-driven async API before widening it. As per coding guidelines, "Do not use ... Task.sleep ... in shipped Swift code" and the **/*.swift path instruction says to apply the cmux custom Swift lint rules in .github/review-bot-rules/ as the source of truth.

🤖 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/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift`
around lines 210 - 230, The public `stop()` API in `ComposerDictationController`
is exposing an internal timing-based cleanup path that relies on `Task.sleep`,
which should not be part of shipped Swift behavior. Keep `stop()` internal or
change it to an explicit signal-driven async completion flow, and remove the
sleep-based watchdog from the `.stopping` recovery path so callers do not depend
on fixed-delay state resolution.

Sources: Coding guidelines, 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.

Inline comments:
In `@ios/cmuxUITests/cmuxUITests.swift`:
- Around line 616-640: The keyboard-evidence tests in the transcript sampling
flow are still tied to absolute launch-time delays and a fixed sampling window,
which makes them flaky. Update the affected test blocks around
launchAgentChatInlinePreviewApp, waitForTranscriptMetrics, scrollTranscript, and
sampleKeyboardEvidenceFrames so sampling starts only after baseline transcript
metrics are observed and keyboard milestones are reached, using keyboardEvents
or keyboardOverlap as the trigger instead of elapsed wall-clock time. Keep the
screenshots/evidence capture opportunistic during those milestone checks, and
apply the same change to all similar occurrences in the listed test scenarios.
- Around line 1766-1808: The parser in init?(_ rawValue: String) currently
defaults topEdgeEffectSoft and bottomEdgeEffectSoft to false when the probe keys
are missing, which can hide a missing iOS 26 scroll-edge metric. Update this
parsing path so the edge-effect fields are required like the other core probe
values: reject the payload by returning nil if topEdgeEffectSoft or
bottomEdgeEffectSoft is absent, and keep the existing required-field handling
consistent with the other frame/bounds fields.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerDebugAutofocusBridge.swift`:
- Around line 1-102: This DEBUG-only autofocus bridge should not live in the
production Sources target; move ChatComposerDebugAutofocusBridge and its UIView
extension helpers out of the production module into a test-only or debug-only
target. Keep the functionality in a UI-test/debug location instead of
ChatComposerDebugAutofocusBridge.swift under Sources, and update any references
so the production build no longer includes the environment-variable-driven
autofocus/dismiss/refocus behavior.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift`:
- Around line 77-79: Remove the DEBUG-only autofocus test hook from the
production composer body. In ChatComposerView, eliminate the conditional
background using ChatComposerDebugAutofocusBridge() from the Sources path and
move any UI-test autofocus wiring into the test target or another non-production
harness. Keep ChatComposerView and its body free of `#if` DEBUG test/debug seams
so production Swift sources do not depend on test-only behavior.
- Around line 360-375: `performPaste()` is writing pasted images directly into
`attachments`, while `loadPickedItems(_:)` and the picker flow rebuild state
from `pickedItems` only, so the composer has two attachment sources. Update the
`ChatComposerView` attachment flow so pasted and picked attachments share one
authoritative source of truth, either by merging picker-derived items with
existing non-picker attachments or by routing both paths through the same
attachment model. Make sure the symbols `performPaste()`, `loadPickedItems(_:)`,
`attachments`, and `pickedItems` stay consistent so repicking does not discard
previously pasted items.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.swift`:
- Around line 303-310: `updateMeasuredGeometryConstants()` is doing an expensive
full subtree scan through `trackedTranscriptTables(in:)` on every
layout/keyboard tick, which makes `ChatKeyboardTrackingViewController` updates
unnecessarily O(subviews) per event. Cache the `ChatTranscriptUITableView` (or
return it from the coordinator) and reuse that reference in
`updateTranscriptOverlayBottomInset(_:)` instead of rescanning, then only
refresh the cache when the hosted transcript hierarchy changes. Apply the same
fix to the other repeated scan call sites in this controller that drive the
keyboard/layout update path.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController`+Debug.swift:
- Around line 1-73: The DEBUG-only keyboard geometry instrumentation in
ChatKeyboardTrackingViewController+Debug should not live under the production
Sources target. Move the updateKeyboardDebugValues(overlap:),
clampedKeyboardAnimationProgress(overlap:), and related keyboardDebug… plumbing
into a test-only target or debug support module, or reduce it to the minimum
internal state needed for `@testable` import. Keep
ChatKeyboardTrackingViewController free of test/debug seams in production Swift
source.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift`:
- Around line 85-87: Remove the UI-test-only initial-scroll/debug seam from
ChatTranscriptTableView and keep production transcript coordination free of test
orchestration. Delete the didApplyDebugInitialScroll state and the env-driven
branch from the coordinator path, and move any initial-scroll handling plus the
extra debug accessibility update into the UI test harness or a dedicated
test-only helper outside Sources. Update ChatTranscriptTableView and any related
methods involved in the initial scroll flow so only production behavior remains.

In
`@Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptUITableView.swift`:
- Around line 12-27: The DEBUG-only transcript metrics hook is currently
embedded in the production ChatTranscriptUITableView source, including the
keyboardDebug* fields and the accessibilityValue exporter. Move these seams into
a dedicated test/debug support target or extension outside Sources, and keep
ChatTranscriptUITableView in shipping code free of UI-test-only geometry hooks.
Update the related accessibilityValue and metric plumbing together so the
production type no longer exposes test/debug-only state.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift`:
- Around line 74-83: The icon-only toolbar buttons in AgentChatDemoScreen’s
ToolbarItemGroup need user-facing accessibility handling, since identifiers
alone are not enough for VoiceOver. Update each Button to either provide a
localized accessibilityLabel using the existing accessibilityIdentifier targets,
or mark them as hidden from accessibility if they are only preview chrome. Keep
the fix localized and consistent with the app’s SwiftUI internationalization
rules.

In
`@Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift`:
- Around line 182-194: The .custom shortcut path in WorkspaceChatPane is lossy
because it converts custom.output to UTF-8 text before sending, which can drop
or alter non-UTF-8 bytes. Update the shortcut handling in the .custom(custom)
branch and any related paths in WorkspaceChatPane to preserve the original
payload bytes end-to-end instead of decoding to String. Use the existing
custom.output and sendSessionTerminalInput flow, but pass the raw data through a
bytes-preserving API or equivalent so shared shortcuts behave the same in chat
and terminal.

In
`@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift`:
- Around line 41-59: The transition guard properties on the state type are
exposing internal state-machine rules as part of the public API. Make
`canStart`, `canCancelPendingStart`, `canFinalize`, and `isStopping`
internal/private in `ComposerDictationTextMerger` (or the relevant state
enum/class), and keep only the externally meaningful read models like
`isListening` and `locksComposerField` public so downstream code cannot depend
on these implementation details.

In
`@Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`:
- Around line 873-878: The test helper setKeyboardHeightForTesting(_:) is only
updating keyboardHeight, leaving the new keyboardVisible source of truth out of
sync with the injected state. Update this helper so it also derives and sets
keyboardVisible consistently from the passed height before calling
layoutRenderedTerminalForCurrentViewport(), layoutBottomDock(), and
syncSurfaceGeometry(shouldReassertNaturalSize:), matching the runtime behavior
used by keyboardUp checks and hide/refocus logic elsewhere in
GhosttySurfaceView.

---

Outside diff comments:
In
`@Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift`:
- Around line 210-230: The public `stop()` API in `ComposerDictationController`
is exposing an internal timing-based cleanup path that relies on `Task.sleep`,
which should not be part of shipped Swift behavior. Keep `stop()` internal or
change it to an explicit signal-driven async completion flow, and remove the
sleep-based watchdog from the `.stopping` recovery path so callers do not depend
on fixed-delay state resolution.
🪄 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: 9196ddbd-133b-45a9-9aa5-577b2d804768

📥 Commits

Reviewing files that changed from the base of the PR and between 1e9025f and 676631b.

📒 Files selected for processing (54)
  • Packages/iOS/CmuxAgentChatUI/Package.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryChipRow.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryShortcut.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatAccessoryShortcutSemanticAction.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerDebugAutofocusBridge.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerMaterialBackground.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerPasteboard.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Composer/ChatComposerView.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardNotificationToken.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackedRoot.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingContainer.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController+Debug.swift
  • Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatKeyboardTrackingViewController.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/ChatTranscriptUITableView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreen.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AgentChatDemoScreenStyle.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView+AgentChatPreview.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GlassInputPill.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalAccessoryChatCompatibility.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalShortcutsSettingsView.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceChatPane.swift
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationController.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerFieldContainer.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconButton.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileComposerIconLabel.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardReservation.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardVisibility.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileScrollViewportSnapshot.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestConfig.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/UITestEnvironmentConfig.swift
  • Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/View+MobileGlass.swift
  • Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/ComposerDictationTests.swift
  • Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardTrackingTests.swift
  • Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/UITestConfigTests.swift
  • Packages/iOS/CmuxMobileTerminal/Package.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalArrowNubDirection.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalArrowNubView.swift
  • Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalKeyboardHeightAnimation.swift
  • ios/cmux/CmuxAppDelegate.swift
  • ios/cmux/Resources/Localizable.xcstrings
  • ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift
  • ios/cmuxPackage/Sources/cmuxFeature/MobileFeedbackStamp+Current.swift
  • ios/cmuxUITests/cmuxUITests.swift
  • scripts/check-package-resolved-policy.py
💤 Files with no reviewable changes (1)
  • Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift

Comment on lines +616 to +640
let app = launchAgentChatInlinePreviewApp(environment: [
"CMUX_UITEST_CHAT_AUTOFOCUS_DELAY": "14.0",
"CMUX_UITEST_CHAT_AUTO_DISMISS_DELAY": "1.25",
])
let table = app.tables["ChatTranscriptTableView"]
XCTAssertTrue(table.waitForExistence(timeout: 8))
let composerBar = app.otherElements["ChatComposerBar"]
XCTAssertTrue(composerBar.waitForExistence(timeout: 8))

let loadedMetrics = try waitForTranscriptMetrics(table, timeout: 8) {
$0.frameHeight > 240 && $0.frameMaxY > 300 && $0.contentHeight > $0.boundsHeight * 1.6
}
try scrollTranscript(table, direction: .down, timeout: 5) {
$0.distanceFromBottom > 180 && $0.offsetY > 100
}
let beforeKeyboard = try waitForTranscriptMetrics(table, timeout: 2) {
abs($0.frameMaxY - loadedMetrics.frameMaxY) < 4 && $0.keyboardOverlap == 0
}

let animationSamples = sampleKeyboardEvidenceFrames(
table: table,
composerBar: composerBar,
duration: 8.0,
frameCapturePrefix: "middle"
)

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 | 🏗️ Heavy lift

Make keyboard-evidence sampling milestone-driven instead of launch-clock driven.

These tests arm autofocus/dismiss/refocus with absolute delays like 14.0 after launch, then sample for a fixed window from wherever setup happens to finish. Fast CI can finish the 8s sample before autofocus fires; slow CI can start after the transition. Trigger actions after baseline metrics are observed, or wait on keyboardEvents / keyboardOverlap milestones and attach screenshots opportunistically. As per coding guidelines, tests must not depend on real wall-clock time and must assert on causality, not latency.

Also applies to: 697-721, 768-793, 844-869, 1888-1919

🤖 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 616 - 640, The
keyboard-evidence tests in the transcript sampling flow are still tied to
absolute launch-time delays and a fixed sampling window, which makes them flaky.
Update the affected test blocks around launchAgentChatInlinePreviewApp,
waitForTranscriptMetrics, scrollTranscript, and sampleKeyboardEvidenceFrames so
sampling starts only after baseline transcript metrics are observed and keyboard
milestones are reached, using keyboardEvents or keyboardOverlap as the trigger
instead of elapsed wall-clock time. Keep the screenshots/evidence capture
opportunistic during those milestone checks, and apply the same change to all
similar occurrences in the listed test scenarios.

Source: Coding guidelines

Comment on lines +1766 to +1808
init?(_ rawValue: String) {
var values: [String: CGFloat] = [:]
for pair in rawValue.split(separator: ";") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard parts.count == 2,
let value = Double(parts[1]) else {
continue
}
values[String(parts[0])] = CGFloat(value)
}
guard let frameMinY = values["frameMinY"],
let frameMaxY = values["frameMaxY"],
let frameHeight = values["frameHeight"],
let boundsHeight = values["boundsHeight"],
let offsetY = values["offsetY"],
let visibleBottomY = values["visibleBottomY"],
let contentHeight = values["contentHeight"],
let distanceFromBottom = values["distanceFromBottom"] else {
return nil
}
self.frameMinY = frameMinY
self.frameMaxY = frameMaxY
self.frameHeight = frameHeight
self.presentationFrameMaxY = values["presentationFrameMaxY"] ?? frameMaxY
self.boundsHeight = boundsHeight
self.offsetY = offsetY
self.visibleBottomY = visibleBottomY
self.contentHeight = contentHeight
self.distanceFromBottom = distanceFromBottom
self.keyboardEvents = Int(values["keyboardEvents"] ?? 0)
self.keyboardOverlap = values["keyboardOverlap"] ?? 0
self.keyboardTargetOverlap = values["keyboardTargetOverlap"] ?? self.keyboardOverlap
self.composerMinY = values["composerMinY"] ?? frameMaxY
self.composerPresentationMinY = values["composerPresentationMinY"] ?? self.composerMinY
self.presentationGap = values["presentationGap"] ?? 0
self.composerOverlayBottomInset = values["composerOverlayBottomInset"] ?? 0
self.keyboardAnimationActive = (values["keyboardAnimationActive"] ?? 0) >= 0.5
self.keyboardAnimationProgress = values["keyboardAnimationProgress"] ?? 1
self.keyboardTransitionDuration = TimeInterval(values["keyboardTransitionDuration"] ?? 0)
self.maxAnimationPresentationGap = values["maxAnimationPresentationGap"] ?? 0
self.keyboardAnimationSamples = Int(values["keyboardAnimationSamples"] ?? 0)
self.topEdgeEffectSoft = (values["topEdgeEffectSoft"] ?? 0) >= 0.5
self.bottomEdgeEffectSoft = (values["bottomEdgeEffectSoft"] ?? 0) >= 0.5

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

Require scroll-edge probe fields instead of defaulting missing values to false.

Line 1807 makes topEdgeEffectSoft false when the probe key is absent, so the Line 1050 regression assertion can pass even if the app stops publishing the iOS 26 top-edge metric. Make the edge-effect keys required for this parser.

Proposed fix
-                  let visibleBottomY = values["visibleBottomY"],
-                  let contentHeight = values["contentHeight"],
-                  let distanceFromBottom = values["distanceFromBottom"] else {
+                  let visibleBottomY = values["visibleBottomY"],
+                  let contentHeight = values["contentHeight"],
+                  let distanceFromBottom = values["distanceFromBottom"],
+                  let topEdgeEffectSoft = values["topEdgeEffectSoft"],
+                  let bottomEdgeEffectSoft = values["bottomEdgeEffectSoft"] else {
                 return nil
             }
@@
-            self.topEdgeEffectSoft = (values["topEdgeEffectSoft"] ?? 0) >= 0.5
-            self.bottomEdgeEffectSoft = (values["bottomEdgeEffectSoft"] ?? 0) >= 0.5
+            self.topEdgeEffectSoft = topEdgeEffectSoft >= 0.5
+            self.bottomEdgeEffectSoft = bottomEdgeEffectSoft >= 0.5
📝 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
init?(_ rawValue: String) {
var values: [String: CGFloat] = [:]
for pair in rawValue.split(separator: ";") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard parts.count == 2,
let value = Double(parts[1]) else {
continue
}
values[String(parts[0])] = CGFloat(value)
}
guard let frameMinY = values["frameMinY"],
let frameMaxY = values["frameMaxY"],
let frameHeight = values["frameHeight"],
let boundsHeight = values["boundsHeight"],
let offsetY = values["offsetY"],
let visibleBottomY = values["visibleBottomY"],
let contentHeight = values["contentHeight"],
let distanceFromBottom = values["distanceFromBottom"] else {
return nil
}
self.frameMinY = frameMinY
self.frameMaxY = frameMaxY
self.frameHeight = frameHeight
self.presentationFrameMaxY = values["presentationFrameMaxY"] ?? frameMaxY
self.boundsHeight = boundsHeight
self.offsetY = offsetY
self.visibleBottomY = visibleBottomY
self.contentHeight = contentHeight
self.distanceFromBottom = distanceFromBottom
self.keyboardEvents = Int(values["keyboardEvents"] ?? 0)
self.keyboardOverlap = values["keyboardOverlap"] ?? 0
self.keyboardTargetOverlap = values["keyboardTargetOverlap"] ?? self.keyboardOverlap
self.composerMinY = values["composerMinY"] ?? frameMaxY
self.composerPresentationMinY = values["composerPresentationMinY"] ?? self.composerMinY
self.presentationGap = values["presentationGap"] ?? 0
self.composerOverlayBottomInset = values["composerOverlayBottomInset"] ?? 0
self.keyboardAnimationActive = (values["keyboardAnimationActive"] ?? 0) >= 0.5
self.keyboardAnimationProgress = values["keyboardAnimationProgress"] ?? 1
self.keyboardTransitionDuration = TimeInterval(values["keyboardTransitionDuration"] ?? 0)
self.maxAnimationPresentationGap = values["maxAnimationPresentationGap"] ?? 0
self.keyboardAnimationSamples = Int(values["keyboardAnimationSamples"] ?? 0)
self.topEdgeEffectSoft = (values["topEdgeEffectSoft"] ?? 0) >= 0.5
self.bottomEdgeEffectSoft = (values["bottomEdgeEffectSoft"] ?? 0) >= 0.5
init?(_ rawValue: String) {
var values: [String: CGFloat] = [:]
for pair in rawValue.split(separator: ";") {
let parts = pair.split(separator: "=", maxSplits: 1)
guard parts.count == 2,
let value = Double(parts[1]) else {
continue
}
values[String(parts[0])] = CGFloat(value)
}
guard let frameMinY = values["frameMinY"],
let frameMaxY = values["frameMaxY"],
let frameHeight = values["frameHeight"],
let boundsHeight = values["boundsHeight"],
let offsetY = values["offsetY"],
let visibleBottomY = values["visibleBottomY"],
let contentHeight = values["contentHeight"],
let distanceFromBottom = values["distanceFromBottom"],
let topEdgeEffectSoft = values["topEdgeEffectSoft"],
let bottomEdgeEffectSoft = values["bottomEdgeEffectSoft"] else {
return nil
}
self.frameMinY = frameMinY
self.frameMaxY = frameMaxY
self.frameHeight = frameHeight
self.presentationFrameMaxY = values["presentationFrameMaxY"] ?? frameMaxY
self.boundsHeight = boundsHeight
self.offsetY = offsetY
self.visibleBottomY = visibleBottomY
self.contentHeight = contentHeight
self.distanceFromBottom = distanceFromBottom
self.keyboardEvents = Int(values["keyboardEvents"] ?? 0)
self.keyboardOverlap = values["keyboardOverlap"] ?? 0
self.keyboardTargetOverlap = values["keyboardTargetOverlap"] ?? self.keyboardOverlap
self.composerMinY = values["composerMinY"] ?? frameMaxY
self.composerPresentationMinY = values["composerPresentationMinY"] ?? self.composerMinY
self.presentationGap = values["presentationGap"] ?? 0
self.composerOverlayBottomInset = values["composerOverlayBottomInset"] ?? 0
self.keyboardAnimationActive = (values["keyboardAnimationActive"] ?? 0) >= 0.5
self.keyboardAnimationProgress = values["keyboardAnimationProgress"] ?? 1
self.keyboardTransitionDuration = TimeInterval(values["keyboardTransitionDuration"] ?? 0)
self.maxAnimationPresentationGap = values["maxAnimationPresentationGap"] ?? 0
self.keyboardAnimationSamples = Int(values["keyboardAnimationSamples"] ?? 0)
self.topEdgeEffectSoft = topEdgeEffectSoft >= 0.5
self.bottomEdgeEffectSoft = bottomEdgeEffectSoft >= 0.5
🤖 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 1766 - 1808, The parser in
init?(_ rawValue: String) currently defaults topEdgeEffectSoft and
bottomEdgeEffectSoft to false when the probe keys are missing, which can hide a
missing iOS 26 scroll-edge metric. Update this parsing path so the edge-effect
fields are required like the other core probe values: reject the payload by
returning nil if topEdgeEffectSoft or bottomEdgeEffectSoft is absent, and keep
the existing required-field handling consistent with the other frame/bounds
fields.

Comment on lines +1 to +102
#if DEBUG && os(iOS)
import Foundation
import SwiftUI
import UIKit

struct ChatComposerDebugAutofocusBridge: UIViewRepresentable {
func makeUIView(context: Context) -> UIView {
let view = UIView(frame: .zero)
view.isUserInteractionEnabled = false
view.isAccessibilityElement = false
view.accessibilityElementsHidden = true
scheduleChatComposerDebugAutofocus(from: view)
return view
}

func updateUIView(_ uiView: UIView, context: Context) {}

@MainActor
private func scheduleChatComposerDebugAutofocus(from view: UIView) {
guard let delay = chatComposerDebugTimeInterval("CMUX_UITEST_CHAT_AUTOFOCUS_DELAY") else {
return
}
UIView.animate(withDuration: 0, delay: max(0, delay), options: [.allowUserInteraction]) {
} completion: { _ in
MainActor.assumeIsolated {
let root = view.window ?? view.cmuxRootView()
let input = root.cmuxFirstFocusableTextInput(preferredIdentifier: "ChatComposerField")
_ = input?.becomeFirstResponder()
scheduleChatComposerDebugDismissAndRefocus(for: input)
}
}
}

@MainActor
private func scheduleChatComposerDebugDismissAndRefocus(for input: UIView?) {
guard let autoDismissDelay = chatComposerDebugTimeInterval("CMUX_UITEST_CHAT_AUTO_DISMISS_DELAY") else {
return
}
UIView.animate(withDuration: 0, delay: max(0, autoDismissDelay), options: [.allowUserInteraction]) {
} completion: { _ in
MainActor.assumeIsolated {
input?.resignFirstResponder()
guard let autoRefocusDelay = chatComposerDebugTimeInterval("CMUX_UITEST_CHAT_AUTO_REFOCUS_AFTER_DISMISS_DELAY") else {
return
}
UIView.animate(withDuration: 0, delay: max(0, autoRefocusDelay), options: [.allowUserInteraction]) {
} completion: { _ in
MainActor.assumeIsolated {
_ = input?.becomeFirstResponder()
}
}
}
}
}

private func chatComposerDebugTimeInterval(_ name: String) -> TimeInterval? {
guard let raw = ProcessInfo.processInfo.environment[name]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty,
let value = Double(raw)
else {
return nil
}
return value
}
}

private extension UIView {
@MainActor
func cmuxRootView() -> UIView {
var current = self
while let superview = current.superview {
current = superview
}
return current
}

@MainActor
func cmuxFirstFocusableTextInput(preferredIdentifier: String) -> UIView? {
if (self is UITextField || self is UITextView), canBecomeFirstResponder {
if accessibilityIdentifier == preferredIdentifier {
return self
}
}
for subview in subviews {
if let found = subview.cmuxFirstFocusableTextInput(preferredIdentifier: preferredIdentifier),
found.accessibilityIdentifier == preferredIdentifier {
return found
}
}
if (self is UITextField || self is UITextView), canBecomeFirstResponder {
return self
}
for subview in subviews {
if let found = subview.cmuxFirstFocusableTextInput(preferredIdentifier: preferredIdentifier) {
return found
}
}
return nil
}
}
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move the autofocus bridge out of the production target.

This file is entirely DEBUG-only UI-test scaffolding, and it drives focus/dismiss/refocus with wall-clock delays from environment variables. That both adds a test seam under Sources/ and keeps the UI test path timing-sensitive. As per coding guidelines, "Do not add test-only or debug-only seams in production Swift source files" and "Tests must not depend on real wall-clock time." As per path instructions, **/Sources/**/*.swift applies .github/review-bot-rules/no-test-debug-seam-in-production-source.md.

🤖 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/Composer/ChatComposerDebugAutofocusBridge.swift`
around lines 1 - 102, This DEBUG-only autofocus bridge should not live in the
production Sources target; move ChatComposerDebugAutofocusBridge and its UIView
extension helpers out of the production module into a test-only or debug-only
target. Keep the functionality in a UI-test/debug location instead of
ChatComposerDebugAutofocusBridge.swift under Sources, and update any references
so the production build no longer includes the environment-variable-driven
autofocus/dismiss/refocus behavior.

Sources: Coding guidelines, Path instructions

Comment on lines +77 to +79
#if DEBUG
.background(ChatComposerDebugAutofocusBridge())
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Remove the UI-test autofocus hook from production Sources/ code.

ChatComposerDebugAutofocusBridge() adds a DEBUG-only test seam directly to the composer body. This repo explicitly forbids #if DEBUG test/debug hooks in production Swift sources; keep that wiring in the test target or another non-production harness instead. As per coding guidelines, production Swift source must not add test-only or debug-only seams, and as per path instructions, Sources/**/*.swift should not gain #if DEBUG members used only for tests/debugging.

🤖 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/Composer/ChatComposerView.swift`
around lines 77 - 79, Remove the DEBUG-only autofocus test hook from the
production composer body. In ChatComposerView, eliminate the conditional
background using ChatComposerDebugAutofocusBridge() from the Sources path and
move any UI-test autofocus wiring into the test target or another non-production
harness. Keep ChatComposerView and its body free of `#if` DEBUG test/debug seams
so production Swift sources do not depend on test-only behavior.

Sources: Coding guidelines, Path instructions

Comment on lines +360 to +375
private func performPaste() {
let pasteboard = UIPasteboard.general
if attachments.count < 4,
let attachment = pasteboard.chatComposerAttachment(
maxDimension: Self.maxAttachmentDimension,
jpegQuality: Self.jpegQuality
) {
attachments.append(attachment)
isDraftFocused = true
return
}
guard let string = pasteboard.chatComposerText() else {
return
}
draft += string
isDraftFocused = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep pasted and picked attachments on one authoritative path.

performPaste() appends image attachments straight into attachments, but the picker flow still rebuilds the draft from pickedItems only in loadPickedItems(_:). After pasting an image, picking or re-picking a photo will drop the pasted attachment from the composer. Merge picker-derived attachments into the existing non-picker items, or move both entry points onto one attachment source of truth.

🤖 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/Composer/ChatComposerView.swift`
around lines 360 - 375, `performPaste()` is writing pasted images directly into
`attachments`, while `loadPickedItems(_:)` and the picker flow rebuild state
from `pickedItems` only, so the composer has two attachment sources. Update the
`ChatComposerView` attachment flow so pasted and picked attachments share one
authoritative source of truth, either by merging picker-derived items with
existing non-picker attachments or by routing both paths through the same
attachment model. Make sure the symbols `performPaste()`, `loadPickedItems(_:)`,
`attachments`, and `pickedItems` stay consistent so repicking does not discard
previously pasted items.

Comment on lines +12 to +27
#if DEBUG
var keyboardDebugEventCount = 0
var keyboardDebugOverlap: CGFloat = 0
var keyboardDebugTargetOverlap: CGFloat = 0
var keyboardDebugGuideOverlap: CGFloat = 0
var keyboardDebugBottomConstraint: CGFloat = 0
var keyboardDebugComposerMinY: CGFloat = 0
var keyboardDebugComposerPresentationMinY: CGFloat = 0
var keyboardDebugPresentationFrameMaxY: CGFloat = 0
var keyboardDebugPresentationFrameMaxYProvider: (() -> CGFloat?)?
var keyboardDebugComposerPresentationMinYProvider: (() -> CGFloat?)?
var keyboardDebugAnimationID = 0
var keyboardDebugAnimationActive = false
var keyboardDebugAnimationProgress: CGFloat = 1
var keyboardDebugTransitionDuration: TimeInterval = 0
#endif

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Move the transcript metrics hook out of Sources/.

These DEBUG-only fields and the accessibilityValue exporter exist to feed UI-test geometry assertions, but they now live on the production ChatTranscriptUITableView type. Keep this in a dedicated test/debug support target instead of the shipping package source. As per coding guidelines, "Do not add test-only or debug-only seams in production Swift source files." As per path instructions, **/Sources/**/*.swift applies .github/review-bot-rules/no-test-debug-seam-in-production-source.md.

Also applies to: 39-44, 120-202

🤖 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/ChatTranscriptUITableView.swift`
around lines 12 - 27, The DEBUG-only transcript metrics hook is currently
embedded in the production ChatTranscriptUITableView source, including the
keyboardDebug* fields and the accessibilityValue exporter. Move these seams into
a dedicated test/debug support target or extension outside Sources, and keep
ChatTranscriptUITableView in shipping code free of UI-test-only geometry hooks.
Update the related accessibilityValue and metric plumbing together so the
production type no longer exposes test/debug-only state.

Sources: Coding guidelines, Path instructions

Comment on lines +74 to +83
ToolbarItemGroup(placement: .topBarTrailing) {
Button(action: {}) {
Image(systemName: "bubble.left.and.bubble.right.fill")
}
.accessibilityIdentifier("AgentChatInlinePreviewChatToggle")
Button(action: {}) {
Image(systemName: "rectangle.stack")
}
.accessibilityIdentifier("AgentChatInlinePreviewTerminalPicker")
}

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

Add localized accessibility labels to the new icon-only toolbar buttons.

These controls are currently discoverable only by identifier; VoiceOver will not get a user-facing name. If they are meant to be interactive, give each button a localized accessibilityLabel. If they are purely preview chrome, hide them from accessibility instead. As per coding guidelines, all user-facing Swift UI text must be localized, and path instructions apply the full-internationalization rule to Swift UI changes.

Suggested fix
                     ToolbarItemGroup(placement: .topBarTrailing) {
                         Button(action: {}) {
                             Image(systemName: "bubble.left.and.bubble.right.fill")
                         }
                         .accessibilityIdentifier("AgentChatInlinePreviewChatToggle")
+                        .accessibilityLabel(
+                            String(localized: "agentChatDemo.inline.chatToggle", defaultValue: "Toggle chat")
+                        )
                         Button(action: {}) {
                             Image(systemName: "rectangle.stack")
                         }
                         .accessibilityIdentifier("AgentChatInlinePreviewTerminalPicker")
+                        .accessibilityLabel(
+                            String(localized: "agentChatDemo.inline.terminalPicker", defaultValue: "Show terminal picker")
+                        )
                     }
📝 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
ToolbarItemGroup(placement: .topBarTrailing) {
Button(action: {}) {
Image(systemName: "bubble.left.and.bubble.right.fill")
}
.accessibilityIdentifier("AgentChatInlinePreviewChatToggle")
Button(action: {}) {
Image(systemName: "rectangle.stack")
}
.accessibilityIdentifier("AgentChatInlinePreviewTerminalPicker")
}
ToolbarItemGroup(placement: .topBarTrailing) {
Button(action: {}) {
Image(systemName: "bubble.left.and.bubble.right.fill")
}
.accessibilityIdentifier("AgentChatInlinePreviewChatToggle")
.accessibilityLabel(
String(localized: "agentChatDemo.inline.chatToggle", defaultValue: "Toggle chat")
)
Button(action: {}) {
Image(systemName: "rectangle.stack")
}
.accessibilityIdentifier("AgentChatInlinePreviewTerminalPicker")
.accessibilityLabel(
String(localized: "agentChatDemo.inline.terminalPicker", defaultValue: "Show terminal picker")
)
}
🤖 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 74 - 83, The icon-only toolbar buttons in AgentChatDemoScreen’s
ToolbarItemGroup need user-facing accessibility handling, since identifiers
alone are not enough for VoiceOver. Update each Button to either provide a
localized accessibilityLabel using the existing accessibilityIdentifier targets,
or mark them as hidden from accessibility if they are only preview chrome. Keep
the fix localized and consistent with the app’s SwiftUI internationalization
rules.

Sources: Coding guidelines, Path instructions

Comment on lines +182 to +194
case let .custom(custom):
guard let output = custom.output,
let text = String(data: output, encoding: .utf8) else {
return nil
}
return ChatAccessoryShortcut(
id: "terminal.inputAccessory.custom.\(custom.id.uuidString)",
title: custom.title,
systemImage: validSymbolName(custom.symbolName),
accessibilityLabel: custom.title
) {
sendSessionTerminalInput(text)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve shortcut payload bytes instead of round-tripping through UTF-8.

CustomToolbarAction.isSupportedInAgentChat accepts any non-nil output, but this path drops non-UTF-8 payloads and re-encodes the rest before sending. That means a shortcut can be configurable as “shared” yet disappear or send different bytes in chat than it does in the terminal.

Proposed fix
-        case let .custom(custom):
-            guard let output = custom.output,
-                  let text = String(data: output, encoding: .utf8) else {
+        case let .custom(custom):
+            guard let output = custom.output else {
                 return nil
             }
             return ChatAccessoryShortcut(
                 id: "terminal.inputAccessory.custom.\(custom.id.uuidString)",
                 title: custom.title,
                 systemImage: validSymbolName(custom.symbolName),
                 accessibilityLabel: custom.title
             ) {
-                sendSessionTerminalInput(text)
+                sendSessionTerminalInput(output)
             }
         }
     }
@@
         case .paste:
             break
         default:
-            guard let output = action.output,
-                  let text = String(data: output, encoding: .utf8) else {
+            guard let output = action.output else {
                 return
             }
-            sendSessionTerminalInput(text)
+            sendSessionTerminalInput(output)
         }
     }
 
-    private func sendSessionTerminalInput(_ text: String) {
-        guard let terminalID = session.terminalID,
-              let data = text.data(using: .utf8)
-        else { return }
+    private func sendSessionTerminalInput(_ data: Data) {
+        guard let terminalID = session.terminalID else { return }
         Task {
             await store.submitTerminalRawInput(data, surfaceID: terminalID)
         }
     }

Also applies to: 198-224

🤖 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/WorkspaceChatPane.swift`
around lines 182 - 194, The .custom shortcut path in WorkspaceChatPane is lossy
because it converts custom.output to UTF-8 text before sending, which can drop
or alter non-UTF-8 bytes. Update the shortcut handling in the .custom(custom)
branch and any related paths in WorkspaceChatPane to preserve the original
payload bytes end-to-end instead of decoding to String. Use the existing
custom.output and sendSessionTerminalInput flow, but pass the raw data through a
bytes-preserving API or equivalent so shared shortcuts behave the same in chat
and terminal.

Comment on lines +41 to +59
public var canStart: Bool { self == .idle }

/// Whether a tap should cancel a start whose authorization is still resolving.
/// True only in ``requestingPermission``: a second tap there aborts the pending
/// start and returns to idle so recognition never begins, rather than being
/// ignored and letting the mic come on anyway when permission later resolves.
var canCancelPendingStart: Bool { self == .requestingPermission }
public var canCancelPendingStart: Bool { self == .requestingPermission }

/// Whether a graceful stop can finalize from this state. True only while
/// ``listening``: a graceful stop flushes buffered audio and waits for the
/// recognition task's final result. From any other state there is no live
/// session to finalize, so the controller hard-cancels instead.
var canFinalize: Bool { self == .listening }
public var canFinalize: Bool { self == .listening }

/// Whether the controller is mid-teardown, waiting for the recognition task's
/// final result before returning to idle. The mic is no longer capturing but
/// the session has not fully settled. Distinguishes a graceful stop's transient
/// wait from both the active ``listening`` state and the resting ``idle``.
var isStopping: Bool { self == .stopping }
public var isStopping: Bool { self == .stopping }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Keep the transition guards internal.

Lines 41-59 expose canStart, canCancelPendingStart, canFinalize, and isStopping, but those are controller-internal transition rules rather than stable UI state. Once public, downstream packages can branch on the current state-machine implementation and lock this package into those exact transitions. Only the externally meaningful read models (isListening, locksComposerField) need to be public here.

🤖 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/CmuxMobileSupport/Sources/CmuxMobileSupport/ComposerDictationTextMerger.swift`
around lines 41 - 59, The transition guard properties on the state type are
exposing internal state-machine rules as part of the public API. Make
`canStart`, `canCancelPendingStart`, `canFinalize`, and `isStopping`
internal/private in `ComposerDictationTextMerger` (or the relevant state
enum/class), and keep only the externally meaningful read models like
`isListening` and `locksComposerField` public so downstream code cannot depend
on these implementation details.

Comment on lines 873 to 878
func setKeyboardHeightForTesting(_ height: CGFloat) {
stopKeyboardHeightAnimation()
keyboardHeight = max(0, height)
layoutRenderedTerminalForCurrentViewport()
layoutBottomDock()
syncSurfaceGeometry(shouldReassertNaturalSize: true)

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

Keep the test keyboard helper’s visibility state in sync.

Lines 874-877 only update keyboardHeight, but this refactor moved the behavioral checks and debug probe over to keyboardVisible (for example Lines 752, 963, 1505, and 1584). Any caller that injects a nonzero test height now gets keyboard-up geometry with a stale keyboard-down state, so keyboardUp assertions and hide/refocus branches diverge from real runtime behavior.

Suggested fix
 func setKeyboardHeightForTesting(_ height: CGFloat) {
     stopKeyboardHeightAnimation()
-    keyboardHeight = max(0, height)
+    let clampedHeight = max(0, height)
+    keyboardVisible = clampedHeight > 0
+    inputProxy.setKeyboardShown(keyboardVisible)
+    keyboardHeight = clampedHeight
+    updateDockedToolbarVisibility()
     layoutRenderedTerminalForCurrentViewport()
     layoutBottomDock()
     syncSurfaceGeometry(shouldReassertNaturalSize: true)
 }

As per path instructions, correctness-critical detection should use a single authoritative source of truth and avoid disagreeing fallback state.

📝 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
func setKeyboardHeightForTesting(_ height: CGFloat) {
stopKeyboardHeightAnimation()
keyboardHeight = max(0, height)
layoutRenderedTerminalForCurrentViewport()
layoutBottomDock()
syncSurfaceGeometry(shouldReassertNaturalSize: true)
func setKeyboardHeightForTesting(_ height: CGFloat) {
stopKeyboardHeightAnimation()
let clampedHeight = max(0, height)
keyboardVisible = clampedHeight > 0
inputProxy.setKeyboardShown(keyboardVisible)
keyboardHeight = clampedHeight
updateDockedToolbarVisibility()
layoutRenderedTerminalForCurrentViewport()
layoutBottomDock()
syncSurfaceGeometry(shouldReassertNaturalSize: true)
}
🤖 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/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift`
around lines 873 - 878, The test helper setKeyboardHeightForTesting(_:) is only
updating keyboardHeight, leaving the new keyboardVisible source of truth out of
sync with the injected state. Update this helper so it also derives and sets
keyboardVisible consistently from the passed height before calling
layoutRenderedTerminalForCurrentViewport(), layoutBottomDock(), and
syncSurfaceGeometry(shouldReassertNaturalSize:), matching the runtime behavior
used by keyboardUp checks and hide/refocus logic elsewhere in
GhosttySurfaceView.

Source: Path instructions

This branch was successfully deployed

1 active deployment
Preview – cmux — 676631be 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