Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
bd8a1cf
[muse] fix(web): write localStorage values even when the stored blob …
Sep 28, 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
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
6be1155
chore(web): reconcile localStorage fix with current main
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
  •  
  •  
  •  
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();
});
});
52 changes: 52 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/githubAlert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { MarkdownNode } from "react-native-nitro-markdown/headless";

const ALERTS = {
NOTE: { title: "Note", color: "#4493f8", lightColor: "#0969da", symbol: "info.circle" },
TIP: { title: "Tip", color: "#3fb950", lightColor: "#1a7f37", symbol: "lightbulb" },
IMPORTANT: {
title: "Important",
color: "#ab7df8",
lightColor: "#8250df",
symbol: "exclamationmark.bubble",
},
WARNING: {
title: "Warning",
color: "#d29922",
lightColor: "#9a6700",
symbol: "exclamationmark.triangle",
},
CAUTION: {
title: "Caution",
color: "#f85149",
lightColor: "#cf222e",
symbol: "exclamationmark.octagon",
},
} as const;

/** GitHub alerts are blockquotes whose first line contains only the alert marker. */
export function githubAlert(node: MarkdownNode) {
if (node.type !== "blockquote") return null;
const paragraph = node.children?.[0];
const text = paragraph?.children?.[0];
if (paragraph?.type !== "paragraph" || text?.type !== "text") return null;
const match = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\](\r?\n|$)/i.exec(text.content ?? "");
if (!match) return null;
const rest = (text.content ?? "").slice(match[0].length);
const siblings = paragraph.children?.slice(1) ?? [];
if (
!rest &&
!match[2] &&
siblings.length &&
!["soft_break", "line_break"].includes(siblings[0]!.type)
)
return null;
if (!rest && ["soft_break", "line_break"].includes(siblings[0]?.type ?? "")) siblings.shift();
const children = [...(rest ? [{ ...text, content: rest }] : []), ...siblings];
return {
...ALERTS[match[1]!.toUpperCase() as keyof typeof ALERTS],
children: [
...(children.length ? [{ ...paragraph, children }] : []),
...(node.children?.slice(1) ?? []),
],
};
}
98 changes: 98 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1059,3 +1059,101 @@ export function nativeMarkdownDocumentRuns(
}
return decorateMentionRuns(decorateSkillRuns(runs, skills));
}

const HTML_TAG = /<!--[\s\S]*?-->|<\/?[a-z](?:[^>"']|"[^"]*"|'[^']*')*>/gi;

function attribute(tag: string, name: string): string | null {
const attributes = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g;
for (const match of tag.matchAll(attributes)) {
if (match[1]?.toLowerCase() === name) {
return decodeHtmlEntities(match[2] ?? match[3] ?? match[4] ?? "");
}
}
return null;
}

/** Called only for parsed HTML nodes, so Markdown code examples keep their literal tags. */
export function markdownHtmlContent(content: string): string {
// An HTML block such as <details> can also contain Markdown fences and code spans.
const codeRanges: Array<{ start: number; end: number }> = [];
const delimiters = [...content.matchAll(/`+|~{3,}/g)];
for (let index = 0; index < delimiters.length; index += 1) {
const opening = delimiters[index]!;
const lineStart = content.lastIndexOf("\n", opening.index - 1) + 1;
const fenced =
opening[0].length >= 3 && /^ {0,3}$/.test(content.slice(lineStart, opening.index));
if (opening[0][0] === "~" && !fenced) continue;
let closingIndex = -1;
for (let candidate = index + 1; candidate < delimiters.length; candidate += 1) {
const closing = delimiters[candidate]!;
if (!fenced && closing[0] === opening[0]) {
closingIndex = candidate;
break;
}
if (!fenced || closing[0][0] !== opening[0][0] || closing[0].length < opening[0].length)
continue;
const start = content.lastIndexOf("\n", closing.index - 1) + 1;
const nextLine = content.indexOf("\n", closing.index);
const end = nextLine === -1 ? content.length : nextLine;
if (
/^ {0,3}$/.test(content.slice(start, closing.index)) &&
/^\s*$/.test(content.slice(closing.index + closing[0].length, end))
) {
closingIndex = candidate;
break;
}
}
if (closingIndex === -1) {
if (fenced) codeRanges.push({ start: opening.index, end: content.length });
continue;
}
const closing = delimiters[closingIndex]!;
codeRanges.push({ start: opening.index, end: closing.index + closing[0].length });
index = closingIndex;
}
return content.replace(HTML_TAG, (tag, offset: number) => {
if (codeRanges.some((range) => offset >= range.start && offset < range.end)) return tag;
if (tag.startsWith("<!--")) return "";
if (/^<img\b/i.test(tag)) {
const src = attribute(tag, "src");
if (!src) return "";
const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]]/g, "\\$&");
const href = src.replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\n/g, "");
return `![${alt}](<${href}>)`;
}
if (/^<summary\b/i.test(tag)) return "\n\n**";
if (/^<\/summary\s*>/i.test(tag)) return "**\n\n";
if (/^<br\b/i.test(tag)) return " \n";
if (/^<\/?(?:p|div|details)\b/i.test(tag)) return "\n\n";
return "";
});
}

export function nativeMarkdownWithHtml(
document: MarkdownNode,
parse: (content: string) => MarkdownNode,
): MarkdownNode {
const withoutOffsets = (node: MarkdownNode): MarkdownNode => {
const { beg: _beg, end: _end, children, ...rest } = node;
return { ...rest, ...(children ? { children: children.map(withoutOffsets) } : {}) };
};
const transform = (node: MarkdownNode): MarkdownNode[] => {
if (node.type === "html_block" || node.type === "html_inline") {
const content = markdownHtmlContent(node.content ?? "");
if (!content.trim())
return /^<br\b/i.test(node.content ?? "") ? [{ type: "line_break" }] : [];
// Fragment offsets restart at zero, so they cannot identify chunks in the parent document.
const children = (parse(content).children ?? []).map(withoutOffsets);
return node.type === "html_inline"
? children.flatMap((child) =>
child.type === "paragraph" ? (child.children ?? []) : [child],
)
: children;
}
if (!node.children) return [node];
const children = node.children.flatMap(transform);
if (node.type === "paragraph" && children.length === 0) return [];
return [{ ...node, children }];
};
return { ...document, children: (document.children ?? []).flatMap(transform) };
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ public final class T3NativeControlsModule: Module {
identifier: identifier, promise: promise)
}.runOnQueue(.main)

AsyncFunction("presentFiles") { (urls: [URL], titles: [String], selectedIndex: Int, identifier: String, promise: Promise) in
guard !urls.isEmpty, urls.count == titles.count, urls.indices.contains(selectedIndex) else {
throw URLError(.badURL)
}
let file = try self.makeFilePresentation(sourceIdentifier: "", identifier: identifier, promise: promise)
guard let presenter = self.appContext?.utilities?.currentViewController() else {
file.dismiss()
return
}
file.present(urls: urls, titles: titles, selectedIndex: selectedIndex, from: presenter)
}.runOnQueue(.main)

OnDestroy {
let presentation = self.videoPresentation
let file = self.filePresentation
Expand Down Expand Up @@ -204,17 +216,24 @@ public final class T3NativeControlsModule: Module {
if videoPresentation?.identifier == identifier { videoPresentation?.dismiss() }
}

private func presentFile(url: URL, title: String, sourceIdentifier: String,
identifier: String, promise: Promise) throws {
private func makeFilePresentation(sourceIdentifier: String, identifier: String,
promise: Promise) throws -> T3NativeFilePresentation {
guard filePresentation == nil, videoPresentation == nil,
let presenter = appContext?.utilities?.currentViewController()
appContext?.utilities?.currentViewController() != nil
else { throw URLError(.cannotLoadFromNetwork) }
let file = T3NativeFilePresentation(identifier: identifier, sources: presentationSources,
sourceIdentifier: sourceIdentifier) { [weak self] error in
self?.filePresentation = nil
if let error { promise.reject(error) } else { promise.resolve(nil) }
}
filePresentation = file
return file
}

private func presentFile(url: URL, title: String, sourceIdentifier: String,
identifier: String, promise: Promise) throws {
let file = try makeFilePresentation(sourceIdentifier: sourceIdentifier, identifier: identifier, promise: promise)
guard let presenter = appContext?.utilities?.currentViewController() else { file.dismiss(); return }
file.present(url: url, title: title, from: presenter)
}

Expand Down
Loading
Loading