Skip to content

[codex] Add Android mobile support#3579

Merged
juliusmarminge merged 48 commits into
mainfrom
android-dev-pr-3514
Jul 12, 2026
Merged

[codex] Add Android mobile support#3579
juliusmarminge merged 48 commits into
mainfrom
android-dev-pr-3514

Conversation

@juliusmarminge

@juliusmarminge juliusmarminge commented Jun 27, 2026

Copy link
Copy Markdown
Member

Summary

  • add Android implementations for the native composer, header controls, and review diff modules
  • introduce Android-specific navigation, headers, file browsing, composer controls, icons, and connection/settings layouts while preserving the iOS toolbar paths
  • make the native Android diff gutter fixed during horizontal scrolling, keep file headers sticky, and show line-range selection feedback
  • move review section controls into an Android header menu and rebuild review comments as a full-screen, keyboard-sticky flow
  • add Android markdown code highlighting, local build configuration, and mobile development documentation

Why

The mobile app inherited iOS-only native modules and navigation assumptions. Android could build only after filling those native module gaps, and several shared screens rendered with incorrect insets, missing icons, inaccessible controls, or desktop/iOS-oriented interaction patterns.

The review diff also scrolled its entire native canvas horizontally, which moved line gutters and file headers with the code. It now owns horizontal code offset internally so persistent chrome stays fixed.

Impact

Android now has a usable end-to-end thread, file, Git review, composer, and connection flow. The iOS implementation keeps using its existing native toolbar and form-sheet behavior through platform-specific branches.

Validation

  • vp check
  • vp run typecheck
  • vp run lint:mobile
  • ./gradlew :t3tools-mobile-review-diff-native:compileDebugKotlin
  • ./gradlew :app:assembleDebug
  • manually exercised light and dark mode on the visible Android emulator
  • installed and launched the debug build on a USB-connected Pixel 7a

Note

Add Android support to the mobile app

  • Adds a native Android terminal renderer via JNI using libghostty-vt with a custom canvas view (TerminalCanvasView.kt) for font metrics, styling, and cursor blinking
  • Introduces AppSymbol.tsx to map SF Symbols and Material icons to Tabler icons on Android, replacing direct expo-symbols usage across the codebase
  • Replaces native stack headers with in-content AndroidScreenHeader on all major screens (home, thread, settings, files, review, connections, new task)
  • Adds AndroidAnchoredMenu for context menus rendered via OverlayPortal to avoid keyboard focus loss, and wires ControlPillMenu to use it on Android
  • Adds Android-specific build infrastructure: Gradle configs for native modules, CMake setup for the terminal JNI library, and Expo config plugins for heap sizing, modern alert/popup dialogs, and predictive back gesture compat
  • Implements Android launcher shortcuts via useAppShortcuts.ts that persist and sync recent threads to launcher quick actions
  • Adds a native Android composer editor module (T3ComposerEditorView.kt) with structured event dispatch, token spans, and selection management
  • Risk: The terminal backend now uses JNI with prebuilt .so libraries for four ABIs; any ABI mismatch or library loading failure will crash the terminal view

Changes since #3579 opened

  • Implemented native text selection system for Android terminal [80cf687]
  • Implemented terminal session lifecycle management with auto-close, respawn, and fallback navigation [80cf687]
  • Integrated bundled MesloLGS NF fonts for Android terminal rendering [80cf687]
  • Added Android-specific in-flow header with terminal menu and actions [80cf687]
  • Changed composer expansion behavior on Android to focus-based model [80cf687]
  • Added inertial fling scrolling to terminal canvas [80cf687]
  • Changed Enter key behavior to send carriage return instead of line feed [80cf687]
📊 Macroscope summarized 80cf687. 10 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.


Note

High Risk
Large new native/Android surface (review diff, composer, terminal JNI and prebuilt libs) plus iOS entitlement gating; regressions or load failures would hit core editing, review, and terminal flows.

Overview
Adds Android Expo modules for the composer editor, header buttons, and review diff so JS can use the same view contracts as iOS, including a custom canvas diff surface with fixed gutters, sticky file headers, async JSON decoding, and scroll/tap events.

Build and platform config: app.config.ts gains an opt-in iOS Personal Team mode (T3CODE_IOS_PERSONAL_TEAM + bundle ID validation) that drops widgets, push, app groups, and native Sign in with Apple; Clerk’s appleSignIn plugin flag follows that mode. Android picks up predictive back, expo-quick-actions, expo-asset, and several Gradle/UI config plugins. Docs add Personal Team and ios:release (Metro-free Release) flows; Metro blocks the repo .t3 directory from the bundle.

Terminal: Android is documented and vendored with pinned libghostty-vt (headers/libs) for VT parsing/rendering, separate from the existing iOS GhosttyKit fork.

Minor UI tweak: lighter translucent card tokens in global.css.

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

@coderabbitai

coderabbitai Bot commented Jun 27, 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: 5bdae982-b2e5-48c1-b3e9-a997ea8c830a

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
  • Commit unit tests in branch android-dev-pr-3514

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Jun 27, 2026
Comment thread apps/mobile/src/app/new/index.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx
Comment thread apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts
Comment thread apps/mobile/src/components/AppSymbol.tsx
Comment thread apps/mobile/src/features/threads/ThreadComposer.tsx Outdated
Comment on lines +394 to +399
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// A direct gesture takes ownership from any interrupted programmatic jump.
// Resume visible-file events immediately so the inspector follows the finger.
isProgrammaticScrollActive = false
contentView.isVerticalScrollActive = true
}

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 ios/T3ReviewDiffView.swift:394

scrollViewWillBeginDragging clears isProgrammaticScrollActive but leaves pendingScrollFileId and pendingScrollAnimated set. If scrollToFile was queued before the target header existed (e.g. rows still decoding), a later setRowsJson calls applyPendingScrollIfNeeded() and jumps to the old target, overriding the user's manual scroll. Clear the pending scroll request when the user takes control.

Suggested change
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// A direct gesture takes ownership from any interrupted programmatic jump.
// Resume visible-file events immediately so the inspector follows the finger.
isProgrammaticScrollActive = false
contentView.isVerticalScrollActive = true
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
// A direct gesture takes ownership from any interrupted programmatic jump.
// Resume visible-file events immediately so the inspector follows the finger.
isProgrammaticScrollActive = false
pendingScrollFileId = nil
pendingScrollAnimated = false
contentView.isVerticalScrollActive = true
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift around lines 394-399:

`scrollViewWillBeginDragging` clears `isProgrammaticScrollActive` but leaves `pendingScrollFileId` and `pendingScrollAnimated` set. If `scrollToFile` was queued before the target header existed (e.g. rows still decoding), a later `setRowsJson` calls `applyPendingScrollIfNeeded()` and jumps to the old target, overriding the user's manual scroll. Clear the pending scroll request when the user takes control.

Comment thread apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift
Comment thread apps/mobile/plugins/withIosSceneLifecycle.cjs
Comment thread apps/mobile/src/features/threads/ThreadFeed.tsx Outdated
Comment on lines -174 to +171
paddingTop: 8,
paddingBottom: target ? 0 : Math.max(insets.bottom, 18),
paddingTop: isAndroid ? insets.top + 8 : 8,
paddingBottom: target ? (isAndroid ? 72 : 0) : Math.max(insets.bottom, 18),

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 review/ReviewCommentComposerSheet.tsx:174

The hard-coded 72 px paddingBottom on Android assumes the KeyboardStickyView footer is always 72 px tall, but the footer height is actually 44 (button) + 8 (pt-2) + Math.max(insets.bottom, 10). On devices where insets.bottom exceeds ~20 (e.g. iPhones with a home indicator, where it can be ~34), the footer is taller than 72 px and overlaps the bottom of the comment input and attachment strip, hiding them from view and making them untappable. The padding should be computed from the actual footer height instead of hard-coded.

-            paddingBottom: target ? (isAndroid ? 72 : 0) : Math.max(insets.bottom, 18),
+            paddingBottom: target ? (isAndroid ? Math.max(insets.bottom, 10) + 52 : 0) : Math.max(insets.bottom, 18),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/review/ReviewCommentComposerSheet.tsx around line 174:

The hard-coded `72` px `paddingBottom` on Android assumes the `KeyboardStickyView` footer is always 72 px tall, but the footer height is actually `44` (button) + `8` (`pt-2`) + `Math.max(insets.bottom, 10)`. On devices where `insets.bottom` exceeds ~20 (e.g. iPhones with a home indicator, where it can be ~34), the footer is taller than 72 px and overlaps the bottom of the comment input and attachment strip, hiding them from view and making them untappable. The padding should be computed from the actual footer height instead of hard-coded.

