[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923
[orchestrator-v2] fix(mobile): Stabilize iOS draft attachments, Hermes sort, and Home#3923mwolson wants to merge 138 commits into
Conversation
…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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
ApprovabilityVerdict: 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. |
2857eb3 to
76a88a0
Compare
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.
76a88a0 to
6e9701d
Compare
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; |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit dd80212. Configure here.
| return sourceCharacter !== "`" && sourceCharacter !== "~"; | ||
| } | ||
|
|
||
| function parseRecoveredMarkdown(value: string, parser: MarkdownParser): RecoveredMarkdown { |
There was a problem hiding this comment.
🟡 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.
| const kept = value | ||
| .split(":") | ||
| .filter((segment) => segment.length > 0 && !isPathSegmentUnderAppDir(segment, appDir)); |
There was a problem hiding this comment.
🟡 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; |
There was a problem hiding this comment.
🟡 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)) { |
There was a problem hiding this comment.
🟡 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.
| 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() |
There was a problem hiding this comment.
🟡 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()) |
There was a problem hiding this comment.
🟡 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> = []; |
There was a problem hiding this comment.
🟠 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); |
There was a problem hiding this comment.
🟡 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( |
There was a problem hiding this comment.
🟡 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
incomingShareis treated as absent whenevergetShare(routeShareId)returnsnull, without checking the provider's loading state. On route restoration/deep-link startup,IncomingShareProviderinitially has an emptydraftsarray while it asynchronously loads persisted shares, so the picker renders as an ordinary task picker; a quick project selection then navigates withincomingShareId: undefined, silently dropping the shared content from the draft.
apps/mobile/src/features/home/HomeScreen.tsx:200
updateGroupDisplaypersistscollapsedProjectGroupsby rebuilding the array only from the currently available display-state map. If a user toggles a group whilepreferencesResultis still loading, that map does not contain the previously persisted collapsed groups, sosavePreferencesoverwrites 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.
dd80212 to
30b5502
Compare
| // 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); | ||
| } |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 30b5502. Configure here.
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.
There was a problem hiding this comment.
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).
❌ 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) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 47dc628. Configure here.


Summary
Array.prototype.toSortedwith a shared copy-sort helper.PreventRemoveProvider.archivedAtso archived threads do not stick on the Home list after a missed archive delta.Base
This branch is now built on
mwolson/t3codev2-base, a merge of the shared iOS/mobile fixes line (ios-fixes-main) intot3code/codex-turn-mapping. Because GitHub diffs againstt3code/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
idand adataUrl. The start-turn path did not treat those dual-tagged attachments as uploads, so order and persistence broke for mobile screenshots.Array.prototype.toSorted. CTM queue UI calls it fromderiveThreadQueueWorkflowState/ThreadQueueControl, so queuing a reply or finishing a turn can crash withTypeError: undefined is not a function.@t3tools/shared/ArraycopySorted([...values].sort) and use it on mobile-reachable call sites (threadWorkflows,ThreadRelationshipsBanner,use-thread-selection, CTMmodel.ts).lineage.relationshipToParent === "subagent"when grouping Home rows; keep fork threads.setOptionsand could hit maximum update depth throughPreventRemoveProvideron CTM device launches.thread.archivedshell 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.normalizeShellThreadMembershipand apply it to every full snapshot (reducer enrichment refreshes and reconnect heals) so active/archive membership always derives fromarchivedAt. The HTTP heal, forced socket snapshot, and stale-snapshot rejection it pairs with live inv2-base.Validation
Arraytests;shell-sync/shellReducermembership tests.vp check(0 errors) and fullvp run typecheckon the rebuilt branch.com.mwolson.t3code.v2.devfrom local orchestrator-v2 integration; queued reply send after active turn finished without crash; Home list usable after nav stability fix.Notes
v2-base: (1) attachments / Hermes / Home, (2) shell archive membership normalization.t3code/codex-turn-mappingonly. Main-line ios-on-main (fix(mobile): Harden iOS reconnect, crash capture, and chat layout on main #3910) does not haveThreadQueueControl/ this queue path.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:
persistAttachmentsnow correctly maps upload results back to their source positions and strips client-only fields (id,previewUri) before sending to the server.Array.prototype.toSorted(unsupported on Hermes) with a sharedcopySortedutility throughout mobile thread sorting.DraftHeroHeadlineproject picker.ThreadFeednow auto-corrects scroll position when content underflows the viewport and shows a full-screen loading overlay during hydration.TerminalInputSequencenormalizes all return keys to carriage return (\r);autoFocusprop added to control keyboard focus.makeEnvironmentShellStateno longer paints cached snapshots immediately; starts insynchronizingand waits for an HTTP heal or authoritative socket snapshot.synchronizedmarker so warm-start clients exit the syncing state faster.TerminalCanvasView,GhosttyBridge) with JNI bindings, selection, scroll, and theme support.IncomingShareProviderandIncomingShareInboxserialize share ingestion, merge content into composer drafts idempotently, and clean up temporary files.LegalPagelayout component.server-config/vcs-refsdata (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
T3ComposerEditornative 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 toDMSans-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.