Skip to content

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

Open
mwolson wants to merge 16 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 16 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.
  • Confirm warm thread resume completion with an opt-in stream marker so empty catch-up no longer leaves detail stuck on Syncing.
  • Hold the Home shell list until a server heal (or true disconnect), so reconnect does not flash stale active membership from disk.
  • Keep chat content above the floating composer when the Android keyboard opens after a quick focus (composer growth racing keyboard onStart).

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.
Warm thread detail resume with an up-to-date cache can replay zero catch-up frames, so the client never got a positive "live buffer attached" signal and could stick on Syncing (especially after in-band expected-failure retry while the supervisor stayed connected). Opt-in { kind: "synchronized" } on thread resume after catch-up: server advertises ServerConfig.threadResumeCompletionMarker, client requests requestCompletionMarker only when advertised, and status stays synchronizing until the marker (legacy servers keep warm setReady promotion). Seeded from contributor work in mwolson#2; emission corrected to subscribeThread.
Stale disk active membership painted into Home before the server heal on reconnect, so archived or ghost threads could flash then shrink away. Hold the list empty or the previous server-backed snapshot until HTTP heal (or an authoritative socket snapshot); only use disk when truly offline or when heal fails and nothing else is available.
On Android, opening the keyboard right after a thread open could grow the composer before keyboard onStart, so delta-based scroll compensation zeroed out and the last messages stayed behind the input. Patch react-native-keyboard-controller chat keyboard path: when the list is anchored at the end, target the live end of the scroll range (including current composer padding) each frame; re-assert the settled target after the animation ends; keep iOS platform files unchanged for this race.

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.
New server emitting { kind: "synchronized" } without opt-in would hard-fail decode on older clients; new clients against old servers would wait forever if the warm ready path were removed. Emit the marker only when subscribe input requests it; advertise support via optional ServerConfig.threadResumeCompletionMarker; request the marker only when advertised.
Ordinary reconnect cleared awaitingCompletion on disconnect, then setReady could promote to live before the next marker arrived (review follow-up). Sticky markerMode for the life of the thread state; re-arm awaitingCompletion on every setSynchronizing and on stream errors while marker mode is active.
Online connect often has session: None while the lease opens. Treating that as offline still painted disk into Home before heal (review follow-up on the shell hold path). Do not apply disk on session absence alone. Apply disk only from true disconnect (setDisconnected) or when a live session fails HTTP heal and needs a provisional list before the socket snapshot.

UI Changes

This affects thread loading feedback, screenshot messages and their viewer, transparent-header scrolling, the floating composer (including Android keyboard open), and Home membership during reconnect (no stale disk flash before heal).

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
  • Thread resume completion marker unit tests (threads-sync + server empty resume / no-request omit): pass
  • Thread marker re-arm after ordinary reconnect (threads-sync): pass
  • Shell disk hold through session:None + in-flight HTTP heal (shell-sync): pass
  • Android device: keyboard open after quick composer focus keeps last messages above the input
  • iOS device regression: floating composer end inset still clear with keyboard closed (personal com.mwolson.t3code.main.dev)

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)
  • Soft timeout/heal if capability is advertised but the completion marker never arrives (optional follow-up)
  • Refresh afterSequence from lastSequence on each resubscribe attempt (optional follow-up)

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

High Risk
Touches reconnect shell/thread sync, WS protocol markers, and native fatal hooks—areas where regressions affect Home membership, thread catch-up, or crash capture on Release iOS.

Overview
Hardens iOS/mobile reconnect and sync, Release crash diagnosis, and thread chat layout so cached state recovers cleanly and Release Hermes fatals leave readable on-disk records.

Client-runtime & server: Shell sync no longer paints stale disk membership on reconnect; it HTTP-heals (or forces a full socket snapshot) before resuming afterSequence, rejects regressive full snapshots, and only falls back to disk when truly offline or heal fails. Thread detail can request an opt-in { kind: "synchronized" } marker after warm resume (ServerConfig.threadResumeCompletionMarker + requestCompletionMarker) so empty catch-up does not stick on syncing. Shared causeFailureMessage improves error text; thread queries expose refresh and preserve data on refresh failure. Server WS emits the completion marker only when requested.

Mobile app: Early crash logger (JS ErrorUtils, breadcrumbs with debounced/max-wait flush, nav/outbox breadcrumbs) plus native T3CrashLog / AppDelegate RCTSetFatalHandler and fsync writeSyncText. Native stack options skip redundant setOptions via structural signatures and stable header factories (avoids PreventRemoveProvider update-depth loops). Thread feed fixes composer insets, short-content scroll, loading overlay, screenshot press targets, and fillWidth markdown; native shadow node rebuilds content on layout instead of stale measure cache. Thread route adds stalled-detail retries; composer images strip client fields via toUploadChatImageAttachments.

Tests: Expanded shell/thread-sync, server subscribeThread, Cursor adapter wall-clock waits, and mobile unit tests for breadcrumbs, crash records, and stack signatures.

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

Note