@juliusmarminge juliusmarminge changed the base branch from main to t3code/ipad-responsive-mobile-layout June 27, 2026 06:46
Comment thread apps/mobile/src/features/threads/ThreadDetailScreen.tsx Outdated
Comment thread apps/mobile/src/features/threads/ThreadNavigationDrawer.tsx Outdated
Comment thread apps/mobile/src/features/home/HomeScreen.tsx Outdated
view.setSpellCheck(spellCheck)
}

Events(

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 t3composereditor/T3ComposerEditorModule.kt:48

The Events(...) declaration omits onComposerSubmit, and T3ComposerEditorView defines no corresponding EventDispatcher or hardware-keyboard handler. As a result, the onSubmit prop exposed by ComposerEditorProps is never invoked on Android — hardware-keyboard submit that works on iOS silently does nothing here. Consider adding "onComposerSubmit" to the Events list, wiring up an onComposerSubmit dispatcher in the view, and detecting the submit key combination (e.g., Enter without Shift) to fire it.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt around line 48:

The `Events(...)` declaration omits `onComposerSubmit`, and `T3ComposerEditorView` defines no corresponding `EventDispatcher` or hardware-keyboard handler. As a result, the `onSubmit` prop exposed by `ComposerEditorProps` is never invoked on Android — hardware-keyboard submit that works on iOS silently does nothing here. Consider adding `"onComposerSubmit"` to the `Events` list, wiring up an `onComposerSubmit` dispatcher in the view, and detecting the submit key combination (e.g., Enter without Shift) to fire it.

backgroundColorValue,
cursorColorValue,
paletteColors,
)

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

createTerminal() assigns the result of GhosttyBridge.nativeCreate() directly to terminalHandle without checking for a 0 return value, which indicates native session creation failed. When this happens, terminalHandle stays 0L and emitResize() proceeds to fire onResize and call feedPendingBuffer()/renderSnapshot() as if the terminal were live, so the JS layer never learns creation failed and the view stays blank. Consider checking the return value and emitting an error event when it is 0.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt around line 252:

`createTerminal()` assigns the result of `GhosttyBridge.nativeCreate()` directly to `terminalHandle` without checking for a `0` return value, which indicates native session creation failed. When this happens, `terminalHandle` stays `0L` and `emitResize()` proceeds to fire `onResize` and call `feedPendingBuffer()`/`renderSnapshot()` as if the terminal were live, so the JS layer never learns creation failed and the view stays blank. Consider checking the return value and emitting an error event when it is `0`.

