Skip to content

[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923

Open
mwolson wants to merge 138 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/ios-v2-attachment-persistence
Open

[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923
mwolson wants to merge 138 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/ios-v2-attachment-persistence

Conversation

@mwolson

@mwolson mwolson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep CTM mobile draft image attachments working when they carry both a local id and a dataUrl.
  • Avoid a Hermes fatal in the queue UI by replacing missing Array.prototype.toSorted with a shared copy-sort helper.
  • Hide orchestration-v2 subagent threads from the mobile Home list (forks stay visible).
  • Stabilize Home native-stack options and callbacks so ordinary Home updates do not re-enter navigation through PreventRemoveProvider.
  • Normalize V2 shell active/archive membership from archivedAt so archived threads do not stick on the Home list after a missed archive delta.

Base

This branch is now built on mwolson/t3code v2-base, a merge of the shared iOS/mobile fixes line (ios-fixes-main) into t3code/codex-turn-mapping. Because GitHub diffs against t3code/codex-turn-mapping, the PR diff includes that merge (mobile keyboard/feed fixes, shell reconnect heal, serverConfig/vcsRefs caching, and the V2 thread resume completion marker) in addition to the two commits listed below. Review the two head commits for this PR's own delta.

Problem and Fix

Problem and Why it Happened Fix
CTM drafts can include image attachments with both a local id and a dataUrl. The start-turn path did not treat those dual-tagged attachments as uploads, so order and persistence broke for mobile screenshots. Treat dual-tagged attachments as uploads, strip local-only fields before persistence, and dispatch server IDs while preserving order with already-persisted attachments.
Hermes 250829098 (RN 0.85.3) does not implement Array.prototype.toSorted. CTM queue UI calls it from deriveThreadQueueWorkflowState / ThreadQueueControl, so queuing a reply or finishing a turn can crash with TypeError: undefined is not a function. Add @t3tools/shared/Array copySorted ([...values].sort) and use it on mobile-reachable call sites (threadWorkflows, ThreadRelationshipsBanner, use-thread-selection, CTM model.ts).
Orchestration-v2 subagent shells appeared in the mobile Home list, unlike the desktop sidebar. Skip threads with lineage.relationshipToParent === "subagent" when grouping Home rows; keep fork threads.
Unstable Home native-stack options and callbacks re-entered setOptions and could hit maximum update depth through PreventRemoveProvider on CTM device launches. Memoize iOS Home header options (filter menu inputs as deps; handlers via ref) and stabilize Settings / New Task callbacks and title option objects.
A dropped thread.archived shell delta (or a mis-tagged full snapshot) could keep an archived thread listed as active on Home even after the reconnect heal applied a fresh snapshot. Add normalizeShellThreadMembership and apply it to every full snapshot (reducer enrichment refreshes and reconnect heals) so active/archive membership always derives from archivedAt. The HTTP heal, forced socket snapshot, and stale-snapshot rejection it pairs with live in v2-base.

Validation

  • Targeted tests: client-runtime attachment command tests; Home subagent filter tests (including fork negative control); shared Array tests; shell-sync / shellReducer membership tests.
  • vp check (0 errors) and full vp run typecheck on the rebuilt branch.
  • Device: archived threads from a paired CTM environment leave Home after reconnect/heal.
  • Device: Release Hermes com.mwolson.t3code.v2.dev from local orchestrator-v2 integration; queued reply send after active turn finished without crash; Home list usable after nav stability fix.

Notes

Note

Stabilize iOS draft attachments, Hermes array sort, and Home screen for mobile

This is a large orchestrator-v2 stabilization PR with dozens of changes across mobile, web, server, and desktop. Key highlights:

  • iOS draft attachments: persistAttachments now correctly maps upload results back to their source positions and strips client-only fields (id, previewUri) before sending to the server.
  • Hermes sort fix: Replaces Array.prototype.toSorted (unsupported on Hermes) with a shared copySorted utility throughout mobile thread sorting.
  • Home screen (Android): Adds a floating new-task FAB, custom branded header with inline search, and persists collapsed project groups via preferences atoms.
  • Draft hero UX: Web chat view gains a centered composer for empty drafts with animated docking transitions and a DraftHeroHeadline project picker.
  • Thread feed scroll correction: ThreadFeed now auto-corrects scroll position when content underflows the viewport and shows a full-screen loading overlay during hydration.
  • iOS terminal stability: TerminalInputSequence normalizes all return keys to carriage return (\r); autoFocus prop added to control keyboard focus.
  • Shell state synchronization: makeEnvironmentShellState no longer paints cached snapshots immediately; starts in synchronizing and waits for an HTTP heal or authoritative socket snapshot.
  • Thread resume completion marker: Server and client cooperate on a synchronized marker so warm-start clients exit the syncing state faster.
  • Android terminal: Full native terminal canvas (TerminalCanvasView, GhosttyBridge) with JNI bindings, selection, scroll, and theme support.
  • Incoming share flow (iOS/Android): IncomingShareProvider and IncomingShareInbox serialize share ingestion, merge content into composer drafts idempotently, and clean up temporary files.
  • Legal pages: Marketing site gains Privacy Policy, Terms of Service, and Security Policy pages with a shared LegalPage layout component.
  • Risk: IndexedDB schema bumped from v2 to v4; upgrade path adds new object stores but no migration for existing server-config/vcs-refs data (cold start for those caches).

Macroscope summarized 47dc628.


Note

Medium Risk
Touches desktop launcher/signing behavior, auto-update state, and substantial mobile native/Expo configuration; misconfiguration could affect local iOS builds, store screenshots CI, or renderer chrome, but changes are mostly additive with tests on key desktop paths.

Overview
Adds a manual GitHub workflow to capture and validate iOS and Android store showcase screenshots (pnpm screenshots:mobile), upload artifacts, and ignore generated .showcase/ / artifacts/app-store/screenshots/ paths.

Desktop changes improve the macOS dev launcher (branded bundle with a separate launcher executable that execs in-bundle Electron, launcher version bump), context menu positioning scaled by webContents zoom, native fullscreen state exposed to the renderer via IPC/preload, nightly update release notes (setFullChangelog + HTML/markdown normalization into update state), and WSL Node engine validation during probe.

Marketing adds a shared legal document layout, index plus privacy/terms/security pages, footer links, and Grok CLI messaging/assets on the homepage (with minor harness/layout tweaks).

Mobile expands Expo config: repo-root brand assets, Personal Team local iOS builds (optional bundle id, stripped widgets/share/SiWA entitlements), more Android plugins (Gradle heap, predictive back, modern menus/alerts, crash log), DM Sans registered under native PostScript names, and a new Android T3ComposerEditor native view (chips, paste images, theming). iOS markdown shadow node now rebuilds attributed content from current children instead of stale measure-time cache. Composer iOS fonts align to DMSans-Regular / DMSans-Medium.

Also bumps electron-builder, adds minimal root app.json, extends Macroscope Effect service conventions (dependency acquisition / runtime boundaries), and minor asset/doc updates.

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

juliusmarminge and others added 17 commits July 7, 2026 15:55
…gg#3781)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
…pingdotgg#3795)

Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
…tgg#3823)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Horus Lugo <horusgoul@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: maria-rcks <maria@kuuro.net>
Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com>
Co-authored-by: Ben Davis <45952064+bmdavis419@users.noreply.github.com>
Co-authored-by: Alex <me@pixp.cc>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>
@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: 8bbf5543-c8a1-4f1c-8bfe-1d903c77b419

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 12, 2026
Comment thread apps/mobile/src/features/home/HomeHeader.tsx
@mwolson
mwolson marked this pull request as ready for review July 12, 2026 21:35
Comment thread packages/shared/src/Array.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from 2857eb3 to 76a88a0 Compare July 12, 2026 21:41
@mwolson mwolson changed the title fix(mobile): Stabilize CTM iOS draft attachments, Hermes sort, and Home [orchestrator-v2] fix(mobile): Stabilize CTM iOS draft attachments, Hermes sort, and Home Jul 12, 2026
mwolson added 2 commits July 12, 2026 18:53
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.
CI Mobile Native Static Analysis failed on a multi-line if where the
opening brace was on the following line.
juliusmarminge and others added 17 commits July 18, 2026 13:05
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
…gg#3608)

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>
…obe success (pingdotgg#3621)

Co-authored-by: Julius Marminge <julius0216@outlook.com>
fatal: true,
} as const;
}
return { ok: true, nodePath, resolvedPath } as const;

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.

WSL version check bypass

Medium Severity

The new WSL Node.js engine version check can be bypassed. If the nodeVersion: line is missing or empty in the probe script's output, the check is skipped, allowing an out-of-range Node.js version to be accepted even when an engine range is specified.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dd80212. Configure here.

Comment thread apps/mobile/src/features/showcase/nativeShowcaseScene.ts
return sourceCharacter !== "`" && sourceCharacter !== "~";
}