Harden iOS reconnect handling, crash capture, and chat feed layout

  • Adds a JS crash logger (installCrashLog) that intercepts global JS errors and unhandled rejections, persisting minimal and full crash records (including breadcrumbs) via a native sync write or Expo FS fallback; an Expo config plugin modifies AppDelegate.swift to also intercept native RN fatal handlers
  • Introduces an 80-entry in-memory breadcrumb ring buffer with debounced disk persistence to crash-logs/last-breadcrumbs.json, with navigation state changes and outbox dispatches recording breadcrumbs
  • Adds a synchronized stream marker protocol: the server emits { kind: 'synchronized' } after catch-up when requestCompletionMarker is set, and the client holds thread state as synchronizing until the marker arrives, preventing false-live status after reconnect
  • Refactors shell sync startup to begin in synchronizing without painting disk state, attempt HTTP heal first, and only use cached disk state as a provisional fallback when offline or when HTTP heal fails; rejects stale full socket snapshots unless marked authoritative
  • Fixes ThreadFeed chat layout by measuring content height and correcting underflow via auto-scroll, adding a THREAD_FEED_COMPOSER_GAP above the floating composer, and showing a loading overlay during hydration
  • Fixes StackHeader.NativeStackScreenOptions to only call navigation.setOptions when the structural signature of options changes, using stable-identity wrappers for header item factories
  • Fixes T3MarkdownTextShadowNode to build content fresh from children on each layout rather than relying on cached mutable members, preventing stale or dropped text
  • Risk: shell state no longer paints cached disk snapshot on startup; users may see a synchronizing state briefly before the HTTP heal completes where they previously saw stale cached content immediately

Macroscope summarized 924ad10.

@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: f9c31dff-768f-41e2-9584-642f8053a59d

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

6 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

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.
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.

Copy link
Copy Markdown

I hit the same persistent “Syncing messages…” state on a physical iPhone with a warm cached thread and no new events after reconnect.

Your reconnect work is directly relevant. In our trace, the server had attached the catch-up + live subscription successfully, but emitted zero frames because the cache was current, so the client had no positive completion signal. We validated a small protocol-level completion marker emitted once the live subscription is attached; it lets a cached thread transition to live without retransmitting its full body. Happy to contribute that as a focused follow-up if it remains useful after this PR lands.

@mwolson

mwolson commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

I hit the same persistent “Syncing messages…” state on a physical iPhone with a warm cached thread and no new events after reconnect.

Your reconnect work is directly relevant. In our trace, the server had attached the catch-up + live subscription successfully, but emitted zero frames because the cache was current, so the client had no positive completion signal. We validated a small protocol-level completion marker emitted once the live subscription is attached; it lets a cached thread transition to live without retransmitting its full body. Happy to contribute that as a focused follow-up if it remains useful after this PR lands.

Hi, if you have time I'd be interested to see a PR with this one as a base branch, which includes the steps to repro it consistently. I haven't come across it yet organically myself. If this PR gets merged first, GitHub should automatically change base to master.

@twistedgrim

Copy link
Copy Markdown

I hit the same persistent “Syncing messages…” state on a physical iPhone with a warm cached thread and no new events after reconnect.
Your reconnect work is directly relevant. In our trace, the server had attached the catch-up + live subscription successfully, but emitted zero frames because the cache was current, so the client had no positive completion signal. We validated a small protocol-level completion marker emitted once the live subscription is attached; it lets a cached thread transition to live without retransmitting its full body. Happy to contribute that as a focused follow-up if it remains useful after this PR lands.

Hi, if you have time I'd be interested to see a PR with this one as a base branch, which includes the steps to repro it consistently. I haven't come across it yet organically myself. If this PR gets merged first, GitHub should automatically change base to master.

Will do Mike. Let me get back to you shortly.

twistedgrim and others added 2 commits July 13, 2026 17:05
## Summary

- Add a synchronized stream item after a resumed thread subscription has attached its live buffer.
- Keep a warm cached thread synchronizing through reconnect until that item arrives.

## Notes

Squash-merge of twistedgrim's PR onto ios-fixes-main. Follow-up gate commit will land separately with capability negotiation and marker-after-catch-up ordering.
Emit the completion marker only on subscribeThread when clients opt in,
after catch-up replay, and keep warm threads synchronizing until it
arrives. Advertise support via ServerConfig so old servers still use the
ready fallback and old clients never see an unknown stream kind.
Comment thread packages/client-runtime/src/state/threads.ts
Do not paint a warm disk shell snapshot before the HTTP or socket heal.
Stale active membership was flashing a larger Home list, then shrinking
when the authoritative heal arrived. Disk remains offline/heal fallback.
Comment thread packages/client-runtime/src/state/threads.ts
@mwolson

mwolson commented Jul 13, 2026

Copy link
Copy Markdown
Contributor Author

@twistedgrim Thanks for filing this and for the PR. I squash-merged your branch onto ios-fixes-main as mwolson/t3code#2 (Confirm resumed thread subscriptions (#2)), then landed two follow-ups on the same branch so the protocol idea is production-safe and so a separate Home-list flash does not get mixed into that change.

Gate commit (your thread-sync idea, hardened)

fix(orchestration): Gate thread resume sync marker after catch-up

Your diagnosis still holds: a warm resume with empty catch-up can attach the live subscription and emit zero frames, so the client has no positive "caught up" signal.

On top of your marker, this commit:

  • Emits { kind: "synchronized" } on thread resume (afterSequence), not shell.
  • Orders the stream as catch-up → synchronized → live, so the UI can stay on "Syncing…" until catch-up is applied, then flip to live once (better for short 1–5s catch-up than going live first and rewriting rows as events arrive).
  • Uses two-way expand/contract:
    • Server advertises threadResumeCompletionMarker on ServerConfig.
    • Client only requests requestCompletionMarker when that is present.
    • Server only emits the marker when requested (old clients never see an unknown kind and hard-fail decode).
  • Keeps the warm-cache ready fallback when the server does not advertise the marker, so a new client against an old serve does not stick on Syncing forever.
  • While the marker is expected, catch-up snapshots/events update data but keep status synchronizing until the marker arrives.
  • Covers the residual path where an in-band expected-failure retry leaves the supervisor connected, demotes to cached, and empty resubscribe would otherwise never go live.

I also hit a real bug in the original tip: the marker was only wired on the shell resume path while the contracts/client/test targeted thread resume (the server test timed out with empty replay). That is fixed here.

Shell-hold commit (separate Home UX; not part of your PR)

fix(client-runtime): Hold shell list until server heal on reconnect

This is unrelated to the thread-detail marker. While retesting on device I saw Home briefly balloon with many more threads, then shrink after the HTTP shell heal. That came from painting a stale warm disk shell snapshot (often inflated active membership after a dropped archive delta) before the authoritative heal.

This commit:

  • Does not paint disk shell into the visible list when online before heal.
  • Holds any existing server-backed list while reconciling.
  • Still uses disk as offline / heal-failure fallback only.

Kept as its own commit so your protocol work stays reviewable on its own.

Happy to take further notes if you still see sticky Syncing on a build that includes both the squash and the gate commit.

Comment thread packages/client-runtime/src/state/threads.ts
Comment thread packages/client-runtime/src/state/shell.ts Outdated
mwolson added 2 commits July 13, 2026 18:54
Keep Home empty while session is still None during online connect setup;
only apply disk fallback from true disconnect. Persist markerMode so
thread resume re-arms awaitingCompletion after ordinary reconnects.
…Android

Anchor the chat scroll position to the end of the scroll range when the
keyboard opens with the list resting at the bottom, reading the live
composer padding per frame. This survives the event-ordering race where
composer expansion lands before the keyboard's onStart (quick tap after
opening a thread), which zeroed out the previous delta-based
compensation and left the last message hidden behind the input. Also
re-assert the settled scroll target after the keyboard animation ends
and guard that correction with shouldShiftContent.
} else {
// Android: keyboard closing — re-capture scroll position
+ extraPaddingOnStart.value = extraContentPadding.value;
+ anchorToEnd.value =

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 patches/react-native-keyboard-controller@1.21.13.patch:1125

On Android keyboard-close, anchorToEnd is recomputed as scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1, but while the keyboard is open size.value.height still includes the keyboard padding, so this end-of-range test compares against the open scroll range, not the closed one. A user who scrolled away from the newest message while the keyboard was open can still satisfy it. The close path then forces scrollTo to the absolute end on every frame, snapping the conversation to the bottom and discarding the user's scroll position. The close-path check should account for the keyboard height (or be captured before close begins) so it reflects the post-close scroll range.

🤖 Copy this AI Prompt to have your agent fix this:
In file @patches/react-native-keyboard-controller@1.21.13.patch around line 1125:

On Android keyboard-close, `anchorToEnd` is recomputed as `scroll.value + layout.value.height - size.value.height >= blankSpace.value - 1`, but while the keyboard is open `size.value.height` still includes the keyboard padding, so this end-of-range test compares against the *open* scroll range, not the closed one. A user who scrolled away from the newest message while the keyboard was open can still satisfy it. The close path then forces `scrollTo` to the absolute end on every frame, snapping the conversation to the bottom and discarding the user's scroll position. The close-path check should account for the keyboard height (or be captured before close begins) so it reflects the post-close scroll range.

@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 924ad10. Configure here.

+ }
+
+ return;
+ }

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.

anchorToEnd ignores whenAtEnd sentinel

Medium Severity

In the non‑iOS useChatKeyboard path, onStart still sets offsetBeforeScroll to -1 when keyboardLiftBehavior is whenAtEnd and the list is not at the end, but the new onMove branch scrolls to the end whenever anchorToEnd is true and never checks that sentinel. Short or underflowing threads can satisfy the blankSpace‑based anchorToEnd test while the user is at the top, so focusing the composer can yank the feed to the bottom on Android.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 924ad10. Configure here.

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.

2 participants