```

The script downloads Zig 0.15.2 when needed, checks out the pinned upstream Ghostty revision, and
rebuilds all four Android ABIs with 16 KB page-size support.

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 t3-terminal/README.md:50

The new README section states the script rebuilds "all four Android ABIs with 16 KB page-size support," but build-libghostty-android.sh never passes -Wl,-z,max-page-size=16384 and -Wl,-z,common-page-size=16384 to the linker. A developer following these instructions will rebuild and vendor .so files that are not 16 KB-page compatible while the docs claim otherwise. Add the required linker flags to the script so the rebuilt libraries match the documented behavior.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-terminal/README.md around line 50:

The new README section states the script rebuilds "all four Android ABIs with 16 KB page-size support," but `build-libghostty-android.sh` never passes `-Wl,-z,max-page-size=16384` and `-Wl,-z,common-page-size=16384` to the linker. A developer following these instructions will rebuild and vendor `.so` files that are not 16 KB-page compatible while the docs claim otherwise. Add the required linker flags to the script so the rebuilt libraries match the documented behavior.

Prop("editable") { view: T3ComposerEditorView, editable: Boolean ->
view.setEditable(editable)
}
Prop("scrollEnabled") { view: T3ComposerEditorView, scrollEnabled: Boolean ->

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 t3composereditor/T3ComposerEditorModule.kt:35

The scrollEnabled prop does not actually disable scrolling. setScrollEnabled only toggles editor.isVerticalScrollBarEnabled, which controls whether the scrollbar is drawn — not whether the view scrolls. When scrollEnabled={false} is passed from JS, the editor remains scrollable and only the scrollbar disappears, so the prop silently does not work. Consider disabling touch interception or overriding onTouchEvent to actually prevent scrolling when scrollEnabled is false.

Also found in 1 other location(s)

apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt:199

setScrollEnabled() at line 199 only assigns editor.isVerticalScrollBarEnabled. Android's setVerticalScrollBarEnabled() controls whether the scrollbar is drawn, not whether an EditText can actually scroll. When callers pass scrollEnabled=false, long composer contents can still be vertically scrolled; only the scrollbar disappears, so the exposed prop does not work on Android.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorModule.kt around line 35:

The `scrollEnabled` prop does not actually disable scrolling. `setScrollEnabled` only toggles `editor.isVerticalScrollBarEnabled`, which controls whether the scrollbar is drawn — not whether the view scrolls. When `scrollEnabled={false}` is passed from JS, the editor remains scrollable and only the scrollbar disappears, so the prop silently does not work. Consider disabling touch interception or overriding `onTouchEvent` to actually prevent scrolling when `scrollEnabled` is false.

Also found in 1 other location(s):
- apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt:199 -- `setScrollEnabled()` at line `199` only assigns `editor.isVerticalScrollBarEnabled`. Android's `setVerticalScrollBarEnabled()` controls whether the scrollbar is drawn, not whether an `EditText` can actually scroll. When callers pass `scrollEnabled=false`, long composer contents can still be vertically scrolled; only the scrollbar disappears, so the exposed prop does not work on Android.

</>
)}

<GitActionProgressOverlay progress={gitActionProgress} onDismiss={dismissGitActionResult} />

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

GitActionProgressOverlay positions itself at insets.top + 48, which was calibrated for the native iOS header. On Android, the new AndroidScreenHeader is Math.max(insets.top, 12) + 58 tall, so the overlay renders ~10px inside the header and overlaps the title and action buttons while a git action is running or after it completes. Consider passing a platform-aware top offset to GitActionProgressOverlay so it clears the Android header.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadRouteScreen.tsx around line 725:

`GitActionProgressOverlay` positions itself at `insets.top + 48`, which was calibrated for the native iOS header. On Android, the new `AndroidScreenHeader` is `Math.max(insets.top, 12) + 58` tall, so the overlay renders ~10px inside the header and overlaps the title and action buttons while a git action is running or after it completes. Consider passing a platform-aware top offset to `GitActionProgressOverlay` so it clears the Android header.

Comment thread apps/mobile/src/features/review/ReviewSheet.tsx
Comment thread apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
Comment thread apps/mobile/src/app/connections/new.tsx Outdated
@juliusmarminge juliusmarminge marked this pull request as ready for review June 27, 2026 14:30

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

Autofix Details

Bugbot Autofix prepared fixes for all 6 issues found in the latest run.

  • ✅ Fixed: iOS associated domains removed
    • Restored the associatedDomains array with applinks and webcredentials entries for clerk.t3.codes, which was inadvertently dropped when relyingParty was removed from the variant config.
  • ✅ Fixed: Composer scroll flag ignored
    • Added a scrollEnabled property to SelectionAwareEditText with a scrollTo override that blocks scrolling when disabled, so setScrollEnabled now controls actual scroll behavior, not just the scroll bar.
  • ✅ Fixed: Collapsed files skew highlight indices
    • Added a visibleToOriginalIndex mapping array built during rebuildVisibleRows, and the onVisibleRowsChanged callback now translates filtered indices back to original row indices before emitting the visible-range event.
  • ✅ Fixed: Stale rows after content reset
    • setContentResetKey now increments rowsDecodeGeneration and clears rows, visibleRows, visibleToOriginalIndex, and canvasView.rows so in-flight decodes from the previous section are rejected and stale content is immediately cleared.
  • ✅ Fixed: Top scroll never clears file
    • emitVisibleFile now checks if verticalOffset is at zero and emits onVisibleFileChange with a null fileId in that case, matching the iOS behavior that selects the "All files" navigator destination.
  • ✅ Fixed: Collapsed comments stay tall
    • Added collapsedCommentIds to DiffCanvasView and updated rowHeight to return 44dp for collapsed comments (matching iOS's 44pt) instead of the full expanded height.

Create PR

Or push these changes by commenting:

@cursor push a991bbde89
Preview (a991bbde89)
diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts
--- a/apps/mobile/app.config.ts
+++ b/apps/mobile/app.config.ts
@@ -107,6 +107,7 @@
     icon: variant.iosIcon,
     supportsTablet: true,
     bundleIdentifier: iosBundleIdentifier,
+    associatedDomains: ["applinks:clerk.t3.codes", "webcredentials:clerk.t3.codes"],
     infoPlist: {
       NSAppTransportSecurity: {
         NSAllowsArbitraryLoads: true,

diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -197,6 +197,7 @@
 
   fun setScrollEnabled(scrollEnabled: Boolean) {
     editor.isVerticalScrollBarEnabled = scrollEnabled
+    editor.scrollEnabled = scrollEnabled
   }
 
   fun setAutoFocus(autoFocus: Boolean) {
@@ -424,7 +425,12 @@
 private class SelectionAwareEditText(context: Context) : EditText(context) {
   var selectionListener: ((Int, Int) -> Unit)? = null
   var pasteImagesListener: ((List<String>) -> Unit)? = null
+  var scrollEnabled = true
 
+  override fun scrollTo(x: Int, y: Int) {
+    if (scrollEnabled) super.scrollTo(x, y)
+  }
+
   override fun onSelectionChanged(selStart: Int, selEnd: Int) {
     super.onSelectionChanged(selStart, selEnd)
     selectionListener?.invoke(selStart, selEnd)

diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
@@ -32,6 +32,7 @@
   private val onToggleComment by EventDispatcher()
   private var rows: List<DiffRow> = emptyList()
   private var visibleRows: List<DiffRow> = emptyList()
+  private var visibleToOriginalIndex: IntArray = intArrayOf()
   private var collapsedFileIds: Set<String> = emptySet()
   private var viewedFileIds: Set<String> = emptySet()
   private var selectedRowIds: Set<String> = emptySet()
@@ -56,11 +57,13 @@
   init {
     canvasView.onRowTap = { row, gesture -> handleRowTap(row, gesture) }
     canvasView.onVisibleRowsChanged = { first, last ->
+      val originalFirst = if (first in visibleToOriginalIndex.indices) visibleToOriginalIndex[first] else first
+      val originalLast = if (last in visibleToOriginalIndex.indices) visibleToOriginalIndex[last] else last
       onDebug(
         mapOf(
           "message" to "visible-range",
-          "firstRowIndex" to first,
-          "lastRowIndex" to last,
+          "firstRowIndex" to originalFirst,
+          "lastRowIndex" to originalLast,
         ),
       )
       emitVisibleFile(first)
@@ -81,7 +84,12 @@
   fun setContentResetKey(value: String) {
     if (contentResetKey == value) return
     contentResetKey = value
+    rowsDecodeGeneration += 1
     tokensDecodeGeneration += 1
+    rows = emptyList()
+    visibleRows = emptyList()
+    visibleToOriginalIndex = intArrayOf()
+    canvasView.rows = emptyList()
     canvasView.tokensByRowId = emptyMap()
     lastVisibleFileId = null
     pendingInitialScroll = true
@@ -314,21 +322,27 @@
 
   private fun rebuildVisibleRows() {
     val filtered = ArrayList<DiffRow>(rows.size)
+    val indexMapping = ArrayList<Int>(rows.size)
     var currentFileCollapsed = false
-    rows.forEach { row ->
+    rows.forEachIndexed { originalIndex, row ->
       if (row.kind == "file") {
         currentFileCollapsed = collapsedFileIds.contains(row.resolvedFileId)
         filtered.add(row)
+        indexMapping.add(originalIndex)
       } else if (!currentFileCollapsed) {
         if (row.kind != "comment" || !collapsedCommentIds.contains(row.id)) {
           filtered.add(row)
+          indexMapping.add(originalIndex)
         } else {
           filtered.add(row.copy(commentText = "Comment collapsed"))
+          indexMapping.add(originalIndex)
         }
       }
     }
     visibleRows = filtered
+    visibleToOriginalIndex = indexMapping.toIntArray()
     canvasView.rows = filtered
+    canvasView.collapsedCommentIds = collapsedCommentIds
     canvasView.viewedFileIds = viewedFileIds
     canvasView.selectedRowIds = selectedRowIds
     applyPendingInitialScroll()
@@ -360,6 +374,13 @@
 
   private fun emitVisibleFile(firstVisibleIndex: Int) {
     if (visibleRows.isEmpty()) return
+    if (canvasView.verticalOffset() <= 0) {
+      if (lastVisibleFileId != null) {
+        lastVisibleFileId = null
+        onVisibleFileChange(mapOf("fileId" to null))
+      }
+      return
+    }
     val start = firstVisibleIndex.coerceIn(0, visibleRows.lastIndex)
     val fileId = (start downTo 0)
       .asSequence()
@@ -590,6 +611,11 @@
       field = value
       invalidate()
     }
+  var collapsedCommentIds: Set<String> = emptySet()
+    set(value) {
+      field = value
+      rebuildOffsets()
+    }
   var theme: DiffTheme = DiffTheme.fallback("light")
     set(value) {
       field = value
@@ -691,7 +717,11 @@
 
   private fun rowHeight(row: DiffRow): Int = when (row.kind) {
     "file" -> style.fileHeaderHeightPx.toInt()
-    "comment" -> max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt())
+    "comment" -> if (collapsedCommentIds.contains(row.id)) {
+      (44 * density).toInt()
+    } else {
+      max((style.rowHeightPx * 3.2f).toInt(), (56 * density).toInt())
+    }
     else -> style.rowHeightPx.toInt()
   }.coerceAtLeast(1)

You can send follow-ups to the cloud agent here.

Comment thread apps/mobile/app.config.ts
canvasView.setVerticalOffset(0)
canvasView.setHorizontalOffset(0)
applyPendingInitialScroll()
}

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.

Stale rows after content reset

Medium Severity

When contentResetKey changes, the view clears tokens and scroll but does not bump rowsDecodeGeneration or clear rows. An in-flight setRowsJson decode from the previous review section can still post after the reset and repopulate the canvas with the old diff.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 276b453. Configure here.

if (fileId == lastVisibleFileId) return
lastVisibleFileId = fileId
onVisibleFileChange(mapOf("fileId" to fileId))
}

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.

Top scroll never clears file

Medium Severity

Android never emits onVisibleFileChange with a null fileId when the diff is scrolled to the top. The navigator keeps highlighting the first file instead of the shared “all files” destination that iOS reports at offset zero.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 276b453. Configure here.

@macroscopeapp

macroscopeapp Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

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

}
} catch (_: Exception) {
}
}

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.

Stale token patches after reset

High Severity

The setTokensPatchJson function asynchronously applies token patches. It only checks tokensResetKey for relevance, missing a crucial check against contentResetKey. This can cause token patches from a previous diff to be applied to the current view, resulting in incorrect highlighting.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ae9365. Configure here.

token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC)
token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
else -> Typeface.MONOSPACE
}

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.

Bold italic tokens render wrong

Low Severity

The when statement for textPaint.typeface in drawLineRow applies fontStyle bits sequentially, causing tokens with both italic and bold flags to render only as italic. This results in bold styling being lost and diverges from the intended combined styling.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ae9365. Configure here.

if (imageUris.isNotEmpty()) {
pasteImagesListener?.invoke(imageUris)
return true
}

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.

Paste skips text when images present

Medium Severity

On paste, if any clipboard item exposes an image URI, Android invokes onComposerPasteImages and returns true without calling super.onTextContextMenuItem. Plain-text paste from the same or another clip item never runs, so mixed or text-first paste silently fails.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ae9365. Configure here.

"onToggleViewedFile",
"onPressLine",
"onToggleComment",
)

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.

Review diff pull refresh missing

Medium Severity

The Android T3ReviewDiffSurface module does not define the refreshing prop or onPullToRefresh event that exist on iOS. SourceFileSurface passes both when refresh is enabled, so pull-to-refresh on the native source file view does nothing on Android.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2ae9365. Configure here.

const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout();

if (!layout.usesSplitView) {
if (Platform.OS === "android" || !layout.usesSplitView) {

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 layout/workspace-sidebar-toolbar.tsx:15

WorkspaceSidebarToolbar returns null on Android regardless of layout.usesSplitView, so split-view Android routes lose the sidebar toggle and New task/Return to chat buttons. The Android exclusion should be removed unless Android genuinely lacks split-view support.

Suggested change
if (Platform.OS === "android" || !layout.usesSplitView) {
if (!layout.usesSplitView) {
Also found in 1 other location(s)

apps/mobile/src/features/review/ReviewSheet.tsx:663

On Android the new !isAndroid guard at ReviewSheet line 663 removes the entire right-side toolbar, which is the only place ThreadGitMenu is rendered. The replacement AndroidScreenHeader only shows the section selector, so opening review on Android no longer exposes the commit/push/"More" git actions that this screen previously provided. Users must leave review to perform those actions, which is a regression in core review functionality.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx around line 15:

`WorkspaceSidebarToolbar` returns `null` on Android regardless of `layout.usesSplitView`, so split-view Android routes lose the sidebar toggle and `New task`/`Return to chat` buttons. The Android exclusion should be removed unless Android genuinely lacks split-view support.

Also found in 1 other location(s):
- apps/mobile/src/features/review/ReviewSheet.tsx:663 -- On Android the new `!isAndroid` guard at `ReviewSheet` line 663 removes the entire right-side toolbar, which is the only place `ThreadGitMenu` is rendered. The replacement `AndroidScreenHeader` only shows the section selector, so opening review on Android no longer exposes the commit/push/"More" git actions that this screen previously provided. Users must leave review to perform those actions, which is a regression in core review functionality.

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

There are 11 total unresolved issues (including 8 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Shared horizontal scroll across files
    • Replaced the single horizontalOffset field with a per-file HashMap<String, Int> so each file maintains its own horizontal scroll position, matching iOS's horizontalOffsetsByFileId behavior.
  • ✅ Fixed: Paste sends unreadable content URIs
    • Added copyToLocalFile() that copies content:// clipboard images into app cache (t3-composer-paste/*.png) and emits file:// URIs that convertPastedImagesToAttachments can read, matching the iOS writeTemporaryImage pattern.
  • ✅ Fixed: Word diff highlights missing Android
    • Added wordDiffRanges field to DiffRow, parsing from row JSON, and implemented drawWordDiffRanges() to render intra-line add/delete highlights matching iOS's behavior.

Create PR

Or push these changes by commenting:

@cursor push 5664a9a0e6
Preview (5664a9a0e6)
diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -7,6 +7,7 @@
 import android.graphics.Paint
 import android.graphics.RectF
 import android.graphics.Typeface
+import android.net.Uri
 import android.text.Editable
 import android.text.InputType
 import android.text.Spanned
@@ -20,6 +21,8 @@
 import expo.modules.kotlin.viewevent.EventDispatcher
 import expo.modules.kotlin.views.ExpoView
 import org.json.JSONObject
+import java.io.File
+import java.util.UUID
 import kotlin.math.max
 
 class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
@@ -434,21 +437,39 @@
     if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
       val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
       val clip = clipboard?.primaryClip
-      val imageUris = buildList {
+      val fileUris = buildList {
         if (clip != null) {
           for (index in 0 until clip.itemCount) {
             clip.getItemAt(index).uri?.let { uri ->
               val mimeType = context.contentResolver.getType(uri)
-              if (mimeType?.startsWith("image/") == true) add(uri.toString())
+              if (mimeType?.startsWith("image/") == true) {
+                copyToLocalFile(uri)?.let { add(it) }
+              }
             }
           }
         }
       }
-      if (imageUris.isNotEmpty()) {
-        pasteImagesListener?.invoke(imageUris)
+      if (fileUris.isNotEmpty()) {
+        pasteImagesListener?.invoke(fileUris)
         return true
       }
     }
     return super.onTextContextMenuItem(id)
   }
+
+  private fun copyToLocalFile(contentUri: Uri): String? {
+    return try {
+      val pasteDir = File(context.cacheDir, "t3-composer-paste")
+      pasteDir.mkdirs()
+      val destFile = File(pasteDir, "${UUID.randomUUID()}.png")
+      context.contentResolver.openInputStream(contentUri)?.use { input ->
+        destFile.outputStream().use { output ->
+          input.copyTo(output)
+        }
+      }
+      Uri.fromFile(destFile).toString()
+    } catch (_: Exception) {
+      null
+    }
+  }
 }

diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
@@ -86,7 +86,7 @@
     lastVisibleFileId = null
     pendingInitialScroll = true
     canvasView.setVerticalOffset(0)
-    canvasView.setHorizontalOffset(0)
+    canvasView.resetHorizontalOffsets()
     applyPendingInitialScroll()
   }
 
@@ -416,6 +416,7 @@
   val change: String,
   val oldLineNumber: Int?,
   val newLineNumber: Int?,
+  val wordDiffRanges: List<DiffWordDiffRange>?,
   val commentText: String,
   val commentRangeLabel: String,
   val commentSectionTitle: String,
@@ -423,6 +424,11 @@
   val resolvedFileId: String get() = fileId.ifEmpty { id }
 }
 
+private data class DiffWordDiffRange(
+  val start: Int,
+  val end: Int,
+)
+
 private data class DiffToken(
   val content: String,
   val color: Int?,
@@ -567,12 +573,13 @@
   )
   private var rowOffsets = intArrayOf(0)
   private var verticalOffset = 0
-  private var horizontalOffset = 0
+  private var horizontalOffsetsByFileId = HashMap<String, Int>()
   private var lastVisibleRange: Pair<Int, Int>? = null
 
   var rows: List<DiffRow> = emptyList()
     set(value) {
       field = value
+      horizontalOffsetsByFileId.clear()
       rebuildOffsets()
     }
   var tokensByRowId: Map<String, List<DiffToken>> = emptyMap()
@@ -603,7 +610,7 @@
   var contentWidthPx: Int = (1200 * density).toInt()
     set(value) {
       field = max(value, suggestedMinimumWidth)
-      setHorizontalOffset(horizontalOffset)
+      clampHorizontalOffsets()
       invalidate()
     }
   var onRowTap: ((DiffRow, String) -> Unit)? = null
@@ -619,7 +626,7 @@
   override fun onSizeChanged(width: Int, height: Int, oldWidth: Int, oldHeight: Int) {
     super.onSizeChanged(width, height, oldWidth, oldHeight)
     setVerticalOffset(verticalOffset)
-    setHorizontalOffset(horizontalOffset)
+    clampHorizontalOffsets()
   }
 
   override fun onDraw(canvas: Canvas) {
@@ -665,20 +672,58 @@
   fun maxVerticalOffset(): Int = max(0, (rowOffsets.lastOrNull() ?: 0) - height)
 
   fun setHorizontalOffset(value: Int) {
+    val fileId = fileIdAtVerticalCenter() ?: return
     val nextOffset = value.coerceIn(0, maxHorizontalOffset())
-    if (horizontalOffset == nextOffset) return
-    horizontalOffset = nextOffset
+    val current = horizontalOffsetsByFileId[fileId] ?: 0
+    if (current == nextOffset) return
+    horizontalOffsetsByFileId[fileId] = nextOffset
     invalidate()
   }
 
   fun scrollByHorizontal(delta: Int) {
-    setHorizontalOffset(horizontalOffset + delta)
+    val fileId = fileIdAtVerticalCenter() ?: return
+    val current = horizontalOffsetsByFileId[fileId] ?: 0
+    setHorizontalOffsetForFile(fileId, current + delta)
   }
 
-  fun horizontalOffset(): Int = horizontalOffset
+  fun horizontalOffset(): Int {
+    val fileId = fileIdAtVerticalCenter() ?: return 0
+    return horizontalOffsetsByFileId[fileId] ?: 0
+  }
 
   fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)
 
+  fun resetHorizontalOffsets() {
+    horizontalOffsetsByFileId.clear()
+  }
+
+  private fun setHorizontalOffsetForFile(fileId: String, value: Int) {
+    val nextOffset = value.coerceIn(0, maxHorizontalOffset())
+    val current = horizontalOffsetsByFileId[fileId] ?: 0
+    if (current == nextOffset) return
+    horizontalOffsetsByFileId[fileId] = nextOffset
+    invalidate()
+  }
+
+  private fun clampHorizontalOffsets() {
+    val maxOffset = maxHorizontalOffset()
+    val iterator = horizontalOffsetsByFileId.entries.iterator()
+    while (iterator.hasNext()) {
+      val entry = iterator.next()
+      entry.setValue(entry.value.coerceIn(0, maxOffset))
+    }
+  }
+
+  private fun fileIdAtVerticalCenter(): String? {
+    if (rows.isEmpty()) return null
+    val centerY = verticalOffset + height / 2
+    val index = rowIndexAt(centerY).coerceIn(0, rows.lastIndex)
+    return (index downTo 0)
+      .asSequence()
+      .map { rows[it].resolvedFileId }
+      .firstOrNull { it.isNotEmpty() }
+  }
+
   private fun rebuildOffsets() {
     rowOffsets = IntArray(rows.size + 1)
     rows.forEachIndexed { index, row ->
@@ -760,7 +805,7 @@
     fill(canvas, theme.hunkBackground, 0f, top.toFloat(), width.toFloat(), bottom.toFloat())
     textPaint.color = theme.hunkText
     textPaint.textSize = style.codeFontSizePx
-    drawScrollableCode(canvas, top, bottom) { codeX ->
+    drawScrollableCode(canvas, top, bottom, row.resolvedFileId) { codeX ->
       canvas.drawText(
         row.text.ifEmpty { row.content },
         codeX,
@@ -773,7 +818,7 @@
   private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) {
     textPaint.color = theme.mutedText
     textPaint.textSize = style.codeFontSizePx
-    drawScrollableCode(canvas, top, bottom) { codeX ->
+    drawScrollableCode(canvas, top, bottom, row.resolvedFileId) { codeX ->
       canvas.drawText(row.text, codeX, centeredBaseline(top, bottom, textPaint), textPaint)
     }
   }
@@ -782,7 +827,7 @@
     fill(canvas, theme.headerBackground, style.gutterWidthPx, top.toFloat(), width.toFloat(), bottom.toFloat())
     boldTextPaint.color = theme.text
     boldTextPaint.textSize = 12f * density
-    drawScrollableCode(canvas, top, bottom) { codeX ->
+    drawScrollableCode(canvas, top, bottom, row.resolvedFileId) { codeX ->
       canvas.drawText(
         row.commentSectionTitle.ifEmpty { row.commentRangeLabel.ifEmpty { "Comment" } },
         codeX,
@@ -824,7 +869,8 @@
     }
 
     val tokens = tokensByRowId[row.id]
-    drawScrollableCode(canvas, top, bottom) { codeX ->
+    drawScrollableCode(canvas, top, bottom, row.resolvedFileId) { codeX ->
+      drawWordDiffRanges(canvas, row, codeX, top, bottom)
       if (tokens.isNullOrEmpty()) {
         textPaint.textSize = style.codeFontSizePx
         textPaint.color = when (row.change) {
@@ -859,16 +905,40 @@
     canvas.drawText(newNumber, style.changeBarWidthPx + style.gutterWidthPx / 2f, baseline, textPaint)
   }
 
+  private fun drawWordDiffRanges(canvas: Canvas, row: DiffRow, codeX: Float, top: Int, bottom: Int) {
+    val ranges = row.wordDiffRanges
+    if (ranges.isNullOrEmpty()) return
+    val change = row.change
+    if (change != "add" && change != "delete") return
+
+    val fillColor = if (change == "add") withAlpha(theme.addBar, 71) else withAlpha(theme.deleteBar, 71)
+    textPaint.textSize = style.codeFontSizePx
+    val charWidth = textPaint.measureText("m")
+    val highlightHeight = max(4f * density, min((bottom - top).toFloat() - 4f * density, textPaint.fontMetrics.let { -it.ascent + it.descent }))
+    val highlightY = (top + bottom) / 2f - highlightHeight / 2f
+    val cornerRadius = 3f * density
+
+    for (range in ranges) {
+      if (range.end <= range.start) continue
+      val startX = codeX + range.start * charWidth
+      val rangeWidth = max(2f * density, (range.end - range.start) * charWidth)
+      backgroundPaint.color = fillColor
+      canvas.drawRoundRect(startX, highlightY, startX + rangeWidth, highlightY + highlightHeight, cornerRadius, cornerRadius, backgroundPaint)
+    }
+  }
+
   private fun drawScrollableCode(
     canvas: Canvas,
     top: Int,
     bottom: Int,
+    fileId: String,
     draw: (Float) -> Unit,
   ) {
     val gutterEnd = style.changeBarWidthPx + style.gutterWidthPx
+    val offset = horizontalOffsetsByFileId[fileId] ?: 0
     canvas.save()
     canvas.clipRect(gutterEnd, top.toFloat(), width.toFloat(), bottom.toFloat())
-    draw(gutterEnd + style.codePaddingPx - horizontalOffset)
+    draw(gutterEnd + style.codePaddingPx - offset)
     canvas.restore()
   }
 
@@ -895,10 +965,12 @@
   private fun drawHorizontalScrollIndicator(canvas: Canvas) {
     val maxOffset = maxHorizontalOffset()
     if (maxOffset <= 0 || width <= 0) return
+    val currentOffset = horizontalOffset()
+    if (currentOffset <= 0) return
     val trackWidth = width.toFloat()
     val thumbWidth = max(24f * density, trackWidth * trackWidth / contentWidthPx)
     val thumbTravel = trackWidth - thumbWidth
-    val left = thumbTravel * horizontalOffset / maxOffset
+    val left = thumbTravel * currentOffset / maxOffset
     fill(
       canvas,
       withAlpha(theme.mutedText, 110),
@@ -962,6 +1034,15 @@
       change = row.optString("change", "context"),
       oldLineNumber = row.optNullableInt("oldLineNumber"),
       newLineNumber = row.optNullableInt("newLineNumber"),
+      wordDiffRanges = row.optJSONArray("wordDiffRanges")?.let { rangesArray ->
+        List(rangesArray.length()) { rangeIndex ->
+          val rangeObj = rangesArray.getJSONObject(rangeIndex)
+          DiffWordDiffRange(
+            start = rangeObj.optInt("start"),
+            end = rangeObj.optInt("end"),
+          )
+        }
+      },
       commentText = row.optString("commentText"),
       commentRangeLabel = row.optString("commentRangeLabel"),
       commentSectionTitle = row.optString("commentSectionTitle"),

You can send follow-ups to the cloud agent here.


fun horizontalOffset(): Int = horizontalOffset

fun maxHorizontalOffset(): Int = max(0, contentWidthPx - width)

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.

Shared horizontal scroll across files

Medium Severity

The T3ReviewDiffView on Android uses a single horizontal scroll offset for the entire diff. This differs from iOS's per-file tracking and can cause files to display with incorrect horizontal alignment if another file was previously scrolled.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2b34778. Configure here.

@cursor

cursor Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Stale token patches after reset
    • Added contentResetKey capture before async decode and guard check on the main thread, matching the iOS implementation that prevents stale patches from being applied after a view reset.
  • ✅ Fixed: Bold italic tokens render wrong
    • Added a combined check for fontStyle bits 1+2 (value 3) mapping to Typeface.BOLD_ITALIC before the individual italic and bold checks in the when statement.
  • ✅ Fixed: Review diff pull refresh missing
    • Added SwipeRefreshLayout wrapping the canvas view, registered the refreshing prop and onPullToRefresh event in the module, and modified touch interception to allow pull-down gestures at the top to reach the refresh layout.
  • ✅ Fixed: Paste skips text when images present
    • Added a check to skip clipboard items that have non-empty text content when scanning for image URIs, so mixed text+image clips fall through to the default text paste behavior.

Create PR

Or push these changes by commenting:

@cursor push 969f425c0b
Preview (969f425c0b)
diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
--- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
+++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt
@@ -437,7 +437,9 @@
       val imageUris = buildList {
         if (clip != null) {
           for (index in 0 until clip.itemCount) {
-            clip.getItemAt(index).uri?.let { uri ->
+            val item = clip.getItemAt(index)
+            if (!item.text.isNullOrEmpty()) continue
+            item.uri?.let { uri ->
               val mimeType = context.contentResolver.getType(uri)
               if (mimeType?.startsWith("image/") == true) add(uri.toString())
             }

diff --git a/apps/mobile/modules/t3-review-diff/android/build.gradle b/apps/mobile/modules/t3-review-diff/android/build.gradle
--- a/apps/mobile/modules/t3-review-diff/android/build.gradle
+++ b/apps/mobile/modules/t3-review-diff/android/build.gradle
@@ -16,4 +16,5 @@
 
 dependencies {
   implementation project(':expo-modules-core')
+  implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
 }

diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt
@@ -44,6 +44,9 @@
       Prop("initialRowIndex") { view: T3ReviewDiffView, initialRowIndex: Double ->
         view.setInitialRowIndex(initialRowIndex)
       }
+      Prop("refreshing") { view: T3ReviewDiffView, refreshing: Boolean ->
+        view.setRefreshing(refreshing)
+      }
 
       Events(
         "onDebug",
@@ -52,6 +55,7 @@
         "onToggleViewedFile",
         "onPressLine",
         "onToggleComment",
+        "onPullToRefresh",
       )
 
       AsyncFunction("scrollToFile") { view: T3ReviewDiffView, fileId: String, animated: Boolean ->

diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
--- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
+++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt
@@ -12,6 +12,7 @@
 import android.view.ViewGroup
 import android.view.ViewConfiguration
 import android.widget.OverScroller
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
 import expo.modules.kotlin.AppContext
 import expo.modules.kotlin.viewevent.EventDispatcher
 import expo.modules.kotlin.views.ExpoView
@@ -24,12 +25,16 @@
 
 class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
   private val canvasView = DiffCanvasView(context)
+  private val swipeRefreshLayout = object : SwipeRefreshLayout(context) {
+    override fun canChildScrollUp(): Boolean = canvasView.verticalOffset() > 0
+  }
   private val onDebug by EventDispatcher()
   private val onVisibleFileChange by EventDispatcher()
   private val onToggleFile by EventDispatcher()
   private val onToggleViewedFile by EventDispatcher()
   private val onPressLine by EventDispatcher()
   private val onToggleComment by EventDispatcher()
+  private val onPullToRefresh by EventDispatcher()
   private var rows: List<DiffRow> = emptyList()
   private var visibleRows: List<DiffRow> = emptyList()
   private var collapsedFileIds: Set<String> = emptySet()
@@ -66,12 +71,21 @@
       emitVisibleFile(first)
     }
 
-    addView(
+    swipeRefreshLayout.addView(
       canvasView,
       LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
     )
+    swipeRefreshLayout.setOnRefreshListener { onPullToRefresh(emptyMap()) }
+    addView(
+      swipeRefreshLayout,
+      LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),
+    )
   }
 
+  fun setRefreshing(value: Boolean) {
+    swipeRefreshLayout.isRefreshing = value
+  }
+
   fun setTokensResetKey(value: String) {
     if (tokensResetKey == value) return
     tokensResetKey = value
@@ -163,6 +177,7 @@
   }
 
   fun setTokensPatchJson(value: String) {
+    val capturedContentResetKey = contentResetKey
     payloadDecodeExecutor.execute {
       try {
         val payload = JSONObject(value)
@@ -171,6 +186,7 @@
           payload.optJSONObject("tokensByRowId") ?: JSONObject(),
         )
         post {
+          if (capturedContentResetKey != contentResetKey) return@post
           if (resetKey.isNotEmpty() && resetKey != tokensResetKey) return@post
           if (decodedTokens.isNotEmpty()) {
             canvasView.tokensByRowId = canvasView.tokensByRowId + decodedTokens
@@ -211,7 +227,14 @@
           val deltaX = event.x - lastTouchX
           val deltaY = event.y - lastTouchY
           if (max(abs(deltaX), abs(deltaY)) > touchSlop) {
-            dragAxis = if (abs(deltaY) >= abs(deltaX)) DragAxis.VERTICAL else DragAxis.HORIZONTAL
+            if (abs(deltaY) >= abs(deltaX)) {
+              if (deltaY > 0 && canvasView.verticalOffset() == 0) {
+                return false
+              }
+              dragAxis = DragAxis.VERTICAL
+            } else {
+              dragAxis = DragAxis.HORIZONTAL
+            }
           }
         }
         return dragAxis != null
@@ -839,6 +862,7 @@
           textPaint.textSize = style.codeFontSizePx
           textPaint.color = token.color ?: theme.text
           textPaint.typeface = when {
+            token.fontStyle and 3 == 3 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD_ITALIC)
             token.fontStyle and 1 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.ITALIC)
             token.fontStyle and 2 != 0 -> Typeface.create(Typeface.MONOSPACE, Typeface.BOLD)
             else -> Typeface.MONOSPACE

You can send follow-ups to the cloud agent here.

Comment thread apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx
Comment thread apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx
Comment thread apps/mobile/src/features/home/HomeHeader.tsx
Comment thread apps/mobile/src/features/home/HomeScreen.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 threads/ThreadComposer.tsx:208

ComposerConnectionStatusPill renders as a pressable that calls props.onPress for every status kind, including the new "syncing" state. In ThreadComposer, onPress is wired to onReconnectEnvironment, so tapping the pill during normal message loading/syncing triggers retryEnvironment and interrupts the in-progress sync instead of acting as a passive progress indicator. Consider rendering the pill as a non-interactive View (or disabling the Pressable) when status.kind === "syncing".

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/threads/ThreadComposer.tsx around line 208:

`ComposerConnectionStatusPill` renders as a pressable that calls `props.onPress` for every status kind, including the new `"syncing"` state. In `ThreadComposer`, `onPress` is wired to `onReconnectEnvironment`, so tapping the pill during normal message loading/syncing triggers `retryEnvironment` and interrupts the in-progress sync instead of acting as a passive progress indicator. Consider rendering the pill as a non-interactive `View` (or disabling the `Pressable`) when `status.kind === "syncing"`.

Comment thread apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Comment thread apps/mobile/src/Stack.tsx
Comment on lines +81 to +85
fun setContentResetKey(value: String) {
if (contentResetKey == value) return
contentResetKey = value
tokensDecodeGeneration += 1
canvasView.tokensByRowId = emptyMap()

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

setContentResetKey clears tokens and scroll position but never clears rows, visibleRows, or canvasView.rows. Since new rowsJson is decoded asynchronously on a background thread, the previous file's rows remain rendered and tappable until the new rows finish decoding and are posted back. Users can see and interact with the wrong file's lines and comments after navigating to a different file. Consider clearing the rows in setContentResetKey so the view is blanked immediately while the new payload is in flight.

  fun setContentResetKey(value: String) {
    if (contentResetKey == value) return
    contentResetKey = value
-    tokensDecodeGeneration += 1
-    canvasView.tokensByRowId = emptyMap()
+    rowsDecodeGeneration += 1
+    rows = emptyList()
+    visibleRows = emptyList()
+    canvasView.rows = emptyList()
+    canvasView.tokensByRowId = emptyMap()
    lastVisibleFileId = null
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt around lines 81-85:

`setContentResetKey` clears tokens and scroll position but never clears `rows`, `visibleRows`, or `canvasView.rows`. Since new `rowsJson` is decoded asynchronously on a background thread, the previous file's rows remain rendered and tappable until the new rows finish decoding and are posted back. Users can see and interact with the wrong file's lines and comments after navigating to a different file. Consider clearing the rows in `setContentResetKey` so the view is blanked immediately while the new payload is in flight.

container.addView(
inputView,
LinearLayout.LayoutParams(1, 1),
inputView.addTextChangedListener(

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

TextWatcher.onTextChanged only forwards the inserted substring (s.subSequence(start, end)) and then clears the EditText in afterTextChanged. For replacement edits where before > 0 and count > 0—such as IME autocorrect replacing teh with the—Android reports only the replacement text, so this code emits the without first emitting a backspace to delete the previously sent teh. The terminal receives duplicated or mangled input instead of the final committed string.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt around line 200:

`TextWatcher.onTextChanged` only forwards the inserted substring (`s.subSequence(start, end)`) and then clears the `EditText` in `afterTextChanged`. For replacement edits where `before > 0` and `count > 0`—such as IME autocorrect replacing `teh` with `the`—Android reports only the replacement text, so this code emits `the` without first emitting a backspace to delete the previously sent `teh`. The terminal receives duplicated or mangled input instead of the final committed string.

bmdavis419 and others added 5 commits July 9, 2026 16:02
…3643)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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: codex <codex@users.noreply.github.com>
lastWidth = width
lastHeight = height
emitResize()
private fun renderSnapshot() {

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

renderSnapshot() silently discards snapshot decode failures: when nativeSnapshot() returns an empty ByteArray on native error paths, TerminalFrame.decode() returns null, and ?.let skips the canvas update. The view keeps showing the previous frame after scroll, resize, feed, or theme updates, so users see stale terminal contents with no error indication. Consider logging or surfacing the decode failure so stale renders are not masked.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-terminal/android/src/main/java/expo/modules/t3terminal/T3TerminalView.kt around line 307:

`renderSnapshot()` silently discards snapshot decode failures: when `nativeSnapshot()` returns an empty `ByteArray` on native error paths, `TerminalFrame.decode()` returns `null`, and `?.let` skips the canvas update. The view keeps showing the previous frame after scroll, resize, feed, or theme updates, so users see stale terminal contents with no error indication. Consider logging or surfacing the decode failure so stale renders are not masked.

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

if (selectedThread === null || environmentId === null || threadId === null) {

On Android, the early-return paths in ThreadFilesTreeScreen (loading, missing workspace, files unavailable) render without setting headerShown: false, so they inherit the stack's default headerShown: true and show the native stack header. The headerShown: !isAndroid override only runs in the final render path at line 365, so Android shows the wrong header chrome while the thread hydrates or when no workspace is attached. Consider hoisting headerShown: !isAndroid into a NativeStackScreenOptions call placed before the early returns so every branch suppresses the native header on Android.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx around line 330:

On Android, the early-return paths in `ThreadFilesTreeScreen` (loading, missing workspace, files unavailable) render without setting `headerShown: false`, so they inherit the stack's default `headerShown: true` and show the native stack header. The `headerShown: !isAndroid` override only runs in the final render path at line 365, so Android shows the wrong header chrome while the thread hydrates or when no workspace is attached. Consider hoisting `headerShown: !isAndroid` into a `NativeStackScreenOptions` call placed before the early returns so every branch suppresses the native header on Android.

className="flex-1 py-2 text-base font-sans text-foreground"
/>
</View>
<ControlPillMenu

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 archive/ArchivedThreadsScreen.tsx:174

The filter menu button is inaccessible to TalkBack on Android. ControlPillMenu wraps its child in AndroidAnchoredMenu, which renders an outer Pressable around the child and disables pointer events on the child via pointerEvents="none". TalkBack focuses the outer Pressable, which has no accessibilityLabel, so screen-reader users encounter an unlabeled button. The accessibilityLabel and accessibilityRole are only set on the inner Pressable, which TalkBack skips. Consider propagating accessibilityLabel and accessibilityRole from the child to the AndroidAnchoredMenu outer Pressable (or accepting them as props on ControlPillMenu).

Also found in 1 other location(s)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:773

In the collapsed Android composer, the primary submit ControlPill is rendered with only icon=&#34;arrow.up&#34; and no label or accessibilityLabel. ControlPill forwards accessibilityLabel={props.accessibilityLabel ?? props.label}, so TalkBack gets an unlabeled button and screen-reader users cannot discover or activate the screen's main start/queue action reliably.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx around line 174:

The filter menu button is inaccessible to TalkBack on Android. `ControlPillMenu` wraps its child in `AndroidAnchoredMenu`, which renders an outer `Pressable` around the child and disables pointer events on the child via `pointerEvents="none"`. TalkBack focuses the outer `Pressable`, which has no `accessibilityLabel`, so screen-reader users encounter an unlabeled button. The `accessibilityLabel` and `accessibilityRole` are only set on the inner `Pressable`, which TalkBack skips. Consider propagating `accessibilityLabel` and `accessibilityRole` from the child to the `AndroidAnchoredMenu` outer `Pressable` (or accepting them as props on `ControlPillMenu`).

Also found in 1 other location(s):
- apps/mobile/src/features/threads/NewTaskDraftScreen.tsx:773 -- In the collapsed Android composer, the primary submit `ControlPill` is rendered with only `icon="arrow.up"` and no `label` or `accessibilityLabel`. `ControlPill` forwards `accessibilityLabel={props.accessibilityLabel ?? props.label}`, so TalkBack gets an unlabeled button and screen-reader users cannot discover or activate the screen's main start/queue action reliably.

Comment on lines +81 to +91
fun setContentResetKey(value: String) {
if (contentResetKey == value) return
contentResetKey = value
tokensDecodeGeneration += 1
canvasView.tokensByRowId = emptyMap()
lastVisibleFileId = null
pendingInitialScroll = true
canvasView.setVerticalOffset(0)
canvasView.setHorizontalOffset(0)
applyPendingInitialScroll()
}

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

setContentResetKey resets canvasView offsets to 0 but does not stop verticalScroller or horizontalScroller. If the user switches content while a fling or animated scroll is still running, computeScroll() continues applying the old scroller positions on subsequent frames, overriding the reset and moving the new diff away from offset 0. Call forceFinished(true) on both scrollers before resetting the offsets.

Suggested change
fun setContentResetKey(value: String) {
if (contentResetKey == value) return
contentResetKey = value
tokensDecodeGeneration += 1
canvasView.tokensByRowId = emptyMap()
lastVisibleFileId = null
pendingInitialScroll = true
canvasView.setVerticalOffset(0)
canvasView.setHorizontalOffset(0)
applyPendingInitialScroll()
}
fun setContentResetKey(value: String) {
if (contentResetKey == value) return
contentResetKey = value
tokensDecodeGeneration += 1
canvasView.tokensByRowId = emptyMap()
lastVisibleFileId = null
pendingInitialScroll = true
verticalScroller.forceFinished(true)
horizontalScroller.forceFinished(true)
canvasView.setVerticalOffset(0)
canvasView.setHorizontalOffset(0)
applyPendingInitialScroll()
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt around lines 81-91:

`setContentResetKey` resets `canvasView` offsets to `0` but does not stop `verticalScroller` or `horizontalScroller`. If the user switches content while a fling or animated scroll is still running, `computeScroll()` continues applying the old scroller positions on subsequent frames, overriding the reset and moving the new diff away from offset `0`. Call `forceFinished(true)` on both scrollers before resetting the offsets.

juliusmarminge and others added 5 commits July 9, 2026 20:06
Co-authored-by: codex <codex@users.noreply.github.com>
…ead work log

The wrapper already re-exports expo-symbols' name type as AppSymbolName for
exactly this use; drop the direct expo-symbols type import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Long-pressing the app icon now offers a static "New task" shortcut plus the
2-3 most recently opened threads. Shortcut items carry in-app hrefs (same
paths as agent notifications), so taps route through linkTo — on cold start
the target is pushed over the initial Home route, keeping back-navigation
sane (sheet back -> home, not app exit).

Thread opens are derived from the root navigation state in the stack layout
(no changes to the thread screens); titles come from the thread-shell atom
and update the shortcut label once they load. Recents persist in secure
storage so the launcher list survives restarts. Runtime updates are gated to
Android; the config plugin generates the adaptive shortcut icon resource.

Requires expo prebuild + a dev-client rebuild (new native module).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flips predictiveBackGestureEnabled on (manifest
android:enableOnBackInvokedCallback="true"), which retires legacy
KEYCODE_BACK/onBackPressed() delivery on Android 13+.

react-native 0.85 keeps JS back handling alive under that mode only on
Android 16 + targetSdk 36 (ReactActivity registers an always-enabled
OnBackPressedCallback there). On Android 13-15 nothing registers, so every
back gesture would background the app and React Navigation/BackHandler
would never hear it. withAndroidPredictiveBackCompat mirrors the same shim
into MainActivity for API 33-35, wrapping invokeDefaultOnBackPressed so the
exit path cannot re-enter the dispatcher and loop.

Audit notes:
- AndroidAnchoredMenu's BackHandler dismiss stays correct: back reaches JS
  via the registered dispatcher callbacks on every API level, and a
  registered callback also means the system never plays a "leave app"
  preview while the menu merely closes. Comment documents the invariant.
- react-native-screens 4.25.2 is both the SDK 56 pin and the latest
  release; v4 has no predictive-back progress animations (upstream:
  software-mansion/react-native-screens#2540), so in-app pops keep the
  standard screens transition.
- Android form sheets (Git sheets etc.) are Material BottomSheetDialog
  fragments that own their dialog-scoped back dispatch; NewTaskSheet and
  Settings present as cards on Android and pop via React Navigation.

Requires expo prebuild + a dev-client rebuild (manifest + MainActivity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Uniwind conversion moved the thread composer's pill stretch from
style={{maxWidth:'100%', width:'100%'}} to className="w-full max-w-full",
but ComposerToolbarButton's default cap (maxWidth: 172) still lived in the
inline style, which beats className-derived styles — so the model and
reasoning pills stopped filling their flex share and left dead space at the
row's end. Move the default cap into the class chain where tailwind-merge
lets callers lift it with max-w-full; the numeric maxWidth prop keeps
winning via the inline style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment thread apps/mobile/src/features/shortcuts/useAppShortcuts.ts Outdated
Comment thread apps/mobile/src/features/shortcuts/useAppShortcuts.ts
Comment thread apps/mobile/src/features/shortcuts/useAppShortcuts.ts
Comment thread apps/mobile/src/features/shortcuts/appShortcuts.ts
Comment thread apps/mobile/src/features/shortcuts/appShortcuts.ts
Comment thread apps/mobile/src/Stack.tsx
// Presents the T3 Connect onboarding sheet after an in-session sign-in.
useConnectOnboardingNavigation();
// Launcher app shortcuts: routes shortcut taps and tracks opened threads.
useAppShortcuts(props.state);

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/Stack.tsx:269

useAppShortcuts can crash the root layout on a malformed thread deep link. If a shortcut, notification, or external link resolves to the Thread route with invalid environmentId or threadId params, EnvironmentId.make() / ThreadId.make() inside the hook throw during render, bringing down the entire navigation tree instead of ignoring the bad link. The same constructors are wrapped in try/catch elsewhere (e.g. AdaptiveWorkspaceLayout), so guard the route-param parsing inside useAppShortcuts the same way.

Also found in 1 other location(s)

apps/mobile/src/features/shortcuts/appShortcuts.ts:32

shortcutHref only checks whether action.params?.href starts with /, so any persisted shortcut carrying an arbitrary in-app path like /settings or an obsolete route is treated as valid. useShortcutNavigation then passes that value straight to linkTo, which makes the app navigate on stale/foreign shortcuts even though this helper's contract says those must be rejected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/mobile/src/Stack.tsx around line 269:

`useAppShortcuts` can crash the root layout on a malformed thread deep link. If a shortcut, notification, or external link resolves to the `Thread` route with invalid `environmentId` or `threadId` params, `EnvironmentId.make()` / `ThreadId.make()` inside the hook throw during render, bringing down the entire navigation tree instead of ignoring the bad link. The same constructors are wrapped in `try/catch` elsewhere (e.g. `AdaptiveWorkspaceLayout`), so guard the route-param parsing inside `useAppShortcuts` the same way.

Also found in 1 other location(s):
- apps/mobile/src/features/shortcuts/appShortcuts.ts:32 -- `shortcutHref` only checks whether `action.params?.href` starts with `/`, so any persisted shortcut carrying an arbitrary in-app path like `/settings` or an obsolete route is treated as valid. `useShortcutNavigation` then passes that value straight to `linkTo`, which makes the app navigate on stale/foreign shortcuts even though this helper's contract says those must be rejected.

shivamhwp and others added 2 commits July 10, 2026 08:13
Addresses the review-bot findings on the shortcuts module:

- activeThreadRef (moved to the pure module) no longer trusts route
  params: trims, type-guards, and catches the branded id constructors'
  schema throws, returning null for malformed input. It runs during
  render of the root stack layout, so a crafted deep link like
  t3code-dev://threads/%20/x previously threw and took down the whole
  navigation tree.
- A failed recents load no longer erases history: the empty in-memory
  fallback still syncs the launcher, but storage writes are gated until
  a successful load or a real thread open. Saves are also chained so an
  older write cannot land after (and overwrite) a newer one.
- shortcutHref allowlists exactly /new and /threads/<seg>/<seg> instead
  of accepting any path-shaped string from launcher-persisted actions.
- Launcher ids reuse the URI-encoded href, so distinct env/thread pairs
  can no longer collide on a plain '-' join.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hardware/gesture back inside a drilled-in submenu now pops one level
(matching the tappable parent-title header) instead of dismissing the
whole menu; a further back at the top level still closes it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ons, Nerd Font (#3775)

Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>

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

There are 15 total unresolved issues (including 13 from previous reviews).

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit 80cf687. Configure here.

if (result._tag === "Failure" && reopenedStaleTerminalKeyRef.current === terminalKey) {
reopenedStaleTerminalKeyRef.current = null;
}
});

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.

Duplicate terminal open on launch

Medium Severity

The new useEffect designed to re-open stale or exited terminals can issue an openTerminal RPC concurrently with an existing pending terminal launch for the same session, leading to redundant openTerminal requests.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80cf687. Configure here.

const sessionEnded = terminal.status === "exited" || terminal.status === "closed";
if (!sessionEnded || runningTerminalKeyRef.current !== terminalKey) {
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.

Exit missed after attach detach

Medium Severity

When terminalAttachInput becomes null (for example during a brief disconnect), the exit handler clears runningTerminalKeyRef but still requires that ref to match before treating exit/closed as a session the user ended on this screen. If the shell exits while attach is detached, reattach can show an exited session without navigating away, and the stale-reopen effect may spawn a new shell instead of mirroring the web drawer exit flow.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 80cf687. Configure here.

@juliusmarminge juliusmarminge merged commit c1ec191 into main Jul 12, 2026
19 of 20 checks passed
@juliusmarminge juliusmarminge deleted the android-dev-pr-3514 branch July 12, 2026 10:03

@mrpunyetaz-cloud mrpunyetaz-cloud left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I need to solve this before merged

mwolson added a commit to mwolson/t3code that referenced this pull request Jul 12, 2026
…apping

The CTM squash auto-merged upstream/main's Android mobile support (pingdotgg#3579) into
CTM-shaped mobile sources, leaving hybrids: main's persistence/ and
state/preferences.ts import an environment-cache-store written against pre-V2
contracts, and CTM's connection/storage.ts calls a makeCatalogStore export the
main-shaped catalog-store no longer has. Restore the whole apps/mobile/src tree
to codex-turn-mapping shape; mobile builds are not produced from this tree.
tarik02 added a commit to tarik02/t3code that referenced this pull request Jul 13, 2026
* Add middle-click close for right panel tabs (pingdotgg#3161)

Co-authored-by: Julius Marminge <jmarminge@gmail.com>

* fix: warm WSL before preflight in WSL-only backend mode (pingdotgg#3588)

* Add Claude Sonnet 5 as the default Claude model (pingdotgg#3620)

* Restore the ultrathink frame border effect (pingdotgg#3625)

* fix(dev): Fix electron dev launch and add test (pingdotgg#3662)

* Add adaptive split-view layout for iPad/mobile workspace (pingdotgg#3514)

Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(mobile): compile patched native pods from source on EAS (pingdotgg#3667)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Make the thread composer read as elevated liquid glass (pingdotgg#3668)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Upgrade Vite Plus and enable bundled dev opt-in (pingdotgg#3679)

* Surface pending tasks in mobile home and draft flow (pingdotgg#3670)

* fix(mobile): combined test branch — scroll, back-swipe, thread lists, computer switching (pingdotgg#3687)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Add repo-root favicon.svg so t3 code shows its own icon (pingdotgg#3683)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Load thread snapshots over HTTP before live sync (pingdotgg#3719)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Fix mobile legend anchor under automatic iOS insets (pingdotgg#3684)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Improve live activity routing and diagnostics (pingdotgg#3685)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* Prevent Add Project sheet from collapsing on relayout (pingdotgg#3759)

* Use variant-specific splash icons in mobile app (pingdotgg#3762)

* Fix Expo widget asset wiring order (pingdotgg#3763)

* Extend Done display to 15 minutes and show up to 5 Live Activity banner rows (pingdotgg#3761)

* Clear VCS presentation state on finish (pingdotgg#3764)

* Lead with the outcome when no agents are active in the Live Activity (pingdotgg#3768)

* Add T3 Connect onboarding for mobile and web (pingdotgg#3765)

* Revert "Add T3 Connect onboarding for mobile and web" (pingdotgg#3776)

* Expose Clerk Google sign-in env vars to Expo (pingdotgg#3772)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Set up Cursor Cloud dev environment (web + Android toolchain) (pingdotgg#3755)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>

* Revert "Revert "Add T3 Connect onboarding for mobile and web"" (pingdotgg#3777)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Use rounded depth logo for production splash screen (pingdotgg#3780)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(release): stage pnpm 11 allowBuilds for desktop installs (pingdotgg#3781)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* Upgrade Clerk toolchain to latest versions (pingdotgg#3785)

* fix(release): bump electron-builder so pnpm 11 deduped deps land in the asar (pingdotgg#3790)

* Fix desktop native optional dependency packaging (pingdotgg#3816)

* [codex] Upgrade Clerk stack (pingdotgg#3821)

Co-authored-by: codex <codex@users.noreply.github.com>

* [codex] Preserve worktree metadata during branch sync (pingdotgg#3822)

Co-authored-by: codex <codex@users.noreply.github.com>

* feat(client): persist offline environment data and mobile preferences (pingdotgg#3795)

Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: codex <codex@users.noreply.github.com>

* [codex] Label max and ultra reasoning (pingdotgg#3824)

Co-authored-by: codex <codex@users.noreply.github.com>

* fix(mobile): embed fonts and render project favicons reliably (pingdotgg#3823)

Co-authored-by: codex <codex@users.noreply.github.com>

* Show compact PR number badges in mobile thread rows (pingdotgg#3827)

Co-authored-by: codex <codex@users.noreply.github.com>

* Expose mobile PR indicator labels to accessibility (pingdotgg#3828)

Co-authored-by: codex <codex@users.noreply.github.com>

* Fix truncated chat error alert layout (pingdotgg#3899)

* fix(marketing): show platform-appropriate commit shortcut on the website (pingdotgg#3644)

* [codex] Add Android mobile support (pingdotgg#3579)

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: Hugo Blom <6117705+huxcrux@users.noreply.github.com>
Co-authored-by: Julius Marminge <jmarminge@gmail.com>
Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: Theo Browne <me@t3.gg>
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: Julius Marminge <julius@mac.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Theo Browne <t3dotgg@users.noreply.github.com>
Co-authored-by: Rowan <rowan@cardow.co>
Co-authored-by: Patricio Gómez Meneses <107218376+Prgm-code@users.noreply.github.com>
Co-authored-by: Jake Leventhal <jakeleventhal@me.com>
Co-authored-by: Vedank Purohit <VedankPurohit2@gmail.com>
Co-authored-by: Horus Lugo <horusgoul@gmail.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>
znoraka added a commit to znoraka/t3code that referenced this pull request Jul 13, 2026
Resolves apps/mobile/app.config.ts: keep fork's buildConfig(base) wrapper
for self-hosted OTA updates while adopting upstream's extracted
widgetsPlugin const and expo-asset plugin (Android support, pingdotgg#3579).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HaukeSchnau added a commit to HaukeSchnau/t3code that referenced this pull request Jul 13, 2026
Merge upstream/main through [codex] Add Android mobile support (pingdotgg#3579) into the personal fork.

Resolve the mobile configuration conflict by retaining upstream conditional widget installation for Android and personal-team builds while preserving the fork-configurable iOS bundle identifier for widget and app-group IDs. Remove the stale native-header theming fragment from ThreadRouteScreen while preserving upstream Android in-flow header behavior.

Verification:
- vp check (passes with 10 pre-existing warnings; output redirected around a Vite+ stdout panic)
- vp run typecheck
- vp run lint:mobile (passes; Linux host skips unavailable SwiftLint, ktlint, and detekt)

No feature work is included in this sync merge.
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:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants