Skip to content

fix(mobile): Stabilize iOS v2 runtime paths, image attach ids, header, and markdown text#3814

Closed
mwolson wants to merge 1 commit into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:ios-v2
Closed

fix(mobile): Stabilize iOS v2 runtime paths, image attach ids, header, and markdown text#3814
mwolson wants to merge 1 commit into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:ios-v2

Conversation

@mwolson

@mwolson mwolson commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep mobile/client-runtime code compatible with Release iOS Hermes by avoiding unsupported newer Array helpers.
  • Make turn dispatch attachments, navigation headers, and crash diagnostics reliable on device Release builds.
  • Stabilize compact phone thread taps (RNGH Pressable) without removing long-press menus that main already has.
  • Stabilize cold thread opens and warm reconnect state after sleep, and surface real failure messages in thread and environment error states.
  • Improve the chat feed: explicit loading placeholder, disabled vertical overscroll-bounce, full-width native selectable markdown, anchored scrolling during composer and keyboard inset changes, and scroll-up that is no longer blocked after a turn completes.
  • Hide subagent threads from the phone thread list, matching the desktop sidebar.

Problem and Fix

Problem and Why it Happened Fix
Release iOS Hermes does not support newer array copy helpers used by mobile-facing shared code. Replace toSorted, toReversed, and findLast on mobile/client-runtime paths with compatible copy, sort, and reverse iteration logic.
Draft images carrying both a client id and a dataUrl were dispatched with draft-only ids, so turns could reference attachments the server never stored. Persist dual-tagged draft attachments through assetsPersistChatAttachments and dispatch turns with server-assigned attachment ids; mobile send paths strip draft-only fields via toUploadChatImageAttachments.
Native selectable markdown could drop trailing words while streaming (stale measured content republished when Yoga laid out without measuring) and could clip wrapped text in shrink-to-fit bubbles. Rebuild attributed content from current children on each layout pass, publish shadow-node state only when it changed, and add a fillWidth path so assistant markdown stretches to the available feed width.
HomeHeader and NativeStackScreenOptions re-applied fresh option objects every render, causing setOptions update loops (maximum update depth) that crashed Release builds. Memoize header options with ref-routed callbacks and skip re-applying identical option objects.
Fatal JS errors on device Release builds vanished without a trace. Install a crash logger at app entry that persists fatal JS errors to disk and reports recent crashes on the next launch.
Opening a thread could sit on "Loading messages..." after navigation reached the route. Expose route-level detail refresh, surface selected-thread atom failures through the detail error path, and retry stalled empty detail loads after 2.5 seconds and then 6 seconds while connected.
Opening a cold or long-running thread could show the route chrome, composer, and run status while the feed area stayed blank during detail catch-up. Render a loading placeholder until the selected thread detail snapshot is available instead of mounting an empty virtualized feed.
After phone sleep, warm thread content could remain stuck behind "Syncing messages..." until another event arrived. Mark warm cached thread and shell state live when the supervisor is ready and data already exists.
Thread and environment error states discarded string and tagged-object failure payloads, always showing a generic fallback message. Add a shared causeFailureMessage helper that surfaces Error messages, string failures, and objects with a string message, using the caller-supplied fallback only when nothing usable exists.
On the v2 stack, compact Home rows could fail to open the thread on tap when the long-press menu and swipeable competed with RN Pressable. Main already keeps both tap and long-press (menu wrap + shouldOpenOnLongPress). Keep the existing ControlPillMenu long-press wrap (same as main/CTM). Use RNGH Pressable for the compact row body so tap-to-open stays reliable next to long-press and swipe. Archive/delete stay on long-press menu and swipe.
Subagent threads appeared in the phone thread list even though the desktop sidebar hides them. Filter subagent threads out of the home thread grouping, matching desktop.
The chat feed jumped near the top while typing: composer growth and keyboard inset changes applied new contentInset values without preserving the visible offset. Extend the keyboard-controller and Legend List patches so inset changes preserve the anchored scroll offset, including a setContentInset: override on the patched list.
After a turn completed, scrolling up rubber-banded back to the bottom: Legend List measured its maintain-at-end zone from the raw scroll offset, so grown content kept re-entering the at-end zone. Measure the maintain-at-end threshold from the scroll-range end (contentSize - scroll - scrollLength) so only genuinely at-end positions maintain scroll position.
The thread feed could overscroll-bounce past the fork/header boundary and reveal blank space above anchored content. Disable vertical bounce on the main thread message list while preserving the upstream scroll anchoring and adjusted-inset behavior.

UI Changes

  • Cold thread detail catch-up now shows a feed loading placeholder instead of a blank feed.
  • Compact Home thread rows still support tap-to-open and long-press archive/delete (same product shape as main). This PR only switches the compact row body to RNGH Pressable so those gestures coexist more reliably on the v2 stack.
  • Thread feed end-inset leaves a small gap so the last message clears the floating composer.
  • Thread message lists no longer vertically overscroll-bounce beyond their content, and scrolling up after a completed turn no longer snaps back to the bottom.
  • The feed stays visually anchored while the composer grows or the keyboard opens.
  • Assistant native selectable markdown now fills the available message width so wrapped response text is not clipped.
  • Thread and environment error banners show the underlying failure message when one is available.
  • Subagent threads no longer appear in the phone thread list.

Validation

  • vp check and vp run typecheck on the squashed single commit.
  • vp test packages/client-runtime/src/state/threads-sync.test.ts packages/client-runtime/src/state/shell-sync.test.ts
  • vp test apps/mobile/src/lib/nativeMarkdownText.test.ts
  • vp test packages/client-runtime/src/errors/causeMessage.test.ts (8 tests for the shared failure-message helper)
  • vp run lint:mobile passed, with SwiftLint, ktlint, and detekt skipped because they are not installed locally.
  • Built and installed a Release iOS app from pure trees/ios-v2 (base t3code/codex-turn-mapping, tip only) on Simulator and on MOiPhone25 (personal signing for the device install).
  • Phone-confirmed reproductions: the chat feed no longer jumps near the top while typing, and scroll-up after a completed turn no longer rubber-bands to the bottom.
  • Earlier device checks also ran from the refreshed orchestrator-v2 integration tree.
  • Grok follow-up review: no blocking issues remain. It called out a non-blocking residual risk that the bounded detail retry can restart slow cold loads.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Note

Fix iOS v2 runtime paths, image attachment ids, header stability, and markdown layout

  • Fixes T3MarkdownTextShadowNode layout to derive content from current children on each layout pass and only call setStateData when content changes, preventing stale or dropped text on iOS.
  • Fixes image attachment id remapping in persistAttachments: dual-tagged draft attachments (with both a client id and dataUrl) are now uploaded via assetsPersistChatAttachments and replaced with server-assigned ids before dispatch.
  • Replaces all Array.prototype.findLast, toSorted, and toReversed calls across runtime state modules with explicit reverse loops and copy-then-sort patterns for iOS v2 compatibility.
  • Makes HomeHeader options referentially stable via useMemo/propsRef and guards NativeStackScreenOptions against re-applying identical option objects, preventing navigation update loops.
  • On a ready event, EnvironmentThreadState and EnvironmentShellState now transition to live when cached data exists, rather than staying synchronizing; adds auto-retry (2.5s and 6s) for missing thread detail while connected.
  • Adds a crash logger installed at app startup that persists fatal JS errors to disk and reports recent crashes on next launch.
  • Excludes subagent threads from the home thread list, matching desktop sidebar behavior.

Macroscope summarized f350d96.

Note

Medium Risk
Touches turn dispatch attachment persistence, native Fabric markdown state, and heavily patched scroll/keyboard inset behavior on iOS; regressions could affect message sending, thread layout, or post-reconnect sync UX.

Overview
This PR tightens mobile iOS reliability across chat UI, sync, and turn delivery, with supporting client-runtime and vendor patch changes.

Native markdown no longer caches measured text in mutable shadow-node state; layout rebuilds content from children and updates state only when it changed, fixing dropped text when Yoga layouts without measuring. Assistant messages use fillWidth so markdown reflows correctly in shrink-to-fit bubbles.

Image attachments on mobile now strip draft-only fields via toUploadChatImageAttachments before startTurn. persistAttachments treats anything with dataUrl as an upload (even if it also has a client id), persists through assetsPersistChatAttachments, and dispatches with server attachment ids.

Navigation / home: HomeHeader memoizes stack options and routes callbacks through a ref to avoid setOptions loops; NativeStackScreenOptions skips re-applying identical option objects. Home thread grouping hides subagent threads like desktop. Compact list rows use gesture-handler Pressable for better long-press / swipe behavior.

