Skip to content

fix(mobile): Harden iOS reconnect, crash capture, and chat layout on main#3910

Open
mwolson wants to merge 11 commits into
pingdotgg:mainfrom
mwolson:ios-fixes-main
Open

fix(mobile): Harden iOS reconnect, crash capture, and chat layout on main#3910
mwolson wants to merge 11 commits into
pingdotgg:mainfrom
mwolson:ios-fixes-main

Conversation

@mwolson

@mwolson mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Backport the applicable v1 iOS runtime and chat reliability fixes onto main.
  • Recover cached shell and thread state cleanly after reconnects, and make stalled thread-detail loading visible and retryable.
  • Keep keyboard, transparent-header, floating-composer, Markdown, and screenshot layout predictable on a real iPhone.
  • Harden Release Hermes crash diagnosis so fatals leave recoverable disk records without attaching a device console.
  • Stop Thread catch-up re-renders from looping through PreventRemoveProvider via unstable native-stack options updates.
  • Heal shell archive membership on reconnect so a dropped archive delta cannot leave archived threads on the Home list after later events advance snapshotSequence.

Problem and Fix

Problem and Why it Happened Fix
Cached shell and thread snapshots could remain marked as synchronizing after a reconnect, while thread-detail failures lost useful error information or appeared to load forever. Restore live status when cached data is available, preserve useful failure messages, retain prior thread data during refresh failures, and retry stalled detail requests before showing an actionable error.
LegendList and keyboard-controller scroll math did not consistently account for UIKit's automatic top and bottom insets around a transparent header and floating composer. Patch the inset and offset calculations, measure the composer overlay, account for the usable viewport, and correct short-content and hydrated-thread resting positions.
Screenshot rows could change geometry while their asset URI resolved, become non-interactive, or leave the newest content beneath the composer when a thread reopened. Keep a fixed attachment container through loading and display, use a full-size accessible press target, preserve the presenting thread behind the image viewer, and apply a one-time end correction after hydrated overflowing content is measured.
Composer image objects contained client-only draft fields that do not belong in startTurn payloads. Convert draft attachments to the upload wire shape for direct sends and outbox retries.
Native selectable Markdown could render at the wrong width or publish stale child content when Yoga skipped measurement on a clone. Add explicit fill-width support and rebuild native attributed content from current children before publishing changed state.
Release Hermes fatals were hard to diagnose after restart. A thin ErrorUtils logger often lost the race with RCTFatal abort and left empty Documents crash logs. Install crash capture early: JS write-first records, dual disk paths, native fsync (T3NativeControls.writeSyncText), unhandled-rejection capture, nav/outbox/stop breadcrumbs with max-wait flush, and a native RCTSetFatalHandler path (T3CrashLog + AppDelegate plugin) that writes last-crash.json / crash-native-*.json with the real JS message and stack.
Thread catch-up and other busy re-renders rebuilt inline native-stack options every layout, so navigation.setOptions re-entered PreventRemoveProvider until React hit maximum update depth. Compare a structural options signature before setOptions, keep stable header-item factories via refs, and memoize Thread screen options.
Warm-cache afterSequence resume could skip a dropped archive upsert after later shell events advanced snapshotSequence, so archived threads stayed on Home until a full resync. Always HTTP-heal the shell snapshot (or force a full socket snapshot if HTTP fails) before resuming live deltas; reject non-authoritative full snapshots older than the current sequence.

Defensive Fixes

Problem and Why it Happened Fix
Continuous breadcrumb activity could keep resetting the trailing debounce so last-breadcrumbs.json never flushed before a kill. Pair the trailing debounce with a max-wait timer so breadcrumbs still land about every 400ms under continuous activity.
Disclosure expansion and short-to-overflow transitions could fight automatic end maintenance. Suspend end maintenance while content underflows or a disclosure owns the visible-content anchor, then restore it through the intended transition path.

UI Changes

This affects thread loading feedback, screenshot messages and their viewer, transparent-header scrolling, and the floating composer.

Focused Simulator layout evidence (two after-state shots only): #3910 (comment)

Validation

  • vp check: pass
  • vp run typecheck: pass
  • Crash-logger unit tests (breadcrumbs, crash record shape, nav path helper, options signature): pass
  • Release Hermes build in the iOS Simulator: pass
  • Signed Release Hermes build on a physical iPhone: pass
  • Reconnect state and failure-message unit coverage: pass
  • Screenshot renders normally and remains tappable after asset resolution: pass
  • Screenshot viewer opens without replacing the presenting thread: pass
  • Reopened screenshot thread keeps the newest content above the floating composer: pass
  • Screenshot top remains reachable below the transparent header: pass
  • Follow-up message after a screenshot preserves complete scroll access: pass
  • Device crash capture: after a real fatal, Documents includes last-crash.json with source: "rct-fatal" and the JS exception message (verified for maximum update depth on Thread)
  • Thread catch-up after the stack-options fix: large active thread finished catching up without the previous PreventRemoveProvider abort
  • SwiftLint opening_brace fix for T3CrashLog (CI Mobile Native Static Analysis)
  • Shell archive heal unit tests (shell-sync): pass
  • Device: archived threads on a paired LAN environment leave Home after reconnect/heal

