From 66b96c3861a7d51234ea4a0fcc006d282a1b7925 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:41:22 +0900 Subject: [PATCH 1/6] Allow overriding EAS owner and projectId via env (#1) --- apps/mobile/app.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 4a0c761f2c6..b8e4321b19c 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -345,10 +345,10 @@ const config: ExpoConfig = { tracesToken: repoEnv.EXPO_PUBLIC_OTLP_TRACES_TOKEN ?? null, }, eas: { - projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454", + projectId: process.env.T3CODE_EAS_PROJECT_ID ?? "d763fcb8-d37c-41ea-a773-b54a0ab4a454", }, }, - owner: "pingdotgg", + owner: process.env.T3CODE_EAS_OWNER ?? "pingdotgg", }; export default config; From fe1b60535d59f15b0ce24b58c6e9b32be8209384 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:10:26 +0900 Subject: [PATCH 2/6] Add personal iOS submit profile (#2) Additive eas.json submit profile for the personal TestFlight app (ascAppId 6793805091, ASC API key read from local decent-measure credentials). Upstream's production profile stays untouched. --- apps/mobile/eas.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index a1d315b7e14..e3249c3d5f8 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -59,6 +59,15 @@ "android": { "track": "internal" } + }, + "personal": { + "ios": { + "ascAppId": "6793805091", + "appleTeamId": "C7X9BCC7LP", + "ascApiKeyPath": "/Users/emanuel/code/decent-measure/apps/expo/credentials/ios/AuthKey_5287YYUU8B.p8", + "ascApiKeyId": "5287YYUU8B", + "ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1" + } } } } From 4f3e1e89514f77595d6c84ff76e3a1868a8e043a Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:36:57 +0900 Subject: [PATCH 3/6] Add Android package override and Play submit config (#3) T3CODE_ANDROID_PACKAGE env hook mirrors the iOS personal-team override so personal builds ship under com.raijustudios.t3code. The personal submit profile gains the Play internal-track config using decent-measure's service account key. --- apps/mobile/app.config.ts | 2 +- apps/mobile/eas.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index b8e4321b19c..9e8e29fc721 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -201,7 +201,7 @@ const config: ExpoConfig = { }, android: { icon: variant.assets.appIcon, - package: variant.androidPackage, + package: process.env.T3CODE_ANDROID_PACKAGE ?? variant.androidPackage, adaptiveIcon: { backgroundColor: variant.assets.androidAdaptiveBackgroundColor, foregroundImage: variant.assets.androidAdaptiveForeground, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index e3249c3d5f8..cb0ad05914d 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -67,6 +67,10 @@ "ascApiKeyPath": "/Users/emanuel/code/decent-measure/apps/expo/credentials/ios/AuthKey_5287YYUU8B.p8", "ascApiKeyId": "5287YYUU8B", "ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1" + }, + "android": { + "serviceAccountKeyPath": "/Users/emanuel/code/decent-measure/apps/expo/credentials/android/barbellry-45151cd5babe.json", + "track": "internal" } } } From 6d1079ca40560bb04a3cb909596b4865fa01b20e Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:52:52 +0900 Subject: [PATCH 4/6] Make submit credentials self-contained (#5) * Make submit credentials self-contained Submit keys now live in gitignored apps/mobile/credentials/ instead of absolute paths into the decent-measure checkout. * Add personal release-pipeline runbook and research docs Ported from the planning repo (r4iju/t3-code) ahead of archiving it; docs/personal/ is additive and stays clear of upstream merges. --- apps/mobile/.gitignore | 3 + apps/mobile/eas.json | 4 +- docs/personal/release-pipeline.md | 90 ++++++++++++++ ...rch-server-protocol-and-direct-lan-auth.md | 114 ++++++++++++++++++ 4 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 docs/personal/release-pipeline.md create mode 100644 docs/personal/research-server-protocol-and-direct-lan-auth.md diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore index d914c328fe1..fef62600a87 100644 --- a/apps/mobile/.gitignore +++ b/apps/mobile/.gitignore @@ -9,6 +9,9 @@ dist/ web-build/ expo-env.d.ts +# Signing / store credentials (personal fork) +credentials/ + # Native .kotlin/ *.orig.* diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index cb0ad05914d..8492627ed82 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -64,12 +64,12 @@ "ios": { "ascAppId": "6793805091", "appleTeamId": "C7X9BCC7LP", - "ascApiKeyPath": "/Users/emanuel/code/decent-measure/apps/expo/credentials/ios/AuthKey_5287YYUU8B.p8", + "ascApiKeyPath": "./credentials/ios/AuthKey_5287YYUU8B.p8", "ascApiKeyId": "5287YYUU8B", "ascApiKeyIssuerId": "69a6de96-923d-47e3-e053-5b8c7c11a4d1" }, "android": { - "serviceAccountKeyPath": "/Users/emanuel/code/decent-measure/apps/expo/credentials/android/barbellry-45151cd5babe.json", + "serviceAccountKeyPath": "./credentials/android/barbellry-45151cd5babe.json", "track": "internal" } } diff --git a/docs/personal/release-pipeline.md b/docs/personal/release-pipeline.md new file mode 100644 index 00000000000..b42d087a6a5 --- /dev/null +++ b/docs/personal/release-pipeline.md @@ -0,0 +1,90 @@ +# Release pipeline: personal T3 Code mobile builds + +> Ported 2026-07-25 from the (archived) planning repo [r4iju/t3-code](https://github.com/r4iju/t3-code); +> this copy is now the living runbook. Historical decision links below point at that repo's issues. + +Spec for [issue #6](https://github.com/r4iju/t3-code/issues/6). Decisions settled on the +[wayfinder map](https://github.com/r4iju/t3-code/issues/1) on 2026-07-23: v1 is upstream's +`apps/mobile` as-is (#5), fork delta stays env/config-only with ad-hoc `upstream/main` merges (#8), +distribution is personal/internal only, connectivity is direct LAN only. + +## Principles + +- **The fork ships upstream's app; we ship the pipeline.** No feature delta, so the pipeline is the + product of this effort. +- **Env/config-only delta.** Pipeline config lives in EAS environment variables, additive files, and + this repo's docs — upstream files are edited only when unavoidable (e.g. if `app.config.ts` lacks + an override hook for bundle identity). +- **decent-measure is the style reference, not a template to copy.** Where its choices fight the + env/config-only rule (local credentials + fastlane), we deviate deliberately. + +## Identity + +- Own bundle/application ID per variant, own EAS project, own App Store Connect record — upstream's + `com.t3tools.t3code.*` IDs stay untouched for clean upstream merges: + - iOS/Android production: `com.raijustudios.t3code` + - Development variant keeps upstream's dev ID locally (dev builds are never distributed). +- Apple team `C7X9BCC7LP` (same as decent-measure). App name on TestFlight: "T3 Code (personal)". +- Prefer setting identity via EAS env vars / `APP_VARIANT` hooks if upstream's `app.config.ts` + supports it; otherwise a minimal, well-marked edit in `app.config.ts` (allowed "when unavoidable"). + +## Build & distribute + +| Platform | Profile | Output | Distribution | +| -------- | --------------------- | ------ | ----------------------------------------------- | +| iOS | upstream `production` | .ipa | `eas submit` → **TestFlight internal** | +| Android | upstream `production` | .aab | `eas submit` → **Google Play internal testing** | + +> **Decision change (2026-07-23):** Android originally shipped as a preview APK via EAS internal +> link. Changed to match decent-measure: production AAB submitted to the Play internal testing +> track with the same Play service account (`barbellry-…json`). Requires a Play Console app record +> for `com.raijustudios.t3code` (manual — Play has no app-creation API) and the fork's +> `T3CODE_ANDROID_PACKAGE` env hook (upstream has no Android identity override). + +- **EAS cloud is the default builder**; the free tier covers occasional personal builds. + Local `eas build --local` is the documented fallback (needs Xcode 26.1+ — ticket #9 — and + JDK 17 + `ANDROID_HOME`; see friction log on + [issue #3](https://github.com/r4iju/t3-code/issues/3)). +- **Credentials: EAS-managed (remote)** for both platforms. This deviates from decent-measure's + local-credentials + fastlane setup on purpose — remote credentials mean zero credential files in + the fork, consistent with env/config-only. Submit credentials (ASC API key for iOS, Play service + account key for Android, both shared with decent-measure) live in the gitignored + `apps/mobile/credentials/` and are referenced by the `personal` submit profile — nothing secret + is committed. +- **Versioning:** follow upstream's app version (their `appVersion` runtime policy); build numbers + auto-increment via EAS remote version source. +- **T3 Connect env vars stay unset.** LAN-only scope; cloud UI stays disabled in our builds. + +## Release ritual + +```bash +cd ~/code/t3code +git fetch upstream && git merge upstream/main # ad-hoc sync (#8) +cd apps/mobile +# T3CODE_EAS_OWNER / T3CODE_EAS_PROJECT_ID / T3CODE_ANDROID_PACKAGE env vars required locally +vp run eas:ios:prod # → .ipa +vp run eas:android:prod # → .aab +eas submit -p ios --latest --profile personal # → TestFlight internal +eas submit -p android --latest --profile personal # → Play internal testing +``` + +No CI initially — releases are manual and occasional. If cadence grows, lift upstream's +fingerprint-based EAS workflow (`.github/workflows/mobile-eas-*.yml`) into the fork. + +## Server operations (decided with the map) + +The LAN server is the **desktop app with Settings → Connections → Network access toggled on** +(binds `0.0.0.0:3773`, stable port, built-in pairing QR). No launchd service, no headless `t3 serve` +for daily use. Device pairing management: `t3 auth` (`pairing create`, `session list/revoke`). +Pairing tokens expire in ~5 minutes — mint right before pairing a new device. + +## Implementation checklist (post-map execution) + +1. Create the App Store Connect app record for `com.raijustudios.t3code`. +2. `eas init` in the fork's `apps/mobile` against a personal EAS project; set EAS env vars/secrets + (ASC API key; identity overrides if the env hook exists). +3. First iOS `production` build + submit; install from TestFlight. +4. Create the Play Console app record for `com.raijustudios.t3code` (manual); first Android + `production` AAB build + submit to the internal testing track; install from Play. +5. Pair both against the desktop app's network endpoint (`http://:3773`). +6. Verify local-build fallback once Xcode 26.1+ lands (#9). diff --git a/docs/personal/research-server-protocol-and-direct-lan-auth.md b/docs/personal/research-server-protocol-and-direct-lan-auth.md new file mode 100644 index 00000000000..c1a44282960 --- /dev/null +++ b/docs/personal/research-server-protocol-and-direct-lan-auth.md @@ -0,0 +1,114 @@ +# t3code server protocol & direct-LAN auth + +Research for [issue #2](https://github.com/r4iju/t3-code/issues/2). Read against upstream +[pingdotgg/t3code](https://github.com/pingdotgg/t3code) @ main, 2026-07-23 (shallow clone at +`~/code/t3code-upstream`). File references below are paths in that repo. + +## TL;DR + +- **Transport:** one WebSocket at `/ws` carrying **Effect RPC with JSON serialization** (not tRPC, + not REST-per-call, not SSE). A small plain-HTTP surface handles auth, assets, and the SPA. + Streaming (agent output, terminals, VCS status) is RPC streams multiplexed on that same socket. +- **Direct LAN is first-class and relay-free.** `t3 serve --host ` (or the desktop app's + _Settings → Connections → Network access_ toggle) binds beyond loopback and prints/shows a + one-time pairing token + QR. Clerk / T3 Connect is an optional NAT-traversal add-on, disabled in + a fresh clone. +- **The official mobile app already supports exactly our target flow:** an "Add Environment" + screen with Host + pairing-code fields (and QR scan) that exchanges the token for a bearer + session and connects directly over `ws://:3773/ws` — no cloud account, no relay. +- **Protocol reuse outside the monorepo is possible but heavy:** the protocol packages are clean + of platform coupling but private, unbuilt, workspace-only, and pinned to an Effect 4.0 _beta_. + A greenfield client means vendoring three packages and reimplementing the platform seam the + upstream mobile app already implements. + +## Protocol map + +**Primary transport** — `GET /ws` upgrades to a WebSocket serving the `WsRpcGroup` RPC group from +`@t3tools/contracts` via `RpcServer.toHttpEffectWebsocket` with `RpcSerialization.layerJson` +(`apps/server/src/ws.ts:2086-2150`). The per-method authorization-scope map at `ws.ts:288-359` is +effectively the API catalog: `dispatchCommand`, `getTurnDiff`, `subscribeShell`, `subscribeThread`, +`terminal*`, `preview*`, `vcs*`, `git*`, `projects*`, and the `subscribe*` stream family. Agent +output arrives as `thread.*` orchestration events through `subscribeShell`/`subscribeThread` RPC +streams, coalesced server-side (`ws.ts:793-838`). + +**Auxiliary HTTP** (`apps/server/src/server.ts:350-369`): the `EnvironmentHttpApi` (Effect HttpApi; +groups `metadata`, `auth`, `orchestration`, `connect` — defined in +`packages/contracts/src/environmentHttp.ts`), an OTLP trace proxy, asset routes, an MCP HTTP +server, and a static fallback serving the bundled web client. + +**Listen address** — default port **3773** (`apps/server/src/config.ts:17`), binding falls back to +`127.0.0.1` unless `--host`/`T3CODE_HOST` is set (`apps/server/src/server.ts:129-144`). The desktop +app's Network-access toggle rebinds to `0.0.0.0` and advertises the LAN IPv4 +(`apps/desktop/src/backend/DesktopServerExposure.ts:30-134`). + +**CORS** — packaged builds send `access-control-allow-origin: *` (auth is the gate, not CORS; +`apps/server/src/http.ts:45-60`). A native app is outside CORS anyway: it needs only reachability +plus a valid credential. + +## Auth for a direct connection + +There is **no anonymous path, even on loopback** — every request and WS upgrade authenticates +against a scoped session (`apps/server/src/auth/EnvironmentAuth.ts:591,936`). The bootstrap chain: + +1. Server issues a **one-time pairing token**. Headless: `t3 serve` prints connection string, + token, pairing URL (`http://:/pair#token=...`) and a QR code + (`apps/server/src/startupAccess.ts:122-148`). Desktop seeds an unbounded bootstrap token for + its own renderer. +2. Client exchanges it at **`POST /oauth/token`** (RFC 8693 token-exchange shape) for an opaque + **bearer session token** (`EnvironmentAuth.ts:690`). +3. Bearer is traded at **`POST /api/auth/websocket-ticket`** for a short-lived single-use ticket, + passed as `/ws?wsTicket=...` so tokens stay out of URLs. + +Sessions persist (SQLite + chmod-600 secrets under the server state dir); `t3 auth` issues extra +pairing credentials and lists/revokes sessions. Ordinary pairing grants scopes +`orchestration:read/operate terminal:operate review:write relay:read`. + +**The Clerk / T3 Connect relay is optional and off by default** ("disabled in a fresh clone" — +`docs/cloud/t3-connect-clerk.md`). It adds a hosted cloudflared tunnel and a separate credential +system (Clerk JWTs + DPoP); it is irrelevant to our LAN-only scope. `packages/tailscale` and +`packages/ssh` are likewise optional access-method providers that converge on the same WS + +pairing model. + +## The mobile direct-LAN flow (already implemented upstream) + +`apps/mobile/src/features/connection/ConnectionsNewRouteScreen.tsx` is an "Add Environment" screen +with a Host field (placeholder `192.168.1.100:8080`), a pairing-code field, and a QR scanner. It +builds a pairing URL, runs `preparePairingRegistration` +(`packages/client-runtime/src/connection/onboarding.ts:86-119`), and registers a relay-free +`BearerConnectionTarget` (`packages/client-runtime/src/connection/model.ts:18-46`). + +Recipe: `t3 serve --host ` → scan the printed QR from the phone (or type +`http://:3773` + code). One caveat: a bare `host:port` without scheme is normalized to +**`https://`** (`packages/shared/src/remote.ts:85-87`), so plain-HTTP LAN servers need the +explicit `http://` prefix — or the QR, which carries it. + +## Reusability of the protocol packages + +- `@t3tools/contracts` (Effect Schema + Effect RPC/HttpApi contract definitions) and + `@t3tools/client-runtime` (connection lifecycle, RPC session, ~55 files of client state reducers + exposed as Effect Atoms) are **platform-clean**: no React/DOM/Node/Expo imports; WebSocket and + fetch are injected. React binding comes from `@effect/atom-react` in the consuming app. +- But they are **private, versionless (client-runtime), unbuilt (exports point at raw `.ts`), + workspace-only (`workspace:*` deps incl. `@t3tools/shared`), unpublished**, and the whole stack + is pinned to **Effect `4.0.0-beta.78`** using `effect/unstable/*` APIs that can break between + betas. +- A consumer must implement the `platform/` seam (capability + persistence + connection-source + Layers) — upstream mobile does this with `expo-network`, RN `AppState`, `expo-sqlite`, + `expo-secure-store` (`apps/mobile/src/connection/platform.ts`, `src/persistence/`, + `src/lib/runtime.ts`). +- `apps/mobile` is mostly UI on top of that layer, plus **five local Expo native modules** + (Swift/Kotlin): a libghostty-based terminal surface, native diff renderer, composer editor, + native controls, and selectable markdown text. These are the hardest parts to port and the main + reason a from-scratch client is expensive. + +## Implications for the fork-vs-greenfield decision (issue #4) + +- Greenfield ≠ "just call an HTTP API": the protocol is Effect RPC over WS with contracts living + in unpublished beta-pinned packages. A greenfield app either vendors + `contracts`+`client-runtime`+`shared` and adopts Effect wholesale, or reimplements the wire + protocol by hand against a moving target. +- Building inside a fork keeps `workspace:*` resolution, the working platform-seam glue, and the + native modules for free; the decent-measure influence would then be limited to release/tooling + style rather than repo structure. +- Either way, connectivity/auth is a non-issue for our scope: the direct-LAN pairing flow exists, + is relay-free, and is the documented recommended path. From 434407fadaa3f3ded90573ae54f65bf0c1d212c8 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Sun, 26 Jul 2026 09:48:23 +0900 Subject: [PATCH 5/6] feat(mobile): surface terminal URLs as tappable link chips (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native Ghostty surface renders text on the GPU with no link hit-testing, so URLs printed in the terminal (e.g. the claude OAuth login URL) could not be opened on mobile. Extract http(s) URLs from the raw PTY stream — stripping ANSI sequences and reassembling URLs hard-wrapped to the terminal width — and show them as a chip bar above the terminal. Tap opens the full URL, long-press copies it. --- .../src/features/terminal/TerminalLinkBar.tsx | 73 ++++++++ .../features/terminal/ThreadTerminalPanel.tsx | 2 + .../terminal/ThreadTerminalRouteScreen.tsx | 2 + .../terminal/terminalBufferLinks.test.ts | 110 ++++++++++++ .../features/terminal/terminalBufferLinks.ts | 167 ++++++++++++++++++ apps/mobile/src/lib/openExternalUrl.ts | 7 +- 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/terminal/TerminalLinkBar.tsx create mode 100644 apps/mobile/src/features/terminal/terminalBufferLinks.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalBufferLinks.ts diff --git a/apps/mobile/src/features/terminal/TerminalLinkBar.tsx b/apps/mobile/src/features/terminal/TerminalLinkBar.tsx new file mode 100644 index 00000000000..b98d184e35a --- /dev/null +++ b/apps/mobile/src/features/terminal/TerminalLinkBar.tsx @@ -0,0 +1,73 @@ +import { memo, useMemo } from "react"; +import { Pressable, ScrollView, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; +import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; +import { extractTerminalBufferLinks, terminalLinkLabel } from "./terminalBufferLinks"; +import type { TerminalTheme } from "./terminalTheme"; + +interface TerminalLinkBarProps { + readonly buffer: string; + readonly theme?: Pick; +} + +const DEFAULT_THEME = { + border: "rgba(255, 255, 255, 0.1)", + foreground: "#e5e5e5", + mutedForeground: "#a3a3a3", +}; + +/** + * Tappable chips for URLs printed in the terminal. The Ghostty surface draws + * text on the GPU without link hit-testing, so this is how terminal URLs + * (e.g. the `claude` OAuth login URL) are opened on mobile. Tap opens the + * full reassembled URL; long-press copies it. + */ +export const TerminalLinkBar = memo(function TerminalLinkBar(props: TerminalLinkBarProps) { + const links = useMemo(() => extractTerminalBufferLinks(props.buffer), [props.buffer]); + const theme = props.theme ?? DEFAULT_THEME; + + if (links.length === 0) { + return null; + } + + return ( + + + {links.map((url) => ( + ({ + flexDirection: "row", + alignItems: "center", + gap: 6, + borderRadius: 999, + borderWidth: 1, + borderColor: theme.border, + paddingHorizontal: 12, + paddingVertical: 6, + opacity: pressed ? 0.6 : 1, + })} + onPress={() => void tryOpenExternalUrl(url, "terminal-link")} + onLongPress={() => copyTextWithHaptic(url, { target: "terminal link" })} + > + + + {terminalLinkLabel(url)} + + + ))} + + + ); +}); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx index 1c7adb6834d..1b368596c13 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalPanel.tsx @@ -8,6 +8,7 @@ import { terminalEnvironment } from "../../state/terminal"; import { useAtomCommand } from "../../state/use-atom-command"; import { useAttachedTerminalSession } from "../../state/use-terminal-session"; import { TerminalSurface } from "./NativeTerminalSurface"; +import { TerminalLinkBar } from "./TerminalLinkBar"; import { hasNativeTerminalSurface } from "./nativeTerminalModule"; import { buildThreadTerminalAttachInput, @@ -238,6 +239,7 @@ export const ThreadTerminalPanel = memo(function ThreadTerminalPanel( + ) : ( <> + { + it("finds a plain URL on its own line", () => { + const buffer = + "Browser didn't open? Use the url below to sign in:\n\nhttps://example.com/auth\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth"]); + }); + + it("reassembles a URL hard-wrapped to the terminal width", () => { + const width = 48; + const buffer = `Use the url below to sign in:\n\n${wrapToWidth(LOGIN_URL, width)}\n\nPaste code here if prompted:\n`; + expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); + }); + + it("does not glue following prose onto a URL that merely ends a short line", () => { + const buffer = "Docs: https://example.com/docs\nthanks for reading\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/docs"]); + }); + + it("ignores ANSI styling wrapped around and inside the URL", () => { + const buffer = + "\x1b[2J\x1b[H\x1b[1;34mSign in:\x1b[0m \x1b[4mhttps://example.com/\x1b[36mauth?state=abc\x1b[0m\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/auth?state=abc"]); + }); + + it("reassembles a wrapped URL whose rows are painted with cursor-positioning sequences", () => { + const width = 40; + const rows = wrapToWidth(LOGIN_URL, width).split("\n"); + const padded = `Open the link below to continue login now`; + const buffer = rows + .map((row, index) => `\x1b[${index + 3};1H\x1b[K${row}`) + .join("\r\n") + .concat(`\r\n${padded}\r\n`); + expect(extractTerminalBufferLinks(buffer)).toEqual([LOGIN_URL]); + }); + + it("extracts OSC 8 hyperlink targets exactly", () => { + const buffer = + "See \x1b]8;;https://example.com/very/long/target\x1b\\the docs\x1b]8;;\x1b\\ page\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://example.com/very/long/target"]); + }); + + it("trims trailing punctuation and unbalanced closers", () => { + const buffer = "Read https://example.com/a). Then https://example.com/b,\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual([ + "https://example.com/b", + "https://example.com/a", + ]); + }); + + it("drops spinner-truncated prefixes of the same URL, keeping most recent first", () => { + const buffer = + "https://example.com/oauth?state=ab\rhttps://example.com/oauth?state=abcdef\nDone. https://example.com/next\n"; + expect(extractTerminalBufferLinks(buffer)).toEqual([ + "https://example.com/next", + "https://example.com/oauth?state=abcdef", + ]); + }); + + it("caps results and prefers the most recent URLs", () => { + const buffer = Array.from({ length: 6 }, (_, index) => `https://example.com/${index}`).join( + "\n", + ); + const links = extractTerminalBufferLinks(buffer); + expect(links).toHaveLength(TERMINAL_LINK_MAX_RESULTS); + expect(links[0]).toBe("https://example.com/5"); + }); + + it("ignores a URL cut in half by the scan window", () => { + const filler = `${"x".repeat(200)}\n`; + const buffer = `https://truncated.example.com/${"a".repeat(TERMINAL_LINK_SCAN_WINDOW_BYTES)}\n${filler.repeat(4)}https://kept.example.com/ok\n`; + expect(extractTerminalBufferLinks(buffer)).toEqual(["https://kept.example.com/ok"]); + }); + + it("returns nothing for an empty or URL-free buffer", () => { + expect(extractTerminalBufferLinks("")).toEqual([]); + expect(extractTerminalBufferLinks("$ ls\nsrc package.json\n")).toEqual([]); + }); +}); + +describe("terminalLinkLabel", () => { + it("shows only the host for bare origins", () => { + expect(terminalLinkLabel("https://claude.ai")).toBe("claude.ai"); + expect(terminalLinkLabel("https://claude.ai/")).toBe("claude.ai"); + }); + + it("marks URLs that carry a path or query", () => { + expect(terminalLinkLabel(LOGIN_URL)).toBe("claude.ai/…"); + }); +}); diff --git a/apps/mobile/src/features/terminal/terminalBufferLinks.ts b/apps/mobile/src/features/terminal/terminalBufferLinks.ts new file mode 100644 index 00000000000..45cd2ab99c6 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalBufferLinks.ts @@ -0,0 +1,167 @@ +/** + * Extracts http(s) URLs from the raw PTY byte stream so the terminal panel can + * offer them as tappable links. The native Ghostty surface renders text on the + * GPU with no tap-to-open support, so links are surfaced in a chip bar instead. + * + * Working on the logical stream (rather than the rendered grid) also lets us + * recover URLs that TUIs hard-wrap to the terminal width — critical on phones + * where OAuth login URLs span many rows. + */ + +/** Only the tail of the buffer is scanned; login/auth URLs are always recent. */ +export const TERMINAL_LINK_SCAN_WINDOW_BYTES = 16_384; + +export const TERMINAL_LINK_MAX_RESULTS = 3; + +/* eslint-disable no-control-regex -- ANSI escape parsing matches ESC/BEL bytes by design */ +const OSC_HYPERLINK_PATTERN = /\x1b\]8;[^;\x07\x1b]*;([^\x07\x1b]*)(?:\x07|\x1b\\)/g; +const OSC_PATTERN = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g; +const CSI_PATTERN = /\x1b\[[0-9;:?]*[ -/]*[@-~]/g; +const ESC_CHARSET_PATTERN = /\x1b[()*+][0-9A-Za-z]/g; +const ESC_SINGLE_PATTERN = /\x1b[@-Z\\-_]/g; +// Everything below 0x20 except \n, plus DEL. \r and \r\n are normalized to \n +// beforehand. +const CONTROL_CHAR_PATTERN = /[\x00-\x09\x0b-\x1f\x7f]/g; +/* eslint-enable no-control-regex */ + +const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; +const URL_CONTINUATION_PATTERN = /^[^\s"'`<>]+/; +const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; + +/** Mirrors the web terminal's link cleanup (apps/web/src/terminal-links.ts). */ +function trimClosingDelimiters(value: string): string { + let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); + + const trimUnbalanced = (open: string, close: string) => { + while (output.endsWith(close)) { + const opens = output.split(open).length - 1; + const closes = output.split(close).length - 1; + if (opens >= closes) return; + output = output.slice(0, -1); + } + }; + + trimUnbalanced("(", ")"); + trimUnbalanced("[", "]"); + trimUnbalanced("{", "}"); + return output; +} + +function extractOscHyperlinks(raw: string): string[] { + const urls: string[] = []; + OSC_HYPERLINK_PATTERN.lastIndex = 0; + for (const match of raw.matchAll(OSC_HYPERLINK_PATTERN)) { + const uri = match[1]; + if (uri !== undefined && /^https?:\/\//.test(uri)) { + urls.push(uri); + } + } + return urls; +} + +function stripAnsi(raw: string): string { + return raw + .replace(OSC_PATTERN, "") + .replace(CSI_PATTERN, "") + .replace(ESC_CHARSET_PATTERN, "") + .replace(ESC_SINGLE_PATTERN, "") + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n") + .replace(CONTROL_CHAR_PATTERN, ""); +} + +/** + * TUIs (e.g. Ink) wrap output to the terminal width themselves, so a URL cut + * by wrapping fills its whole row and continues as a bare token on the next + * line. Rows narrower than this are treated as intentional line ends — phone + * terminals report ~45+ columns at the default font size, and requiring it + * avoids gluing following prose onto short URLs that merely end a line. + */ +const MIN_WRAP_JOIN_WIDTH = 40; + +function joinWrappedUrl( + lines: ReadonlyArray, + lineIndex: number, + matchText: string, + matchEnd: number, +): string { + let url = matchText; + let index = lineIndex; + let cutByWrap = matchEnd === lines[index]?.length; + + while (cutByWrap && (lines[index]?.length ?? 0) >= MIN_WRAP_JOIN_WIDTH) { + const nextLine = lines[index + 1]; + if (nextLine === undefined) break; + const continuation = URL_CONTINUATION_PATTERN.exec(nextLine)?.[0]; + // A wrap continuation is the line's only content; anything after a space + // is prose that happens to follow the URL. + if (continuation === undefined || continuation !== nextLine.trimEnd()) break; + + url += continuation; + index += 1; + cutByWrap = continuation.length === nextLine.length; + } + + return url; +} + +/** + * Returns unique http(s) URLs found near the end of the terminal buffer, most + * recent first. Wrapped URLs are reassembled and ANSI styling is ignored. + */ +export function extractTerminalBufferLinks(buffer: string): string[] { + if (buffer.length === 0) return []; + + let window = buffer.slice(-TERMINAL_LINK_SCAN_WINDOW_BYTES); + if (window.length < buffer.length) { + // Drop the possibly mid-sequence/mid-URL first line of the window. + const firstNewline = window.indexOf("\n"); + if (firstNewline >= 0) { + window = window.slice(firstNewline + 1); + } + } + + const oscUrls = extractOscHyperlinks(window); + const text = stripAnsi(window); + const lines = text.split("\n"); + + const ordered: string[] = [...oscUrls]; + for (const [lineIndex, line] of lines.entries()) { + URL_PATTERN.lastIndex = 0; + for (const match of line.matchAll(URL_PATTERN)) { + const start = match.index ?? -1; + if (start < 0) continue; + const joined = joinWrappedUrl(lines, lineIndex, match[0], start + match[0].length); + const trimmed = trimClosingDelimiters(joined); + if (trimmed.length > "https://".length) { + ordered.push(trimmed); + } + } + } + + // Keep the last occurrence of each URL, and drop URLs that are prefixes of a + // longer one (spinner \r-redraws leave truncated duplicates behind). + const deduped: string[] = []; + for (let index = ordered.length - 1; index >= 0; index -= 1) { + const url = ordered[index]; + if (url === undefined) continue; + if (deduped.some((existing) => existing.startsWith(url))) continue; + deduped.push(url); + } + + return deduped.slice(0, TERMINAL_LINK_MAX_RESULTS); +} + +/** Compact chip label: host plus a hint that a path/query follows. */ +export function terminalLinkLabel(url: string): string { + try { + const parsed = new URL(url); + const hasTail = + (parsed.pathname !== "/" && parsed.pathname !== "") || + parsed.search !== "" || + parsed.hash !== ""; + return hasTail ? `${parsed.host}/…` : parsed.host; + } catch { + return url.length > 40 ? `${url.slice(0, 39)}…` : url; + } +} diff --git a/apps/mobile/src/lib/openExternalUrl.ts b/apps/mobile/src/lib/openExternalUrl.ts index 10e6378bc00..9e778f19933 100644 --- a/apps/mobile/src/lib/openExternalUrl.ts +++ b/apps/mobile/src/lib/openExternalUrl.ts @@ -1,7 +1,12 @@ import * as Schema from "effect/Schema"; import { Linking } from "react-native"; -const ExternalUrlTarget = Schema.Literals(["file-preview", "markdown-link", "pull-request"]); +const ExternalUrlTarget = Schema.Literals([ + "file-preview", + "markdown-link", + "pull-request", + "terminal-link", +]); export type ExternalUrlTarget = typeof ExternalUrlTarget.Type; From a7c82dd62df75303fbb9be918c7a9b39e7ccb3e7 Mon Sep 17 00:00:00 2001 From: r4iju <5772718+r4iju@users.noreply.github.com> Date: Sun, 26 Jul 2026 10:38:19 +0900 Subject: [PATCH 6/6] feat(mobile): make message prose selectable on Android Android had no way to select or copy parts of a chat message: the native selectable-markdown view is iOS-only, and the react-native- nitro-markdown fallback rendered prose in plain Text without the selectable prop. Patch react-native-nitro-markdown (pnpm patch) to accept a selectable option, threaded through its context onto the root Text nodes (inline group wrapper, standalone text, headings) where Android honors it, and pass it at the three fallback Markdown call sites in ThreadFeed. Verified on a Pixel 8 Pro emulator: long-press selects a word with drag handles, extending and copying pastes exactly the selected fragment. --- .../src/features/threads/ThreadFeed.tsx | 3 + .../react-native-nitro-markdown@0.5.8.patch | 317 ++++++++++++++++++ pnpm-lock.yaml | 11 +- pnpm-workspace.yaml | 1 + 4 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 patches/react-native-nitro-markdown@0.5.8.patch diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 37a8639fdbd..5d46a008210 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -949,6 +949,7 @@ function renderFeedEntry( /> ) : ( { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -255,8 +256,9 @@ const Markdown = ({ + stylingStrategy, + onLinkPress, + tableOptions, +- highlightCode +- }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode]); ++ highlightCode, ++ selectable ++ }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode, selectable]); + const topLevelBlocks = parseResult.ast?.type === "document" ? parseResult.ast.children ?? [] : parseResult.ast ? [parseResult.ast] : []; + const shouldVirtualizeBySetting = virtualize === true || virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks; + const shouldVirtualize = parseResult.ast !== null && shouldVirtualizeBySetting; +@@ -317,7 +319,8 @@ const NodeRendererComponent = ({ + const { + renderers, + theme, +- styles: nodeStyles ++ styles: nodeStyles, ++ selectable + } = (0, _MarkdownContext.useMarkdownContext)(); + const baseStyles = getBaseStyles(theme); + const renderChildren = (children, childInListItem = false, childParentIsText = false) => { +@@ -345,7 +348,8 @@ const NodeRendererComponent = ({ + } else { + const Wrapper = childParentIsText ? _react.Fragment : _reactNative.Text; + const wrapperProps = childParentIsText ? {} : { +- style: baseStyles.text ++ style: baseStyles.text, ++ selectable + }; + elements.push(/*#__PURE__*/(0, _jsxRuntime.jsx)(Wrapper, { + ...wrapperProps, +@@ -443,6 +447,7 @@ const NodeRendererComponent = ({ + }); + } + return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { ++ selectable, + style: [baseStyles.text, nodeStyles?.text], + children: node.content + }); +diff --git a/lib/commonjs/renderers/heading.js b/lib/commonjs/renderers/heading.js +index 7e56d037a9620a78c77bc79894d578b1d81740be..f2b136d1e37e0e967322be44561ae2d13d016d39 100644 +--- a/lib/commonjs/renderers/heading.js ++++ b/lib/commonjs/renderers/heading.js +@@ -15,11 +15,13 @@ const Heading = ({ + style + }) => { + const { +- theme ++ theme, ++ selectable + } = (0, _MarkdownContext.useMarkdownContext)(); + const styles = (0, _styleCache.getCachedStyles)(stylesCache, theme, createStyles); + const headingStyles = [styles.heading, level === 1 && styles.h1, level === 2 && styles.h2, level === 3 && styles.h3, level === 4 && styles.h4, level === 5 && styles.h5, level === 6 && styles.h6]; + return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, { ++ selectable, + style: [...headingStyles, style], + children: children + }); +diff --git a/lib/module/markdown.js b/lib/module/markdown.js +index c97a209c1f8c01f8d6ba17e0f77ea174b82d1d7e..fc8798ff28984fc27733a8c9b955432881044c41 100644 +--- a/lib/module/markdown.js ++++ b/lib/module/markdown.js +@@ -184,7 +184,8 @@ export const Markdown = ({ + virtualizationMinBlocks = 40, + virtualization, + tableOptions, +- highlightCode ++ highlightCode, ++ selectable = false + }) => { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -251,8 +252,9 @@ export const Markdown = ({ + stylingStrategy, + onLinkPress, + tableOptions, +- highlightCode +- }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode]); ++ highlightCode, ++ selectable ++ }), [renderers, theme, nodeStyles, stylingStrategy, onLinkPress, tableOptions, highlightCode, selectable]); + const topLevelBlocks = parseResult.ast?.type === "document" ? parseResult.ast.children ?? [] : parseResult.ast ? [parseResult.ast] : []; + const shouldVirtualizeBySetting = virtualize === true || virtualize === "auto" && topLevelBlocks.length >= virtualizationMinBlocks; + const shouldVirtualize = parseResult.ast !== null && shouldVirtualizeBySetting; +@@ -312,7 +314,8 @@ const NodeRendererComponent = ({ + const { + renderers, + theme, +- styles: nodeStyles ++ styles: nodeStyles, ++ selectable + } = useMarkdownContext(); + const baseStyles = getBaseStyles(theme); + const renderChildren = (children, childInListItem = false, childParentIsText = false) => { +@@ -340,7 +343,8 @@ const NodeRendererComponent = ({ + } else { + const Wrapper = childParentIsText ? Fragment : Text; + const wrapperProps = childParentIsText ? {} : { +- style: baseStyles.text ++ style: baseStyles.text, ++ selectable + }; + elements.push(/*#__PURE__*/_jsx(Wrapper, { + ...wrapperProps, +@@ -438,6 +442,7 @@ const NodeRendererComponent = ({ + }); + } + return /*#__PURE__*/_jsx(Text, { ++ selectable, + style: [baseStyles.text, nodeStyles?.text], + children: node.content + }); +diff --git a/lib/module/renderers/heading.js b/lib/module/renderers/heading.js +index e8964caa5c663c196c0995f7a85177288854c10f..bcd10dba7b752a85548a8a75fb1d5db23e364a0c 100644 +--- a/lib/module/renderers/heading.js ++++ b/lib/module/renderers/heading.js +@@ -11,11 +11,13 @@ export const Heading = ({ + style + }) => { + const { +- theme ++ theme, ++ selectable + } = useMarkdownContext(); + const styles = getCachedStyles(stylesCache, theme, createStyles); + const headingStyles = [styles.heading, level === 1 && styles.h1, level === 2 && styles.h2, level === 3 && styles.h3, level === 4 && styles.h4, level === 5 && styles.h5, level === 6 && styles.h6]; + return /*#__PURE__*/_jsx(Text, { ++ selectable, + style: [...headingStyles, style], + children: children + }); +diff --git a/lib/typescript/commonjs/MarkdownContext.d.ts b/lib/typescript/commonjs/MarkdownContext.d.ts +index 79104e1b0f43f0d670a2a0669891dfa6d10bbd2a..9b9de6c1aea4fc96067e90491890e352e495e29d 100644 +--- a/lib/typescript/commonjs/MarkdownContext.d.ts ++++ b/lib/typescript/commonjs/MarkdownContext.d.ts +@@ -66,6 +66,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + export declare const MarkdownContext: import("react").Context; + export declare const useMarkdownContext: () => MarkdownContextValue; +diff --git a/lib/typescript/commonjs/markdown.d.ts b/lib/typescript/commonjs/markdown.d.ts +index 6d2a60212fbbce22ef94f49060186fec60e1b2a1..5153ed2e2c460a370e8fae20ac971f03ef437c1c 100644 +--- a/lib/typescript/commonjs/markdown.d.ts ++++ b/lib/typescript/commonjs/markdown.d.ts +@@ -147,6 +147,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + export declare const Markdown: FC; + //# sourceMappingURL=markdown.d.ts.map +\ No newline at end of file +diff --git a/lib/typescript/module/MarkdownContext.d.ts b/lib/typescript/module/MarkdownContext.d.ts +index 79104e1b0f43f0d670a2a0669891dfa6d10bbd2a..9b9de6c1aea4fc96067e90491890e352e495e29d 100644 +--- a/lib/typescript/module/MarkdownContext.d.ts ++++ b/lib/typescript/module/MarkdownContext.d.ts +@@ -66,6 +66,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + export declare const MarkdownContext: import("react").Context; + export declare const useMarkdownContext: () => MarkdownContextValue; +diff --git a/lib/typescript/module/markdown.d.ts b/lib/typescript/module/markdown.d.ts +index 6d2a60212fbbce22ef94f49060186fec60e1b2a1..5153ed2e2c460a370e8fae20ac971f03ef437c1c 100644 +--- a/lib/typescript/module/markdown.d.ts ++++ b/lib/typescript/module/markdown.d.ts +@@ -147,6 +147,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + export declare const Markdown: FC; + //# sourceMappingURL=markdown.d.ts.map +\ No newline at end of file +diff --git a/src/MarkdownContext.ts b/src/MarkdownContext.ts +index 2d93f0da14a8664724913d27e895e7f19f66a56c..6e72ce92da941185541ed7a773aa9de9c95f6191 100644 +--- a/src/MarkdownContext.ts ++++ b/src/MarkdownContext.ts +@@ -97,6 +97,7 @@ export type MarkdownContextValue = { + minColumnWidth?: number; + measurementStabilizeMs?: number; + }; ++ selectable?: boolean; + }; + + export const MarkdownContext = createContext({ +diff --git a/src/markdown.tsx b/src/markdown.tsx +index 50147a1c217f92353dc2f04b97a726d5289247ad..71205619e1b47d1f4a1f238fdcd8b6797bbadbea 100644 +--- a/src/markdown.tsx ++++ b/src/markdown.tsx +@@ -435,6 +435,13 @@ export type MarkdownProps = { + * Pass `true` to use the built-in tokenizer, or a custom highlighter function. + */ + highlightCode?: boolean | CodeHighlighter; ++ /** ++ * Makes prose text selectable so users can copy fragments. ++ * Applied to the root Text nodes (inline groups, standalone text, headings), ++ * which is where the platform honors the prop. ++ * @default false ++ */ ++ selectable?: boolean; + }; + + export const Markdown: FC = ({ +@@ -458,6 +465,7 @@ export const Markdown: FC = ({ + virtualization, + tableOptions, + highlightCode, ++ selectable = false, + }) => { + const parserOptionGfm = options?.gfm; + const parserOptionMath = options?.math; +@@ -564,6 +572,7 @@ export const Markdown: FC = ({ + onLinkPress, + tableOptions, + highlightCode, ++ selectable, + }), + [ + renderers, +@@ -573,6 +582,7 @@ export const Markdown: FC = ({ + onLinkPress, + tableOptions, + highlightCode, ++ selectable, + ], + ); + +@@ -658,7 +668,7 @@ const NodeRendererComponent: FC = ({ + inListItem, + parentIsText = false, + }) => { +- const { renderers, theme, styles: nodeStyles } = useMarkdownContext(); ++ const { renderers, theme, styles: nodeStyles, selectable } = useMarkdownContext(); + const baseStyles = getBaseStyles(theme); + + const renderChildren = ( +@@ -703,7 +713,7 @@ const NodeRendererComponent: FC = ({ + const Wrapper = childParentIsText ? Fragment : Text; + const wrapperProps = childParentIsText + ? {} +- : { style: baseStyles.text }; ++ : { style: baseStyles.text, selectable }; + + elements.push( + +@@ -814,7 +824,9 @@ const NodeRendererComponent: FC = ({ + return {node.content}; + } + return ( +- {node.content} ++ ++ {node.content} ++ + ); + + case "bold": +diff --git a/src/renderers/heading.tsx b/src/renderers/heading.tsx +index b388247dbd79554dc17f3eaab37616101589b95e..44785e8c4aef7079e64edddafdf1fa64187cf802 100644 +--- a/src/renderers/heading.tsx ++++ b/src/renderers/heading.tsx +@@ -21,7 +21,7 @@ const ANDROID_SYSTEM_FONTS = new Set([ + ]); + + export const Heading: FC = ({ level, children, style }) => { +- const { theme } = useMarkdownContext(); ++ const { theme, selectable } = useMarkdownContext(); + const styles = getCachedStyles(stylesCache, theme, createStyles); + + const headingStyles = [ +@@ -34,7 +34,11 @@ export const Heading: FC = ({ level, children, style }) => { + level === 6 && styles.h6, + ]; + +- return {children}; ++ return ( ++ ++ {children} ++ ++ ); + }; + + type HeadingStyles = ReturnType; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea636d3807..77f112d7824 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,7 @@ patchedDependencies: expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 + react-native-nitro-markdown@0.5.8: 9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 @@ -253,7 +254,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(daaf54394c837c77cd399b3041ea2723) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -385,7 +386,7 @@ importers: version: 1.21.13(patch_hash=20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008)(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14553,7 +14554,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(daaf54394c837c77cd399b3041ea2723)': dependencies: expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14561,7 +14562,7 @@ snapshots: expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-nitro-markdown: 0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -19703,7 +19704,7 @@ snapshots: react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(patch_hash=9dcb973ab644214b73d40b4bdb998b8c91bd21d8c8e418639a2d482bcaf11293)(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff840659efc..788f1efac1c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -117,6 +117,7 @@ patchedDependencies: expo-modules-jsi@56.0.10: patches/expo-modules-jsi@56.0.10.patch react-native-gesture-handler@2.31.2: patches/react-native-gesture-handler@2.31.2.patch react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch + react-native-nitro-markdown@0.5.8: patches/react-native-nitro-markdown@0.5.8.patch react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch react-native-screens@4.25.2: patches/react-native-screens@4.25.2.patch