Thread experience: dedicated loading feed placeholder; 2.5s / 6s detail refresh retries while connected but detail is missing; thread queries expose refresh and shared causeFailureMessage errors. Composer/list spacing adds a 24px gap above the floating composer. Fatal JS crash logging installs at entry via installCrashLog.

Reconnect: shell and per-thread state return to live immediately when cached projection/snapshot exists after reconnect, without waiting for new stream events.

Compatibility: widespread replacement of findLast / toSorted / toReversed with manual reverse loops in client-runtime and mobile state.

Patches extend @legendapp/list and react-native-keyboard-controller (and a small native-stack mail toolbar tweak) for translucent-header insets, composer height adjustment, and scroll offset preservation under automatic content insets.

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

@coderabbitai

coderabbitai Bot commented Jul 8, 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: 5e343971-a3dd-4245-8fe9-ca39d5e036e5

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 vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Jul 8, 2026
Comment thread packages/client-runtime/src/state/threads.ts
Comment on lines +182 to +184
if (routeConnectionState !== "connected") {
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.

🟡 Medium threads/ThreadRouteScreen.tsx:182

The retry counter in detailRefreshAttemptsRef is never reset when the route drops out of the "connected" state. After the two scheduled refreshes are used once, a later disconnect/reconnect on the same thread leaves the counter at 2, so THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts] is undefined and the effect never schedules another refresh. A thread detail that is still empty after reconnect stays stuck on the loading/unavailable path indefinitely.

The effect bails out early when routeConnectionState !== "connected", so the attempt counter is only deleted when detail arrives or the thread changes — not on disconnect. Consider resetting the counter whenever routeConnectionState leaves "connected".

Suggested change
if (routeConnectionState !== "connected") {
return;
}
if (routeConnectionState !== "connected") {
detailRefreshAttemptsRef.current.delete(routeThreadKey);
return;
}
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around lines 182-184:

The retry counter in `detailRefreshAttemptsRef` is never reset when the route drops out of the `"connected"` state. After the two scheduled refreshes are used once, a later disconnect/reconnect on the same thread leaves the counter at 2, so `THREAD_DETAIL_STALL_RETRY_DELAYS_MS[attempts]` is `undefined` and the effect never schedules another refresh. A thread detail that is still empty after reconnect stays stuck on the loading/unavailable path indefinitely.

The effect bails out early when `routeConnectionState !== "connected"`, so the attempt counter is only deleted when detail arrives or the thread changes — not on disconnect. Consider resetting the counter whenever `routeConnectionState` leaves `"connected"`.

@mwolson mwolson force-pushed the ios-v2 branch 2 times, most recently from 44d02f1 to 2bd64fe Compare July 8, 2026 20:21
Comment on lines +39 to +44
function formatThreadStateFailure(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
return error instanceof Error && error.message.trim().length > 0
? error.message
: "Could not load conversation.";
}

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 state/threads.ts:39

formatThreadStateFailure discards non-Error failure payloads. Cause.squash returns the original error value for Cause.fail, so a string error like Cause.fail("nope") yields "nope", but the error instanceof Error check falls through to the generic "Could not load conversation." message, hiding the real failure text. Consider converting non-Error values to a string (e.g. String(error)) before checking whether it is non-empty.

Suggested change
function formatThreadStateFailure(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
return error instanceof Error && error.message.trim().length > 0
? error.message
: "Could not load conversation.";
}
function formatThreadStateFailure(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
const message = error instanceof Error ? error.message : typeof error === "string" ? error : String(error);
return message.trim().length > 0
? message
: "Could not load conversation.";
}
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/state/threads.ts around lines 39-44:

`formatThreadStateFailure` discards non-`Error` failure payloads. `Cause.squash` returns the original error value for `Cause.fail`, so a string error like `Cause.fail("nope")` yields `"nope"`, but the `error instanceof Error` check falls through to the generic `"Could not load conversation."` message, hiding the real failure text. Consider converting non-`Error` values to a string (e.g. `String(error)`) before checking whether it is non-empty.

return;
}

if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") {

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:177

The retry effect at lines 168–222 fires refresh() even when the failed load already produced an error. When the detail is null but selectedThreadDetailState.error is populated (a real server-side fetch failure), the effect treats it the same as a stalled empty load and schedules two extra refresh() calls against the same failing request, producing duplicate network traffic and repeated error churn. Consider adding selectedThreadDetailState.error to the early-return guard alongside the null-detail and deleted-status checks so retries only fire for the intended stalled-empty case.