Remaining Work

  • Keep exercising heavy-thread catch-up and streaming on a physical iPhone for residual performance issues (memory / large projections are separate from the options-loop fatal). Done: large active CTM thread finished catching up on device after the stack-options fix without the PreventRemoveProvider abort (see Validation).
  • Add focused Simulator layout screenshots. Done in fix(mobile): Harden iOS reconnect, crash capture, and chat layout on main #3910 (comment)

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included layout screenshots for UI changes (comment)
  • I included a video for animation/interaction changes

Note

Harden iOS crash capture, reconnect healing, and thread chat layout

  • Adds a crash logging system to iOS: native RCT fatal handlers write crash records to Documents/crash-logs via a synchronous fsync path; JS errors are intercepted globally and persisted with breadcrumbs via installCrashLogger and a new Expo config plugin (withIosCrashLog.cjs)
  • Introduces a breadcrumb ring buffer (breadcrumbs.ts) with debounced disk persistence and navigation state recording on every route change
  • Hardens iOS reconnect in shell.ts: always attempts an HTTP heal on reconnect; if it fails, subscribes without afterSequence so the server sends a full snapshot accepted authoritatively; prevents stale socket snapshots from overriding newer healed state
  • Fixes thread and shell state transitions to enter live immediately on reconnect when cached data is present, instead of remaining synchronizing
  • Fixes NativeStackScreenOptions to call navigation.setOptions only when a structural signature changes, preventing maximum-update-depth crashes from re-entrant header updates
  • Improves thread chat layout in ThreadFeed: detects content underflow, auto-aligns short threads to the top anchor, and shows a loading placeholder without remounting the list
  • Adds stall detection to ThreadRouteScreen: retries thread detail load twice with increasing delays, then surfaces a user-facing error
  • Risk: shell.ts reconnect no longer resumes from disk cache alone — every reconnect triggers a server heal, which may increase server load after network interruptions

Macroscope summarized ed1a5f2.


Note

High Risk
Large mobile surface area: native fatal hooks, shell reconnect semantics (more full heals on reconnect), vendored LegendList patches, and Fabric shadow-node layout changes—all affect production iOS chat and sync behavior.

Overview
Adds early crash diagnostics on iOS: breadcrumbs (nav, outbox, thread stop) flushed to Documents/crash-logs, a JS ErrorUtils path that writes minimal records first then enriches, and native RCTSetFatalHandler / fsync via T3CrashLog plus an Expo withIosCrashLog AppDelegate plugin so Release Hermes fatals still leave last-crash.json.

Reconnect and sync: Shell sync no longer resumes afterSequence from disk alone—it HTTP-heals (or takes the first full socket snapshot when HTTP fails), rejects stale full snapshots, and returns shell/thread state to live when cached data exists. Thread detail gets refresh/retry hooks and a stall error after timed retries; shared causeFailureMessage improves error text.

Thread UI and layout: ThreadFeed gains loading overlay, short-content scroll correction, composer gap/inset estimates, and safer screenshot attachments/viewer; LegendList / keyboard patches handle transparent-header and floating-composer insets. Native markdown rebuilds attributed text in layout() (fixes missing text) and supports fillWidth.

Stability: NativeStackScreenOptions skips redundant setOptions via a structural signature and stable header factories (fixes max update depth on busy threads). Composer uploads strip draft-only fields via toUploadChatImageAttachments.

Reviewed by Cursor Bugbot for commit ed1a5f2. Bugbot is set up for automated code reviews on this repo. Configure here.

@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 063fcb36-0956-43a1-81b3-71f895c69ea4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jul 12, 2026

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.

🟡 Medium