function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown {

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 src/markdown-list-indentation.ts:59

parseRecoveredMarkdown prepends INLINE_PARSE_PREFIX to the recovered text and re-parses it as a paragraph, which prevents GFM task-list syntax ([ ] task) from being recognized. For an over-indented task item like - [ ] task, the recovered [ ] task is parsed as ordinary paragraph text instead of setting the enclosing listItem.checked metadata, so ChatMarkdown renders no checkbox and onTaskListChange cannot work. Task-list markers are list-item syntax, not inline extensions, so they are lost during recovery. Consider detecting and preserving the task-list marker — or reconstructing listItem.checked — when recovering the outer list item.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/markdown-list-indentation.ts around line 59:

`parseRecoveredMarkdown` prepends `INLINE_PARSE_PREFIX` to the recovered text and re-parses it as a paragraph, which prevents GFM task-list syntax (`[ ] task`) from being recognized. For an over-indented task item like `-       [ ] task`, the recovered `[ ] task` is parsed as ordinary paragraph text instead of setting the enclosing `listItem.checked` metadata, so `ChatMarkdown` renders no checkbox and `onTaskListChange` cannot work. Task-list markers are list-item syntax, not inline extensions, so they are lost during recovery. Consider detecting and preserving the task-list marker — or reconstructing `listItem.checked` — when recovering the outer list item.

Comment on lines +1101 to +1103
const kept = value
.split(":")
.filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir));

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 terminal/Manager.ts:1101

stripAppImageRuntimeEnv drops empty components from PATH and LD_LIBRARY_PATH, not just entries under APPDIR. Empty colon-delimited components are meaningful on Linux — they represent the current working directory — so a PATH like /usr/bin: loses the trailing : entry and integrated terminals get a different command search path even though the comment promises to preserve all real user entries. The filter rejects segments with segment.length > 0, which discards empty components along with the AppImage mount. Consider filtering only by !isPathSegmentUnderAppDir(segment, appDir) so empty components are preserved.

      const kept = value
        .split(":")
-       .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir));
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/terminal/Manager.ts around lines 1101-1103:

`stripAppImageRuntimeEnv` drops empty components from `PATH` and `LD_LIBRARY_PATH`, not just entries under `APPDIR`. Empty colon-delimited components are meaningful on Linux — they represent the current working directory — so a `PATH` like `/usr/bin:` loses the trailing `:` entry and integrated terminals get a different command search path even though the comment promises to preserve all real user entries. The filter rejects segments with `segment.length > 0`, which discards empty components along with the AppImage mount. Consider filtering only by `!isPathSegmentUnderAppDir(segment, appDir)` so empty components are preserved.

) {
return;
}
reopenedStaleTerminalKeyRef.current = terminalKey;

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 terminal/ThreadTerminalRouteScreen.tsx:387

When openTerminal fails in the stale-session reopen path, clearing reopenedStaleTerminalKeyRef.current does not trigger a re-render, so the effect never re-runs and the screen stays attached to the dead closed/exited snapshot indefinitely. Consider tracking the retry state with useState so a failed reopen schedules a re-render and the effect can retry.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx around line 387:

When `openTerminal` fails in the stale-session reopen path, clearing `reopenedStaleTerminalKeyRef.current` does not trigger a re-render, so the effect never re-runs and the screen stays attached to the dead `closed`/`exited` snapshot indefinitely. Consider tracking the retry state with `useState` so a failed reopen schedules a re-render and the effect can retry.

if (parsed && isLoopbackHost(parsed.hostname)) {

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 browser/browserTargetResolver.ts:104

resolveBrowserNavigationTarget returns http://127.0.0.2:3000 unchanged when the environment is on 192.168.x.x or Tailscale, so the preview navigates to the client's own loopback interface instead of the environment. The guard uses the shared isLoopbackHost, which only recognizes 127.0.0.1, while this module's isLocalLoopbackHost recognizes the full 127.0.0.0/8 range. Consider using isLocalLoopbackHost for the guard so all 127.x.x.x addresses are remapped.

Suggested change
if (parsed && isLoopbackHost(parsed.hostname)) {
if (parsed && isLocalLoopbackHost(parsed.hostname)) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/browser/browserTargetResolver.ts around line 104:

`resolveBrowserNavigationTarget` returns `http://127.0.0.2:3000` unchanged when the environment is on `192.168.x.x` or Tailscale, so the preview navigates to the client's own loopback interface instead of the environment. The guard uses the shared `isLoopbackHost`, which only recognizes `127.0.0.1`, while this module's `isLocalLoopbackHost` recognizes the full `127.0.0.0/8` range. Consider using `isLocalLoopbackHost` for the guard so all `127.x.x.x` addresses are remapped.

container,
LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
)
applyTheme()

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 t3terminal/T3TerminalView.kt:168

A newly mounted terminal never focuses the hidden inputView or opens the keyboard when the autoFocus prop is omitted, even though the default true advertises autofocus as enabled. Kotlin property initialization assigns autoFocus = true without invoking its setter, and the init block no longer requests keyboard focus, so the focus path only runs if JS explicitly sends the prop. Consider calling requestKeyboardFocus() in init when autoFocus is true.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt around line 168:

A newly mounted terminal never focuses the hidden `inputView` or opens the keyboard when the `autoFocus` prop is omitted, even though the default `true` advertises autofocus as enabled. Kotlin property initialization assigns `autoFocus = true` without invoking its setter, and the `init` block no longer requests keyboard focus, so the focus path only runs if JS explicitly sends the prop. Consider calling `requestKeyboardFocus()` in `init` when `autoFocus` is `true`.

return rows.getOrNull(sticky.index)?.let { RowHit(it, sticky.top, sticky.bottom) }
}
}
val index = rowIndexAt(verticalOffset + y.toInt())

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 t3reviewdiff/T3ReviewDiffView.kt:890

Tapping blank space below the last row still triggers the final row's tap handler, so a tap on empty area can toggle a viewed checkbox, collapse a file, or long-press a line. rowHitAt passes verticalOffset + y to rowIndexAt, which clamps out-of-range coordinates to rows.lastIndex, so any y past the content is mapped to the last row instead of returning no hit. Consider returning null when verticalOffset + y exceeds rowOffsets.last().

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt around line 890:

Tapping blank space below the last row still triggers the final row's tap handler, so a tap on empty area can toggle a viewed checkbox, collapse a file, or long-press a line. `rowHitAt` passes `verticalOffset + y` to `rowIndexAt`, which clamps out-of-range coordinates to `rows.lastIndex`, so any y past the content is mapped to the last row instead of returning no hit. Consider returning `null` when `verticalOffset + y` exceeds `rowOffsets.last()`.

if (!mimeType) return null;

const payload = trimmed.slice(commaIndex + 1);
const runs: Array<string> = [];

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.

🟠 High src/imageMime.ts:83

parseBase64DataUrl allocates a new substring and pushes it into the runs array at every whitespace boundary. A valid payload with alternating base64 characters and whitespace (e.g. "A \n" repeated millions of times) creates millions of array entries and substrings before runs.join(""), consuming far more memory than the input and potentially crashing the server with an OOM. Consider accumulating into a single growing buffer or pre-sizing one output string instead of one allocation per run.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/imageMime.ts around line 83:

`parseBase64DataUrl` allocates a new substring and pushes it into the `runs` array at every whitespace boundary. A valid payload with alternating base64 characters and whitespace (e.g. `"A \n"` repeated millions of times) creates millions of array entries and substrings before `runs.join("")`, consuming far more memory than the input and potentially crashing the server with an OOM. Consider accumulating into a single growing buffer or pre-sizing one output string instead of one allocation per run.

// A share extension payload remains available until the containing app
// acknowledges it. Use a content-derived id so a crash after the durable
// write but before acknowledgement reuses the same inbox item.
const shareId = await this.dependencies.idForPayloads(payloads);

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 sharing/incoming-share-inbox.ts:91

refresh derives shareId from payload content alone, so when a user intentionally shares identical content a second time while the first inbox item is still pending, line 92 matches the existing draft, enters the replay branch, clears the native handoff, and silently discards the new share. The code comment on lines 129–130 states that duplicate handoffs with equal payloads must remain distinct, but the content-derived id collapses them. Consider incorporating a handoff-specific identity (e.g. a native payload identifier or timestamp) into the id so only true crash retries coalesce while intentional duplicate shares are preserved.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/mobile/src/features/sharing/incoming-share-inbox.ts around line 91:

`refresh` derives `shareId` from payload content alone, so when a user intentionally shares identical content a second time while the first inbox item is still pending, line 92 matches the existing draft, enters the replay branch, clears the native handoff, and silently discards the new share. The code comment on lines 129–130 states that duplicate handoffs with equal payloads must remain distinct, but the content-derived id collapses them. Consider incorporating a handoff-specific identity (e.g. a native payload identifier or timestamp) into the id so only true crash retries coalesce while intentional duplicate shares are preserved.

if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
continue;
}
const connection = Option.getOrElse(

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

allEnvironmentShellsBootstrappedAtom returns true for environments that haven't started connecting yet. Each per-environment stateAtom is initialized to AVAILABLE_CONNECTION_STATE before the supervisor stream emits, so an environment with no shell snapshot passes the connectionProjectionPhase(connection) !== "disconnected" check immediately after the catalog loads — before any connection or bootstrap attempt has occurred. This causes consumers to settle on the landing state with missing shells instead of waiting for initialization.

The Option.getOrElse fallback to AVAILABLE_CONNECTION_STATE treats the initial/unresolved state the same as a genuinely disconnected environment. Consider distinguishing the unresolved state (no stateAtom value yet) from a real "disconnected" phase so the atom doesn't report bootstrapping as complete prematurely.

Also found in 2 other location(s)

apps/mobile/src/features/threads/NewTaskRouteScreen.tsx:94

incomingShare is treated as absent whenever getShare(routeShareId) returns null, without checking the provider's loading state. On route restoration/deep-link startup, IncomingShareProvider initially has an empty drafts array while it asynchronously loads persisted shares, so the picker renders as an ordinary task picker; a quick project selection then navigates with incomingShareId: undefined, silently dropping the shared content from the draft.

apps/mobile/src/features/home/HomeScreen.tsx:200

updateGroupDisplay persists collapsedProjectGroups by rebuilding the array only from the currently available display-state map. If a user toggles a group while preferencesResult is still loading, that map does not contain the previously persisted collapsed groups, so savePreferences overwrites the stored array and silently forgets every other collapsed group. The optimistic preference layer protects the new write from the eventual load result, but it cannot merge unknown entries that were omitted from this replacement array.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/state/shell.ts around line 34:

`allEnvironmentShellsBootstrappedAtom` returns `true` for environments that haven't started connecting yet. Each per-environment `stateAtom` is initialized to `AVAILABLE_CONNECTION_STATE` before the supervisor stream emits, so an environment with no shell snapshot passes the `connectionProjectionPhase(connection) !== "disconnected"` check immediately after the catalog loads — before any connection or bootstrap attempt has occurred. This causes consumers to settle on the landing state with missing shells instead of waiting for initialization.

The `Option.getOrElse` fallback to `AVAILABLE_CONNECTION_STATE` treats the initial/unresolved state the same as a genuinely disconnected environment. Consider distinguishing the unresolved state (no `stateAtom` value yet) from a real `"disconnected"` phase so the atom doesn't report bootstrapping as complete prematurely.

Also found in 2 other location(s):
- apps/mobile/src/features/threads/NewTaskRouteScreen.tsx:94 -- `incomingShare` is treated as absent whenever `getShare(routeShareId)` returns `null`, without checking the provider's loading state. On route restoration/deep-link startup, `IncomingShareProvider` initially has an empty `drafts` array while it asynchronously loads persisted shares, so the picker renders as an ordinary task picker; a quick project selection then navigates with `incomingShareId: undefined`, silently dropping the shared content from the draft.
- apps/mobile/src/features/home/HomeScreen.tsx:200 -- `updateGroupDisplay` persists `collapsedProjectGroups` by rebuilding the array only from the currently available display-state map. If a user toggles a group while `preferencesResult` is still loading, that map does not contain the previously persisted collapsed groups, so `savePreferences` overwrites the stored array and silently forgets every other collapsed group. The optimistic preference layer protects the new write from the eventual load result, but it cannot merge unknown entries that were omitted from this replacement array.

@mwolson
mwolson force-pushed the fix/ios-v2-attachment-persistence branch from dd80212 to 30b5502 Compare July 18, 2026 14:46
// Prefer a non-null archivedAt when the same id appears in both lists.
if (existing.archivedAt === null && thread.archivedAt !== null) {
byId.set(String(thread.id), thread);
}

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.

Archive wins membership ID conflicts

Medium Severity

When the same thread id appears in both threads and archivedThreads, normalizeShellThreadMembership always prefers the copy with a non-null archivedAt. An inconsistent full snapshot that still carries a stale archived row can therefore override a fresher active row (archivedAt: null) and keep an unarchived thread off Home.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 30b5502. Configure here.

mwolson added 3 commits July 18, 2026 11:21
Completes the pingdotgg#4055 port in the v2-base merge: draft routes carry a
pre-allocated thread id, and the conditional subscription blocked
promotion to live shell data until remount. Flagged by Grok review.

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

There are 4 total unresolved issues (including 3 from previous reviews).

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 47dc628. Configure here.


Function("markShowcaseReady") { (scene: String) in
let readyPath = NSHomeDirectory() + "/Library/Caches/T3ShowcaseReadyScene"
try? scene.write(toFile: readyPath, atomically: true, encoding: .utf8)

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.

Crash breadcrumb fsync path removed

Medium Severity

The writeSyncText function, which provided durable last-chance crash log writes, was removed from T3NativeControlsModule. This breaks the breadcrumbPersist mechanism, which still expects this native fsync path. As a result, last-minute breadcrumbs may be lost during iOS fatals, impacting crash diagnostics.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 47dc628. 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.