-    if (selectedThreadDetail !== null || selectedThreadDetailState.status === "deleted") {
+    if (
+      selectedThreadDetail !== null ||
+      selectedThreadDetailState.error !== null ||
+      selectedThreadDetailState.status === "deleted"
+    ) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 177:

The retry effect at lines 168–222 fires `refresh()` even when the failed load already produced an error. When the detail is `null` but `selectedThreadDetailState.error` is populated (a real server-side fetch failure), the effect treats it the same as a stalled empty load and schedules two extra `refresh()` calls against the same failing request, producing duplicate network traffic and repeated error churn. Consider adding `selectedThreadDetailState.error` to the early-return guard alongside the `null`-detail and `deleted`-status checks so retries only fire for the intended stalled-empty case.

return useSelectedThreadDetailQuery().state;
}

export function useSelectedThreadDetailQuery() {

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 state/use-thread-detail.ts:37

useSelectedThreadDetailQuery builds its target from selectedThread, which stays null during a cold open before the shell list materializes. The hook therefore queries (null, null) and its refresh only touches the empty-state atom, so the retry logic in ThreadRouteScreen can never refresh the real thread detail — the route stays stuck on the loading screen in the exact stalled-load case this PR intends to recover. The target should be derived from the route-backed thread id (useThreadSelection / route params) rather than selectedThread.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/state/use-thread-detail.ts around line 37:

`useSelectedThreadDetailQuery` builds its target from `selectedThread`, which stays `null` during a cold open before the shell list materializes. The hook therefore queries `(null, null)` and its `refresh` only touches the empty-state atom, so the retry logic in `ThreadRouteScreen` can never refresh the real thread detail — the route stays stuck on the loading screen in the exact stalled-load case this PR intends to recover. The target should be derived from the route-backed thread id (`useThreadSelection` / route params) rather than `selectedThread`.

Comment thread patches/@react-navigation%2Fnative-stack@7.17.6.patch
@mwolson mwolson marked this pull request as ready for review July 8, 2026 20:34
@macroscopeapp

macroscopeapp Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

10 blocking correctness issues found. Multiple unresolved review comments identify potential bugs in thread retry logic, state synchronization timing, and scroll behavior. The PR also introduces new crash logging infrastructure and modifies native iOS code and third-party library patches, warranting careful human review.

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

Comment on lines +103 to +107
const setReady = SubscriptionRef.update(state, (current) => ({
...current,
status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const),
error: Option.none(),
}));

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 state/shell.ts:103

setReady now sets the shell status to "live" whenever a cached snapshot exists, even when the shell subscription has not yet replayed missed updates after reconnect. On reconnect with stale cached data and pending server-side changes, the UI stops showing "synchronizing" and treats the shell as current until the first stream item arrives, so consumers briefly render out-of-date environment data as if synchronization completed. The previous implementation preserved the existing "live" status but otherwise stayed in "synchronizing"; the new version should similarly avoid promoting to "live" before the stream confirms it is up to date. Consider keeping the prior guard (only stay "live" if already "live", otherwise set "synchronizing") or waiting for the first stream item before marking "live".

-  const setReady = SubscriptionRef.update(state, (current) => ({
-    ...current,
-    status: Option.isSome(current.snapshot) ? ("live" as const) : ("synchronizing" as const),
-    error: Option.none(),
-  }));
+  const setReady = SubscriptionRef.update(state, (current) =>
+    current.status === "live"
+      ? current
+      : {
+          ...current,
+          status: "synchronizing" as const,
+          error: Option.none(),
+        },
+  );
🤖 Copy this AI Prompt to have your agent fix this:
In file @packages/client-runtime/src/state/shell.ts around lines 103-107:

`setReady` now sets the shell status to `"live"` whenever a cached snapshot exists, even when the shell subscription has not yet replayed missed updates after reconnect. On reconnect with stale cached data and pending server-side changes, the UI stops showing `"synchronizing"` and treats the shell as current until the first stream item arrives, so consumers briefly render out-of-date environment data as if synchronization completed. The previous implementation preserved the existing `"live"` status but otherwise stayed in `"synchronizing"`; the new version should similarly avoid promoting to `"live"` before the stream confirms it is up to date. Consider keeping the prior guard (only stay `"live"` if already `"live"`, otherwise set `"synchronizing"`) or waiting for the first stream item before marking `"live"`.

);
}

if (props.contentPresentation.kind === "loading") {

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:1648

When contentPresentation transitions from loading to ready while the feed is still empty, the KeyboardAwareLegendList mounts with a zero bottom content inset. The useLayoutEffect that calls props.listRef.current?.reportContentInset({ bottom }) only reruns when listMountKey changes, and listMountKey stays ${threadId}:empty across the loading→ready transition — so the effect already ran while the list was unmounted (ref was null) and never fires again for the newly mounted instance. The composer inset is never reported, leaving the list with a zero bottom inset until a separate inset change occurs, causing empty threads to rest one composer-height too low after loading clears. Consider including contentPresentation.kind in listMountKey, or adding props.contentPresentation.kind to the effect's dependency array so it reruns when the list mounts.

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

When `contentPresentation` transitions from `loading` to `ready` while the feed is still empty, the `KeyboardAwareLegendList` mounts with a zero bottom content inset. The `useLayoutEffect` that calls `props.listRef.current?.reportContentInset({ bottom })` only reruns when `listMountKey` changes, and `listMountKey` stays `${threadId}:empty` across the loading→ready transition — so the effect already ran while the list was unmounted (ref was `null`) and never fires again for the newly mounted instance. The composer inset is never reported, leaving the list with a zero bottom inset until a separate inset change occurs, causing empty threads to rest one composer-height too low after loading clears. Consider including `contentPresentation.kind` in `listMountKey`, or adding `props.contentPresentation.kind` to the effect's dependency array so it reruns when the list mounts.

@mwolson mwolson force-pushed the ios-v2 branch 2 times, most recently from bebf066 to 4e4c4b5 Compare July 9, 2026 01:10
// mount positions during attach, where UIKit applies the inset.
key={listMountKey}
style={{ flex: 1 }}
bounces={false}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium threads/ThreadFeed.tsx:1676

Setting bounces={false} together with alwaysBounceVertical={false} on the KeyboardAwareLegendList makes the pure-inset blank region (anchoredEndSpace/contentInset) immovable. Short threads get stuck under the composer/header with no way to drag the empty space into view, even though the existing applyWorkaroundForContentInsetHitTestBug prop shows the feed intentionally relies on the user being able to scroll that inset region. Consider removing alwaysBounceVertical={false} (or both props) so the inset area remains scrollable while still controlling overshcroll where needed.

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

Setting `bounces={false}` together with `alwaysBounceVertical={false}` on the `KeyboardAwareLegendList` makes the pure-inset blank region (`anchoredEndSpace`/`contentInset`) immovable. Short threads get stuck under the composer/header with no way to drag the empty space into view, even though the existing `applyWorkaroundForContentInsetHitTestBug` prop shows the feed intentionally relies on the user being able to scroll that inset region. Consider removing `alwaysBounceVertical={false}` (or both props) so the inset area remains scrollable while still controlling overshcroll where needed.

Comment thread patches/@react-navigation%2Fnative-stack@7.17.6.patch
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/features/threads/thread-list-items.tsx

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

const [viewportHeight, setViewportHeight] = useState(0);

The new loading early return at line 1649 renders ThreadFeedPlaceholder instead of the <View onLayout={handleViewportLayout}> wrapper, so viewportHeight stays 0 (and viewportWidth stays 0 in split layout) while the thread is loading. When the state transitions to ready, the KeyboardAwareLegendList mounts with viewportHeight === 0, so estimatedListSize is omitted and the list has no seeded viewport size for its attach-time scroll math — the feed can initially position too high or too low under the header/composer until a layout pass corrects it. Consider seeding viewportHeight/viewportWidth from useWindowDimensions() or restructuring the early returns so the viewport-measuring View stays mounted across the loading→ready transition.

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

The new `loading` early return at line 1649 renders `ThreadFeedPlaceholder` instead of the `<View onLayout={handleViewportLayout}>` wrapper, so `viewportHeight` stays `0` (and `viewportWidth` stays `0` in split layout) while the thread is loading. When the state transitions to `ready`, the `KeyboardAwareLegendList` mounts with `viewportHeight === 0`, so `estimatedListSize` is omitted and the list has no seeded viewport size for its attach-time scroll math — the feed can initially position too high or too low under the header/composer until a layout pass corrects it. Consider seeding `viewportHeight`/`viewportWidth` from `useWindowDimensions()` or restructuring the early returns so the viewport-measuring `View` stays mounted across the loading→ready transition.

@mwolson mwolson force-pushed the ios-v2 branch 2 times, most recently from 91d3b42 to b6bf722 Compare July 10, 2026 00:09
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jul 10, 2026
@mwolson mwolson changed the title [orchestrator-v2] fix(mobile): Stabilize iOS v2 runtime paths fix(mobile): Stabilize iOS v2 runtime paths, image attach ids, and Home header Jul 10, 2026
@mwolson mwolson changed the title fix(mobile): Stabilize iOS v2 runtime paths, image attach ids, and Home header fix(mobile): Stabilize iOS v2 runtime paths, image attach ids, header, and markdown text Jul 10, 2026
@github-actions github-actions Bot removed the size:XL 500-999 changed lines (additions + deletions). label Jul 11, 2026
@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Jul 11, 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([]) with an empty dependency array, so it permanently captures the onScrollBeginDrag prop from the first render. If the parent replaces or removes that callback after mount, drag events still invoke the stale callback (or undefined when it was removed). Store the callback in a ref or include it in the memo dependencies so the wrapper always calls the current prop.

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

The `onScrollBeginDrag` wrapper is created inside a `useMemo([])` with an empty dependency array, so it permanently captures the `onScrollBeginDrag` prop from the first render. If the parent replaces or removes that callback after mount, drag events still invoke the stale callback (or `undefined` when it was removed). Store the callback in a ref or include it in the memo dependencies so the wrapper always calls the current prop.

@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 3 potential issues.

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 41b3536. Configure here.

Comment thread patches/@legendapp__list@3.2.0.patch
Comment thread patches/@legendapp__list@3.2.0.patch
Comment thread patches/@legendapp__list@3.2.0.patch
…ad UX

- Replace Hermes-unsupported array helpers on mobile and client-runtime paths.
- Remap draft image attachments to server ids before dispatching turns.
- Fix native selectable markdown dropping trailing words while streaming and
  clipping wrapped text; fill the available message width.
- Stop Home/Threads header setOptions loops that crashed Release builds.
- Add a persisted fatal JS crash logger with startup replay.
- Retry stalled thread detail loads and restore live status after reconnect.
- Use RNGH Pressable for compact thread rows; disable feed overscroll bounce.
- Hide subagent threads from the thread list, matching the desktop sidebar.
- Keep the chat feed anchored during composer and keyboard inset changes
  (keyboard-controller and Legend List patches).
- Measure Legend List's maintain-at-end zone from the scroll-range end so
  completed turns no longer rubber-band scroll-up attempts.
- Surface string and tagged-object failure messages in thread and environment
  error states instead of a generic fallback.
Comment on lines +71 to 72
const lastAppliedOptionsRef = useRef<NativeStackNavigationOptions | undefined>(undefined);
const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);

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/StackHeader.tsx:71

When the navigation object changes but normalizedOptions stays referentially stable, the effect returns early because lastAppliedOptionsRef.current === normalizedOptions, so navigation.setOptions is never called for the new navigation context. The deduplication guard ignores navigation, so options are silently dropped on a navigation change. Consider resetting lastAppliedOptionsRef.current when navigation changes so the options are re-applied to the new navigator.

Suggested change
const lastAppliedOptionsRef = useRef<NativeStackNavigationOptions | undefined>(undefined);
const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);
const lastAppliedOptionsRef = useRef<NativeStackNavigationOptions | undefined>(undefined);
const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);
useEffect(() => {
lastAppliedOptionsRef.current = undefined;
}, [navigation]);
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/native/StackHeader.tsx around lines 71-72:

When the `navigation` object changes but `normalizedOptions` stays referentially stable, the effect returns early because `lastAppliedOptionsRef.current === normalizedOptions`, so `navigation.setOptions` is never called for the new navigation context. The deduplication guard ignores `navigation`, so options are silently dropped on a navigation change. Consider resetting `lastAppliedOptionsRef.current` when `navigation` changes so the options are re-applied to the new navigator.

@mwolson

mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by the more minimal CTM mobile PR #3923 (fix/ios-v2-attachment-persistence).

#3923 keeps the proven subset (draft attachment id persistence, Hermes toSortedcopySorted, subagent Home hide, Home nav stability) without the larger ios-v2 surface.

Keeping the ios-v2 branch around for local reference only; it is not part of the current orchestrator-v2 integration stack and is not intended for merge as-is.

@mwolson mwolson closed this Jul 12, 2026
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