+ onScrollBeginDrag: (event) => {

The onScrollBeginDrag wrapper is created inside a useMemo(..., []) that captures the initial onScrollBeginDrag prop. If a parent passes a new callback after mount, drag events continue invoking the stale closure — the prop update is silently ignored. Store the latest callback in a ref or include onScrollBeginDrag in the memo dependencies.

🤖 Copy this AI Prompt to have your agent fix this:
In file @patches/@legendapp__list@3.2.0.patch around line 951:

The `onScrollBeginDrag` wrapper is created inside a `useMemo(..., [])` that captures the initial `onScrollBeginDrag` prop. If a parent passes a new callback after mount, drag events continue invoking the stale closure — the prop update is silently ignored. Store the latest callback in a ref or include `onScrollBeginDrag` in the memo dependencies.

Comment thread patches/@legendapp__list@3.2.0.patch
Co-authored-by: codex <codex@users.noreply.github.com>
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated

let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const scheduleRetry = () => {

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.

🟡 Medium threads/ThreadRouteScreen.tsx:199

The stall error appears after ~40s, not the ~20s implied by THREAD_DETAIL_STALL_ERROR_DELAY_MS. After two failed retries (8s + 12s), scheduleRetry() schedules the error after an additional 20s, so a permanently empty detail doesn't show detailLoadError until roughly 40 seconds. When the retry limit is reached, set the stalled state immediately instead of scheduling another timeout.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 199:

The stall error appears after ~40s, not the ~20s implied by `THREAD_DETAIL_STALL_ERROR_DELAY_MS`. After two failed retries (8s + 12s), `scheduleRetry()` schedules the error after an additional 20s, so a permanently empty detail doesn't show `detailLoadError` until roughly 40 seconds. When the retry limit is reached, set the stalled state immediately instead of scheduling another timeout.

Comment on lines +1439 to +1442
void props.listRef.current?.scrollToOffset({
animated: false,
offset: -anchorTopInset,
});

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.

🟡 Medium threads/ThreadFeed.tsx:1439

When usesNativeAutomaticInsets is false, the underflow correction scrolls to -anchorTopInset, but that same inset is already rendered as the ListHeaderComponent spacer. This double-counts the header inset and positions a short conversation at a negative overscroll offset instead of resting at offset 0. The negative offset should only be used for native automatic-inset layouts where the header inset lives in UIKit's adjustedContentInset and is absent from contentHeight.

Suggested change
void props.listRef.current?.scrollToOffset({
animated: false,
offset: -anchorTopInset,
});
void props.listRef.current?.scrollToOffset({
animated: false,
offset: usesNativeAutomaticInsets ? -anchorTopInset : 0,
});
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around lines 1439-1442:

When `usesNativeAutomaticInsets` is false, the underflow correction scrolls to `-anchorTopInset`, but that same inset is already rendered as the `ListHeaderComponent` spacer. This double-counts the header inset and positions a short conversation at a negative overscroll offset instead of resting at offset `0`. The negative offset should only be used for native automatic-inset layouts where the header inset lives in UIKit's `adjustedContentInset` and is absent from `contentHeight`.

Comment thread apps/mobile/src/lib/breadcrumbPersist.ts
@mwolson mwolson marked this pull request as ready for review July 12, 2026 22:12
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

5 blocking correctness issues found. This PR introduces substantial new infrastructure for crash logging (native iOS + JS), thread reconnect retry logic, and shell sync healing mechanisms with ~3000 additions. Multiple new files, native code changes, Expo plugins, and 5 unresolved medium-severity review comments identifying potential bugs warrant human review.

You can customize Macroscope's approvability policy. Learn more.

Capture RCTFatal message and stack to Documents via a native handler so
Release Hermes aborts leave last-crash.json without a device console.
Harden the JS ErrorUtils path with a minimal write-first record, durable
fsync writes, breadcrumb max-wait flush, and an Expo AppDelegate plugin.

Skip NativeStackScreenOptions setOptions when content is unchanged so
Thread catch-up re-renders do not loop through PreventRemoveProvider and
hit maximum update depth.
return "";
}
const record = options as Record<string, unknown>;
return stableJsonStringify({

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.

🟡 Medium native/nativeStackOptionsSignature.ts:12

buildNativeStackOptionsSignature omits fields like headerShown, headerTransparent, presentation, and gestureEnabled. When a caller changes one of these omitted options while the included fields stay the same, the signature is identical, so the skip logic suppresses setOptions and the screen keeps stale options. Consider including all NativeStackNavigationOptions fields in the signature (or documenting why only this subset is tracked).

Also found in 1 other location(s)

apps/mobile/src/native/StackHeader.tsx:114

The signature cache is not scoped to navigation: when the navigation object/context changes while props.options has the same signature, this effect reruns but returns at the signature check before calling the new navigator's setOptions. The replacement navigator therefore never receives this component's screen options. Reset/cache the signature per navigation object or perform the comparison only after accounting for navigation.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/native/nativeStackOptionsSignature.ts around line 12:

`buildNativeStackOptionsSignature` omits fields like `headerShown`, `headerTransparent`, `presentation`, and `gestureEnabled`. When a caller changes one of these omitted options while the included fields stay the same, the signature is identical, so the skip logic suppresses `setOptions` and the screen keeps stale options. Consider including all `NativeStackNavigationOptions` fields in the signature (or documenting why only this subset is tracked).

Also found in 1 other location(s):
- apps/mobile/src/native/StackHeader.tsx:114 -- The signature cache is not scoped to `navigation`: when the navigation object/context changes while `props.options` has the same signature, this effect reruns but returns at the signature check before calling the new navigator's `setOptions`. The replacement navigator therefore never receives this component's screen options. Reset/cache the signature per navigation object or perform the comparison only after accounting for `navigation`.

</View>
) : null}
{props.contentPresentation.kind === "loading" ? (
<View pointerEvents="none" className="bg-screen" style={StyleSheet.absoluteFill}>

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.

🟡 Medium threads/ThreadFeed.tsx:1890

The loading overlay sets pointerEvents="none", so taps pass through to the still-mounted feed underneath while only the "Loading messages" placeholder is visible. Users can unknowingly activate hidden links, image viewers, or disclosure controls at the tapped coordinates. Consider removing pointerEvents="none" so the overlay intercepts input and blocks interaction with the hidden feed.

Suggested change
<View pointerEvents="none" className="bg-screen" style={StyleSheet.absoluteFill}>
<View className="bg-screen" style={StyleSheet.absoluteFill}>
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadFeed.tsx around line 1890:

The loading overlay sets `pointerEvents="none"`, so taps pass through to the still-mounted feed underneath while only the "Loading messages" placeholder is visible. Users can unknowingly activate hidden links, image viewers, or disclosure controls at the tapped coordinates. Consider removing `pointerEvents="none"` so the overlay intercepts input and blocks interaction with the hidden feed.

Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx Outdated
CI Mobile Native Static Analysis failed on a multi-line if where the
opening brace was on the following line.
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Comment thread apps/mobile/src/native/nativeStackOptionsSignature.ts
@mwolson

mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Simulator layout evidence (Release Hermes)

1. Transparent header: first message not cut off

Scrolled to the top of a screenshot thread. The first user bubble (prompt + attached screenshot) is fully below the nav chrome; nothing is clipped under the transparent header.

Thread top fully visible

2. Floating composer: content stays above the input

Short screenshot thread after send. Message body and reply sit above the floating composer with clear gap; content is not trapped under the input bar.

Composer clear of content

All captures: iPhone Simulator, Release + Hermes, ios-fixes-main validation.

mwolson added a commit to mwolson/t3code that referenced this pull request Jul 13, 2026
…x-turn-mapping

Squash-merge the full Julian orchestrator v2 stack onto main PRs
(ios-fixes-main pingdotgg#3910 and secret-store pingdotgg#2916). Refresh this commit when
upstream/t3code/codex-turn-mapping moves; do not cherry-pick its commits
individually.
Warm-cache afterSequence resume can skip a dropped thread.archived
delta after later events advance snapshotSequence, leaving archived
threads on the home list. Always HTTP-heal (or force a full socket
snapshot) before resuming live shell deltas, and reject stale full
snapshots from the stream.

@cursor cursor Bot left a comment

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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit de8b9f6. Configure here.

Comment thread packages/client-runtime/src/state/shell.ts Outdated
When HTTP shell heal fails, the full socket snapshot is the membership
source of truth. Accept that first snapshot even if its sequence is behind
the warm disk cache so ghost active threads cannot stick.
Comment thread packages/client-runtime/src/state/shell.ts
@mwolson mwolson changed the title fix(mobile): Stabilize the iOS app on main fix(mobile): Harden iOS reconnect, crash capture, and chat layout on main Jul 13, 2026
mwolson added 3 commits July 12, 2026 22:27
A prior session can arm acceptNextSocketSnapshotAuthoritatively when HTTP
heal fails and then disconnect before the socket snapshot arrives. Clear
the flag on a successful HTTP heal so a later reconnect does not treat
stale enrichment snapshots as authoritative.
waitForJsonLogMatch only yieldNow'd between attempts, so under busy CI it
could return before the ACP mock agent wrote session/cancel. Use a real
wall-clock delay (not TestClock sleep) and fail explicitly on timeout.
CI Check treats setTimeout as an Effect lint error. Keep wall-clock delays
via a diagnostics-scoped helper, fail with a tagged timeout, and avoid
TestClock-bound Effect.sleep so ACP mock log polls still work under load.
Move the header options memo before the missing environmentId, threadId,
or selectedThread guards so hook order stays stable if those become null
on a re-render of a mounted ThreadRouteContent instance.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant