Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
dd82cd8
fix(server): bound Bitbucket API requests with timeout
kvnloo Sep 29, 2026
6968c45
test(server): cover stalled Bitbucket requests
kvnloo Sep 29, 2026
778a450
style(server): normalize Bitbucket timeout test spacing
kvnloo Sep 29, 2026
5ce5a8f
fix(ssh): qualify runner script builders in tunnel test (#18078)
juliusmarminge Oct 11, 2026
6c13016
fix(web): keep thread links open until the shell is live (#14697)
saphid Oct 11, 2026
75fca7d
fix(web): scope PR title collapse to tab scrollers (#14644)
Adamulek123 Oct 11, 2026
6cf0526
fix(server): detect fork PRs for branches without upstreams (#13894)
Adamulek123 Oct 11, 2026
a908a7a
fix(search): find threads by their branch PR number (#14658)
tris203 Oct 11, 2026
b9afba5
feat(web): show Claude workflow phases and members in Lineage (#12598)
Bil0000 Oct 11, 2026
2e194f8
fix(mobile): round Android Agents sheet corners (#14925)
PixPMusic Oct 11, 2026
b4542c5
fix(web): the Usage page sends a signed-out browser to pairing (#17938)
AdEx-Partners-DE Oct 11, 2026
cb7d073
fix(server): OpenCode 2 continuations end after a reconnect took a St…
juliusmarminge Oct 11, 2026
ae5fd18
fix(server): OpenCode 2 continuations replay only their own backgroun…
juliusmarminge Oct 11, 2026
f0a47b3
fix(server): ACP reapplies a model after a switch away from it failed…
juliusmarminge Oct 11, 2026
f769e01
fix(web): focus settings search with command-f (#17859)
extoci Oct 11, 2026
5047ee7
fix(mobile): keep Android project paths on one line (#14178)
wellorbetter Oct 11, 2026
0cb3840
fix(web): unpin preview dragging on narrow chat canvases (#15556)
MatthewFeroz Oct 11, 2026
8f43ced
fix(web): thread details toggle no longer covers the thread title on …
freddy-d Oct 11, 2026
8dfee38
fix(server): a directory no longer resolves as the resource monitor b…
Furox-Art Oct 11, 2026
119e61b
fix(server): a Claude usage limit no longer resets the context meter …
Vantrongs Oct 11, 2026
bf725cb
fix(server): GitLab merge requests can expand unchanged lines (#17528)
ScottN-PV Oct 11, 2026
6fa0dd8
fix(provider-cursor): a skill tree deeper than the scan limit no long…
ScottN-PV Oct 11, 2026
5310a4a
fix(web): pass hex theme colors to HTML renders (#16325)
RustedAperture Oct 11, 2026
7808950
fix(web): align the API estimate info icon (#15655)
RakshithBhat03 Oct 11, 2026
cf82199
test(server): ACP teardown tests pass on Windows hosts (#17080)
sheehanmunim Oct 11, 2026
bb9b9aa
fix(server): sync device tool license versions with installer pins (#…
Yash-Singh1 Oct 11, 2026
f845895
fix: sync model favorites and visibility across clients (#16816)
jakeleventhal Oct 11, 2026
4b6d9a0
chore(deps-dev): bump compression from 1.8.1 to 1.8.2 in the npm_and_…
dependabot[bot] Oct 11, 2026
5584d21
fix(server): update fff to stop runaway watcher rescans (#14543)
realhasanshoaib Oct 11, 2026
2025430
fix(mobile): license generation works with filtered installs (#18127)
Yash-Singh1 Oct 11, 2026
8d199b4
fix(mobile): image-only messages ask the agent to respond, like deskt…
whoisaldo Oct 11, 2026
1f6df6b
feat(pull-requests): guided reviews, a beta tour of a pull request (#…
juliusmarminge Oct 11, 2026
18656a3
feat(mcp): agents can write and read pull request review guides (#18143)
juliusmarminge Oct 11, 2026
ed0f7b9
fix(server): Sol Ultrafast stops vanishing from the Codex model picke…
t3dotgg Oct 11, 2026
d51d3d6
fix(bitbucket): cover response body reads with request deadlines
kvnloo Oct 11, 2026
b847f4d
test(bitbucket): cover header-first body stalls on success and errors
kvnloo Oct 11, 2026
a7cb2e8
style(bitbucket): use catchTags for timeout errors
kvnloo Oct 11, 2026
2625327
fix(usage): say resets momentarily in the final minute (#17869)
extoci Oct 11, 2026
c09c235
fix(server): preserve ambient Cursor MCP servers (#15875)
aravhawk Oct 11, 2026
243c999
fix(server): skip Windows cleanup for exited child processes (#16007)
Alb11747 Oct 11, 2026
8ac6846
feat(settings): providers can be turned on or off per project (#17879)
yordis Oct 11, 2026
f23d008
perf(github): resolve credentials once per GraphQL request (#18289)
juliusmarminge Oct 11, 2026
4946602
fix(server): refresh linked PRs without a checkout (#17167)
PixPMusic Oct 11, 2026
328dc27
fix(mobile): add compose to the single-column iPad list (#18157)
PixPMusic Oct 11, 2026
4bd0ecc
fix(mobile): let sheet lists scroll before dismissal (#17177)
PixPMusic Oct 11, 2026
b4af602
fix(mobile): restore compact iPad trackpad scrolling (#13665)
PixPMusic Oct 11, 2026
b9bf609
fix(mobile): preserve filters across layout changes (#13085)
PixPMusic Oct 11, 2026
80cd575
fix(mobile): separate unfolded thread list from header (#13088)
PixPMusic Oct 11, 2026
a1f81a4
fix(android): Match thread fallback layouts to the loaded pane (#14669)
PixPMusic Oct 11, 2026
74f9df3
fix(mobile): size inline MCP apps to their content (#17040)
PixPMusic Oct 11, 2026
b147dc9
fix(mobile): preserve iPad search chrome across layout changes (#13667)
PixPMusic Oct 11, 2026
2e19207
fix(client): apply host color scheme to saved MCP apps (#17026)
PixPMusic Oct 11, 2026
8863990
fix(android): Use MD3 loading spinners (#14282)
PixPMusic Oct 11, 2026
8de61d4
fix(client): merge No project filters across environments (#17050)
PixPMusic Oct 11, 2026
ac2cc78
fix(client): tolerate blocked cookies in MCP app sandboxes (#17027)
PixPMusic Oct 11, 2026
5495cc4
feat(mobile): browse and review pull requests natively (#18126)
juliusmarminge Oct 11, 2026
bed9239
feat(mobile): guided reviews for pull requests (#18267)
juliusmarminge Oct 11, 2026
5cbe82a
fix(devices): refresh backlogged iOS video and require input admissio…
juliusmarminge Oct 11, 2026
d07a07f
feat(mobile): keep pull requests available offline (#18351)
juliusmarminge Oct 11, 2026
81dbc87
fix(mobile): keep bot badges out of PR attachments (#18347)
juliusmarminge Oct 11, 2026
cafe620
fix(desktop): tooltips no longer open under the Windows and Linux win…
flamboh Oct 11, 2026
1d270be
fix(web): revert trailing toast icons (#18356)
t3-code-bot[bot] Oct 11, 2026
f43f6dd
fix(web): even 4px inset around rows in the PR badge tooltip (#18352)
SunkenInTime Oct 11, 2026
c0c495e
ci(mobile): publish nightly Android APKs to a GitHub prerelease (#16271)
SunkenInTime Oct 11, 2026
3f70c83
fix(server): Claude 5.1+ text before a tool call shows as assistant t…
Gigioxx Oct 11, 2026
765b04f
fix(bitbucket): integrate full-response timeouts on current T3 main
kvnloo Oct 11, 2026
5adf42f
test(bitbucket): parameterize timeout cases and split typed requests
kvnloo Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
189 changes: 189 additions & 0 deletions .github/scripts/mirror-play-apk.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
// Mirrors the newest build on a Google Play track to a rolling GitHub
// prerelease as a universal APK. Play generates and signs that APK from the
// uploaded bundle with the app signing key, so it carries the same signature
// as the Play Store install. Run by .github/workflows/mobile-android-apk.yml.
const { execFileSync } = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { Readable } = require("node:stream");
const { pipeline } = require("node:stream/promises");

const PLAY_API = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";

function requireEnv(name) {
const value = process.env[name];
if (!value) throw new Error(`${name} is required.`);
return value;
}

// Service account key -> OAuth access token via a self-signed JWT grant.
async function playAccessToken(key) {
const now = Math.floor(Date.now() / 1000);
const encode = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
const unsigned = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
iss: key.client_email,
scope: "https://www.googleapis.com/auth/androidpublisher",
aud: key.token_uri,
iat: now,
exp: now + 600,
})}`;
const signature = crypto.sign("RSA-SHA256", Buffer.from(unsigned), key.private_key);
const response = await fetch(key.token_uri, {
method: "POST",
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: `${unsigned}.${signature.toString("base64url")}`,
}),
});
if (!response.ok) {
throw new Error(`Google token exchange failed: ${response.status} ${await response.text()}`);
}
return (await response.json()).access_token;
}

async function play(token, method, url) {
const response = await fetch(url, { method, headers: { authorization: `Bearer ${token}` } });
if (!response.ok) {
throw new Error(`${method} ${url} failed: ${response.status} ${await response.text()}`);
}
return response;
}

// Highest version code the track serves, with its release name. Play names a
// release after the bundle's versionName unless the uploader sets one. This
// endpoint reads without opening an edit; an edit would invalidate any open
// EAS Submit edit made with the same service account.
async function newestTrackBuild(token, appUrl, trackName) {
const { releases = [] } = await (
await play(token, "GET", `${appUrl}/tracks/${trackName}/releases`)
).json();
let newest;
for (const release of releases) {
if (release.releaseLifecycleState !== "RELEASE_LIFECYCLE_STATE_PUBLISHED") continue;
for (const { versionCode } of release.activeArtifacts ?? []) {
if (!newest || versionCode > newest.versionCode) {
newest = { versionCode, versionName: release.releaseName || String(versionCode) };
}
}
}
return newest;
}

function gh(...args) {
return execFileSync("gh", args, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
}

function readRelease(tag) {
try {
return JSON.parse(gh("release", "view", tag, "--json", "assets,body"));
} catch {
return undefined;
}
}

// Downloads the build's universal APK and uploads it, creating the release on
// first use. Returns false when Play lists no universal APK, which happens
// while it is still processing a fresh upload.
async function uploadApk({ token, appUrl, build, tag, apkName, notes, release }) {
const generated = await (
await play(token, "GET", `${appUrl}/generatedApks/${build.versionCode}`)
).json();
// One entry per app signing key. More than one means a key rotation, so
// fail rather than guess which signature testers should get.
const universal = (generated.generatedApks ?? []).filter((entry) => entry.generatedUniversalApk);
if (universal.length === 0) return false;
if (universal.length > 1) {
throw new Error(`Play returned ${universal.length} universal APKs, one per signing key.`);
}
const { downloadId } = universal[0].generatedUniversalApk;

const apkPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "apk-")), apkName);
const download = await play(
token,
"GET",
`${appUrl}/generatedApks/${build.versionCode}/downloads/${encodeURIComponent(downloadId)}:download?alt=media`,
);
await pipeline(Readable.fromWeb(download.body), fs.createWriteStream(apkPath));

if (!release) {
// Prerelease with a non-v tag: release.yml, the desktop updater, the CLI,
// and the marketing site's latest-release lookup all ignore it.
gh(
"release",
"create",
tag,
"--prerelease",
"--latest=false",
"--title",
"T3 Code for Android (nightly)",
"--notes",
notes,
);
}
// --clobber replaces an asset a failed upload left behind.
gh("release", "upload", tag, apkPath, "--clobber");
return true;
}

async function main() {
const packageName = requireEnv("PACKAGE_NAME");
const trackName = requireEnv("PLAY_TRACK");
const tag = requireEnv("RELEASE_TAG");
const key = JSON.parse(requireEnv("GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"));
const summary = (line) =>
process.env.GITHUB_STEP_SUMMARY &&
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`);

const token = await playAccessToken(key);
const appUrl = `${PLAY_API}/${packageName}`;
const build = await newestTrackBuild(token, appUrl, trackName);
if (!build) {
console.log(`The ${trackName} track has no live release.`);
return;
}

const safeName = build.versionName.replace(/[^0-9A-Za-z.]+/g, "-").replace(/^-|-$/g, "");
const apkName = `t3code-android-${safeName}-${build.versionCode}.apk`;
const notes = [
`T3 Code for Android that works with nightly servers, mirrored from the Google Play ${trackName} track. Current build: ${build.versionName} (${build.versionCode}).`,
"",
"The Play Store release can't connect to a nightly server yet. Install this APK if you can't join the Play testing group. Only the newest build is kept here.",
].join("\n");

const release = readRelease(tag);
const assets = release?.assets ?? [];
// A failed upload can leave an empty asset under the final name.
const published = assets.some(
(asset) => asset.name === apkName && asset.state === "uploaded" && asset.size > 0,
);
if (published) {
console.log(`${apkName} is already published.`);
} else if (await uploadApk({ token, appUrl, build, tag, apkName, notes, release })) {
console.log(`Published ${apkName}.`);
summary(`:white_check_mark: Published \`${apkName}\` to the \`${tag}\` release.`);
} else {
// The next run retries.
console.log(`Play lists no universal APK for ${build.versionCode}.`);
summary(
`:hourglass: Play lists no universal APK for ${build.versionName} (${build.versionCode}).`,
);
return;
}

// Reconciled on every run so an interrupted cleanup finishes on the next one.
for (const stale of assets) {
if (stale.name.endsWith(".apk") && stale.name !== apkName) {
gh("release", "delete-asset", tag, stale.name, "--yes");
}
}
if (release && release.body.replace(/\r\n/g, "\n").trim() !== notes) {
gh("release", "edit", tag, "--notes", notes);
}
}

main().catch((error) => {
console.error(error);
process.exit(1);
});
59 changes: 59 additions & 0 deletions .github/workflows/mobile-android-apk.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Mobile Android APK

# Publishes the newest nightly-compatible Android build as a sideloadable APK
# on the rolling "android-nightly" prerelease, for nightly users who can't join
# the Play testing group. Play re-signs uploaded bundles with the app signing
# key, so its universal APK matches the Play Store install and needs no signing
# secrets here. EAS submits asynchronously (--no-wait), so this polls Play
# instead of chaining off mobile-eas-production.yml; runs with nothing new exit
# early.
on:
schedule:
# Avoid minute zero, when GitHub scheduled jobs are busiest.
- cron: "23 * * * *"
workflow_dispatch:

concurrency:
group: mobile-android-apk
cancel-in-progress: false

jobs:
mirror:
name: Mirror Play APK
runs-on: blacksmith-2vcpu-ubuntu-2404
timeout-minutes: 10
permissions:
contents: write
env:
PACKAGE_NAME: com.t3tools.t3code
# eas.json submits v2-preview builds to this closed testing track; they
# speak the orchestration protocol nightly servers use, which the Play
# Store release can't.
PLAY_TRACK: v2
RELEASE_TAG: android-nightly
steps:
- id: credentials
name: Check for Play credentials
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -n "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "GOOGLE_PLAY_SERVICE_ACCOUNT_JSON is not available; skipping APK mirror."
fi
- name: Checkout
if: steps.credentials.outputs.present == 'true'
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts

- name: Mirror the newest APK
if: steps.credentials.outputs.present == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: node .github/scripts/mirror-play-apk.cjs
2 changes: 2 additions & 0 deletions .github/workflows/mobile-eas-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ jobs:
uses: expo/expo-github-action/continuous-deploy-fingerprint@main
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
# The Expo action otherwise suppresses the EAS command's failure output.
RUNNER_DEBUG: "1"
with:
profile: preview:dev
branch: pr-${{ github.event.pull_request.number }}
Expand Down
Binary file added apps/mobile/assets/icons/pull-request.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/pull-request@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/mobile/assets/icons/pull-request@3x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
28 changes: 25 additions & 3 deletions apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SymbolView } from "expo-symbols";
import { githubAlert } from "./githubAlert";
import { createContext, memo, useContext, useMemo } from "react";
import { Image, Platform, ScrollView, Text, useColorScheme, View } from "react-native";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
Expand Down Expand Up @@ -502,6 +504,7 @@ export function NativeMarkdownBlock(props: {
readonly compact?: boolean;
}) {
const depth = props.depth ?? 0;
const dark = useColorScheme() === "dark";
switch (props.node.type) {
case "document":
return (
Expand Down Expand Up @@ -555,19 +558,37 @@ export function NativeMarkdownBlock(props: {
}}
/>
);
case "blockquote":
case "blockquote": {
const alert = githubAlert(props.node);
const alertColor = dark ? alert?.color : alert?.lightColor;
return (
<View
style={{
borderLeftColor: props.textStyle.quoteMarkerColor,
borderLeftColor: alertColor ?? props.textStyle.quoteMarkerColor,
borderLeftWidth: 2,
marginVertical: props.compact ? 4 : 0,
paddingLeft: 11,
paddingVertical: 2,
gap: 6,
}}
>
{(props.node.children ?? []).map((child, index) => (
{alert ? (
<View style={{ flexDirection: "row", alignItems: "center", gap: 7 }}>
{Platform.OS === "ios" ? (
<SymbolView name={alert.symbol} size={17} tintColor={alertColor} />
) : null}
<Text
style={{
color: alertColor,
fontFamily: props.textStyle.boldFontFamily,
fontSize: props.textStyle.fontSize,
}}
>
{alert.title}
</Text>
</View>
) : null}
{(alert?.children ?? props.node.children ?? []).map((child, index) => (
<NativeMarkdownBlock
key={nodeKey(child, index)}
node={child}
Expand All @@ -581,6 +602,7 @@ export function NativeMarkdownBlock(props: {
))}
</View>
);
}
case "list":
return (
<NativeList
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { View } from "react-native";
import { parseMarkdownWithOptions } from "react-native-nitro-markdown/headless";

import {
nativeMarkdownWithHtml,
nativeMarkdownChunkSpacing,
nativeMarkdownDocumentChunks,
nativeMarkdownDocumentRuns,
Expand Down Expand Up @@ -53,7 +54,10 @@ export function SelectableMarkdownText({
}: SelectableMarkdownTextProps) {
const chunks = useMemo(() => {
const parsedDocument = nativeMarkdownWithAuthoredWindowsPaths(
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
nativeMarkdownWithHtml(
parseMarkdownWithOptions(markdown, { gfm: true, html: true, math: false }),
(content) => parseMarkdownWithOptions(content, { gfm: true, html: false, math: false }),
),
markdown,
);
const document = preserveSoftBreaks
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/githubAlert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vite-plus/test";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";
import { githubAlert } from "./githubAlert";

const quote = (...children: MarkdownNode[]): MarkdownNode => ({
type: "blockquote",
children: [{ type: "paragraph", children }],
});

describe("GitHub alerts in native Markdown", () => {
it.each(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"])(
"recognizes %s and retains formatted content",
(marker) => {
const bold: MarkdownNode = { type: "bold", children: [{ type: "text", content: "Details" }] };
const node = quote({ type: "text", content: `[!${marker}]` }, { type: "soft_break" }, bold);
const original = structuredClone(node);
const alert = githubAlert(node);
expect(alert?.title.toUpperCase()).toBe(marker);
expect(alert?.children[0]?.children).toEqual([bold]);
expect(node).toEqual(original);
},
);
it("handles text nodes containing the line break and following text", () => {
expect(
githubAlert(quote({ type: "text", content: "[!WARNING]\nKeep this text." }))?.children[0]
?.children?.[0]?.content,
).toBe("Keep this text.");
});
it("leaves inline markers, code, and ordinary quotes alone", () => {
expect(githubAlert(quote({ type: "text", content: "[!WARNING] inline" }))).toBeNull();
expect(
githubAlert(
quote(
{ type: "text", content: "[!WARNING]" },
{ type: "bold", children: [{ type: "text", content: "inline" }] },
),
),
).toBeNull();
expect(githubAlert({ type: "code_block", content: "> [!WARNING]" })).toBeNull();
expect(githubAlert(quote({ type: "text", content: "Just a quote" }))).toBeNull();
});
});
Loading
Loading