diff --git a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
index 3caa8c6961..432c31f82d 100644
--- a/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
+++ b/apps/mobile/src/app/(app)/agent-chat/repo-picker.tsx
@@ -2,9 +2,8 @@ import { useFocusEffect, useRouter } from 'expo-router';
import * as Haptics from 'expo-haptics';
import { Check, Info, Lock, Search, SearchX, Unlock } from '@/components/ui/icons';
import { useCallback, useMemo, useRef, useState } from 'react';
-import { FlatList, Pressable, TextInput, View } from 'react-native';
+import { Pressable, TextInput, View } from 'react-native';
import { useTranslation } from 'react-i18next';
-import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { EmptyState } from '@/components/empty-state';
import { PickerSheet } from '@/components/picker-sheet';
@@ -21,7 +20,6 @@ type PickerListItem =
export default function RepoPickerScreen() {
const router = useRouter();
const colors = useThemeColors();
- const { bottom } = useSafeAreaInsets();
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [bridge, setBridge] = useState(() => repoPickerSlot.get(UNFENCED_ROUTE_KEY));
@@ -102,7 +100,6 @@ export default function RepoPickerScreen() {
@@ -136,17 +133,18 @@ export default function RepoPickerScreen() {
}
/>
) : (
- item.key}
- keyboardShouldPersistTaps="handled"
- keyboardDismissMode="on-drag"
- contentContainerStyle={{ paddingBottom: bottom }}
- renderItem={({ item }) => {
+ // Mapped rows inside the shell ScrollView instead of a FlatList: the
+ // FlatList stretches into the space the formSheet offers and its rows
+ // painted over the pinned search header while scrolling. The shell
+ // scroll view starts below the header, so a row can never overlap it.
+
+ {listItems.map(item => {
if (item.kind === 'header') {
return (
-
+
{t(item.titleKey)}
);
@@ -156,6 +154,7 @@ export default function RepoPickerScreen() {
const rowLabel = `${platformName} ${repo.fullName}`;
return (
{
handleSelect(`${repo.platform}:${repo.fullName}`);
@@ -182,9 +181,33 @@ export default function RepoPickerScreen() {
) : null}
);
- }}
- />
+ })}
+ {renderBitbucketNote()}
+
)}
);
+
+ /**
+ * Personal Bitbucket never lists repositories (organization-only), so the
+ * grouped list would end at GitLab with nothing explaining the gap. The
+ * note renders once, after the provider sections, whenever the picker has
+ * rows but no Bitbucket section; a connected org's rows suppress it.
+ */
+ function renderBitbucketNote() {
+ if (search.trim() || !bridge) {
+ return null;
+ }
+ if (bridge.sections.some(section => section.key === 'bitbucket')) {
+ return null;
+ }
+ return (
+
+
+ {t('agentChat.repoPicker.platformBitbucket')}
+
+ {t('agentChat.newSession.bitbucketOrganizationsOnly')}
+
+ );
+ }
}
diff --git a/apps/mobile/src/components/agents/new-session-configure-form.tsx b/apps/mobile/src/components/agents/new-session-configure-form.tsx
index 6ebae1b378..33d9762546 100644
--- a/apps/mobile/src/components/agents/new-session-configure-form.tsx
+++ b/apps/mobile/src/components/agents/new-session-configure-form.tsx
@@ -217,6 +217,7 @@ export function NewSessionConfigureForm({
contentContainerClassName="flex-grow px-4 pb-8 pt-4"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
+ keyboardDismissMode="on-drag"
>
>, type: string) {
return renderer.root.findAll(node => node.type === type);
}
+// The model picker hosts its rows in a FlatList and manages its own scrolling
+// (PickerSheet scrollable=false); the repository picker renders mapped rows
+// inside the shell ScrollView and so always keeps that ScrollView mounted.
describe.each([
- { name: 'model', Component: ModelPickerContent },
- { name: 'repository', Component: RepoPickerScreen },
-])('$name picker centering', ({ Component }) => {
+ { name: 'model', Component: ModelPickerContent, rowHost: 'FlatList', hasShellScrollView: false },
+ {
+ name: 'repository',
+ Component: RepoPickerScreen,
+ rowHost: 'Pressable',
+ hasShellScrollView: true,
+ },
+])('$name picker centering', ({ Component, rowHost, hasShellScrollView }) => {
it('keeps the search input and native header mounted when replacing the list', async () => {
const renderer = await mount(Component);
const input = hosts(renderer, 'TextInput')[0];
@@ -102,15 +110,17 @@ describe.each([
const group = header.parent;
expect(group?.props.collapsable).toBe(false);
expect(group?.findAll(node => node === input)).toHaveLength(1);
- expect(hosts(renderer, 'FlatList')).toHaveLength(1);
+ expect(hosts(renderer, rowHost).length).toBeGreaterThan(0);
expect(hosts(renderer, 'CenteredState')).toHaveLength(0);
const changeSearch = input.props.onChangeText as (text: string) => void;
act(() => {
changeSearch('no matching choice');
});
- expect(hosts(renderer, 'FlatList')).toHaveLength(0);
- expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
+ expect(hosts(renderer, rowHost)).toHaveLength(0);
+ if (!hasShellScrollView) {
+ expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
+ }
expect(hosts(renderer, 'CenteredState')).toHaveLength(1);
expect(hosts(renderer, 'TextInput')[0]).toBe(input);
expect(hosts(renderer, 'SheetHeader')[0]).toBe(header);
@@ -119,7 +129,7 @@ describe.each([
act(() => {
changeSearch('');
});
- expect(hosts(renderer, 'FlatList')).toHaveLength(1);
+ expect(hosts(renderer, rowHost).length).toBeGreaterThan(0);
expect(hosts(renderer, 'CenteredState')).toHaveLength(0);
expect(hosts(renderer, 'TextInput')[0]).toBe(input);
expect(header.parent).toBe(group);
@@ -136,7 +146,9 @@ describe.each([
const renderer = await mount(Component);
expect(hosts(renderer, 'CenteredState')).toHaveLength(1);
expect(hosts(renderer, 'FlatList')).toHaveLength(0);
- expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
+ if (!hasShellScrollView) {
+ expect(hosts(renderer, 'ScrollView')).toHaveLength(0);
+ }
expect(hosts(renderer, 'TextInput')).toHaveLength(1);
});
});
diff --git a/apps/mobile/src/components/agents/repository-branch-selector.tsx b/apps/mobile/src/components/agents/repository-branch-selector.tsx
index 6bed318e26..f28cfa5843 100644
--- a/apps/mobile/src/components/agents/repository-branch-selector.tsx
+++ b/apps/mobile/src/components/agents/repository-branch-selector.tsx
@@ -1,5 +1,5 @@
import { useState, useSyncExternalStore } from 'react';
-import { FlatList, Modal, Pressable, View } from 'react-native';
+import { Modal, Pressable, ScrollView, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { Check, ChevronDown } from '@/components/ui/icons';
@@ -198,12 +198,13 @@ export function RepositoryBranchSelector({
{t('agentChat.newSession.branchPickerTitle')}
- branch}
- renderItem={({ item }) => renderBranchRow(item, close)}
- showsVerticalScrollIndicator={false}
- />
+ {/* ScrollView, not FlatList: a FlatList stretches to the space
+ its container offers, so two branch rows rendered as a mostly
+ empty sheet. A ScrollView hugs its rows and only scrolls once
+ the card's max height is reached. */}
+
+ {branches.branches.map(branch => renderBranchRow(branch, close))}
+
{scrollable && !expired ? (
- {body}
+ // keyboardShouldPersistTaps keeps a first tap on a row working while
+ // a picker's search field holds the keyboard open.
+
+ {body}
+
) : (
body
)}
diff --git a/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx
index 6670790de5..d70dcfd487 100644
--- a/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx
+++ b/apps/mobile/src/components/pr-review/full-surface-states.mounted.test.tsx
@@ -24,16 +24,22 @@ vi.mock('@tanstack/react-query', async importOriginal => ({
...(await importOriginal()),
useQuery: () => query,
}));
-vi.mock('expo-router', () => ({
- useRouter: () => ({ back: vi.fn(), push: vi.fn() }),
- useLocalSearchParams: () => ({
+// Mutable so one suite can hand the composer route a malformed param set
+// without re-mocking `expo-router` per test.
+const routeParams = vi.hoisted(() => {
+ const current: Record = {
owner: 'org',
repo: 'repo',
number: '1',
path: 'src/a.ts',
line: '1',
side: 'RIGHT',
- }),
+ };
+ return { current };
+});
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ back: vi.fn(), push: vi.fn(), replace: vi.fn() }),
+ useLocalSearchParams: () => routeParams.current,
}));
vi.mock('react-native', () => ({
View: 'View',
@@ -116,6 +122,14 @@ beforeEach(() => {
query.isError = true;
query.isLoading = false;
query.error.data.code = 'INTERNAL_SERVER_ERROR';
+ routeParams.current = {
+ owner: 'org',
+ repo: 'repo',
+ number: '1',
+ path: 'src/a.ts',
+ line: '1',
+ side: 'RIGHT',
+ };
vi.clearAllMocks();
});
@@ -205,3 +219,22 @@ describe.each([
unmount();
});
});
+
+describe('composer malformed route', () => {
+ it('renders the terminal invalid state without the Add-comment chrome', async () => {
+ // A hand-built or restored composer link with no valid comment target
+ // failed at the ROUTE, not at the comment: the sheet must not announce
+ // "Add comment" over a "Page not found" body.
+ routeParams.current = { owner: 'org', repo: 'repo', number: '1' };
+ const { renderer, unmount } = await renderWithProviders(
+ createElement(PrReviewCommentComposerScreen)
+ );
+ expect(renderer.root.findAll(node => String(node.type) === 'InvalidRouteState')).toHaveLength(
+ 1
+ );
+ expect(renderer.root.findAll(node => String(node.type) === 'PrFormSheetHeader')).toHaveLength(
+ 0
+ );
+ unmount();
+ });
+});
diff --git a/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx b/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx
index 4f3753a543..24a4d743f4 100644
--- a/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx
+++ b/apps/mobile/src/components/pr-review/pr-form-sheet-chrome.tsx
@@ -48,10 +48,12 @@ export function useFormSheetKeyboardVisible(): boolean {
export function PrFormSheetHeader(props: { title: string; eyebrow: string; onBack: () => void }) {
return (
+ {/* Left-aligned heading on the back row: `centerTitle` would split the
+ header into a centered title row and a second row holding a lone
+ dismiss chevron, which read as a stray control under the title. */}
;
- } else if (isEdit) {
+ return ;
+ }
+
+ let body: ReactNode = null;
+ if (isEdit) {
body = null;
} else if (pr.isLoading) {
body = (
diff --git a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx
index e457816bc8..091e819a3c 100644
--- a/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx
@@ -21,6 +21,13 @@ import { selectPrReviewGateView } from '@/lib/pr-review/pr-review-connect-gate-v
import { type ProviderPrPlatform } from '@/lib/pr-review/provider-pr-ref';
import { useTRPC } from '@/lib/trpc';
+/**
+ * Cold deep links land straight on a gate state with no navigation history,
+ * so `ScreenHeader` would render without a back control. The provider-neutral
+ * inbox is the one exit every PR-review surface shares.
+ */
+const PR_REVIEW_ENTRY_HREF = '/(app)/pr-review' as const;
+
type PrReviewConnectGateProps = {
readonly children: ReactNode;
/**
@@ -157,7 +164,7 @@ function GitHubConnectGate({ children }: Readonly<{ children: ReactNode }>) {
if (view === 'error') {
return (
-
+
) {
if (view === 'loading') {
return (
-
+
@@ -186,7 +193,7 @@ function GitHubConnectGate({ children }: Readonly<{ children: ReactNode }>) {
const revoked = view === 'reconnect';
return (
-
+
-
+
-
+
-
+
@@ -359,7 +366,7 @@ function ProviderConnectGate({
const title = platform === 'gitlab' ? t('common.connectGitlab') : t('common.connectBitbucket');
return (
-
+
encodeURIComponent(segment)).join('/');
- const instance =
- ref.platform === 'gitlab' && ref.instanceHint ? { instance: ref.instanceHint } : {};
- return {
- pathname: `/(app)/pr-review/${platform}/${encoded}/${sheet}`,
- params: { ...instance, ...params },
- };
+ // The path is built at runtime, so it never appears in the generated
+ // typed-routes literal union; like `providerPrHref` (provider-pr-ref.ts),
+ // the params ride in an encoded query string and the href is the cast
+ // string.
+ const queryParts: string[] = [];
+ if (ref.platform === 'gitlab' && ref.instanceHint) {
+ queryParts.push(`instance=${encodeURIComponent(ref.instanceHint)}`);
+ }
+ for (const [key, value] of Object.entries(params)) {
+ queryParts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
+ }
+ const search = queryParts.length > 0 ? `?${queryParts.join('&')}` : '';
+ return `/(app)/pr-review/${platform}/${encoded}/${sheet}${search}` as Href;
}
diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
index 530c43005c..9e7769c413 100644
--- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
@@ -196,6 +196,12 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
}, [queryClient, queries, pr.data?.headSha]);
const isMergeRequest = queries.platform === 'gitlab';
+ // A first-load failure of the overview leaves the screen without a PR
+ // body: Submit review and share would open sheets or share a link for a
+ // merge request that never loaded, so they stay rendered (no header
+ // shift) but stop responding. The tabs are disabled for the same reason —
+ // their reads cannot succeed while the overview that gates them failed.
+ const loadFailed = pr.isError && pr.data === undefined;
// The review-submit sheet is a route sibling on every provider (s6), so
// the affordance is offered wherever its scope can actually be queried:
// a Bitbucket PR without a selected organization waits at the boundary
@@ -267,12 +273,14 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
{webUrl ? (
@@ -286,6 +294,7 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
) : null}
diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx
index 123088cc14..1357e1e8ee 100644
--- a/apps/mobile/src/components/pr-review/pr-review-submit.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx
@@ -61,7 +61,8 @@ import {
} from '@/lib/pr-review/use-pr-review-mutations';
import { type PendingReviewItem, usePendingReview } from '@/lib/pr-review/pending-review-provider';
import { partitionPendingItems } from '@/lib/pr-review/partition-pending-items';
-import { type ProviderPrRef, providerPrRouteSegments } from '@/lib/pr-review/provider-pr-ref';
+import { providerPrSheetHref } from '@/components/pr-review/pr-review-provider-sheet-href';
+import { type ProviderPrRef } from '@/lib/pr-review/provider-pr-ref';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { usePrReviewFooterPreference } from '@/lib/hooks/use-pr-review-footer-preference';
import { maybeAskAfterSuccessfulOutcome } from '@/lib/feedback';
@@ -338,22 +339,15 @@ export function PrReviewSubmit(props: PrReviewSubmitProps) {
if (prRef) {
// The pending comment edits through the ref's own composer route, so
// the composer stays inside the scope its queries run under.
- const { platform, identity } = providerPrRouteSegments(prRef);
- const encoded = identity.map(segment => encodeURIComponent(segment)).join('/');
- const href: Href = {
- pathname: `/(app)/pr-review/${platform}/${encoded}/comment-composer`,
- params: {
+ router.push(
+ providerPrSheetHref(prRef, 'comment-composer', {
path: item.path,
side: item.side,
line: String(item.line),
...(item.startLine !== undefined ? { startLine: String(item.startLine) } : {}),
pendingId: item.id,
- ...(prRef.platform === 'gitlab' && prRef.instanceHint
- ? { instance: prRef.instanceHint }
- : {}),
- },
- };
- router.push(href);
+ })
+ );
return;
}
const href: Href = {
diff --git a/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx b/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx
index ac71ab5522..78bf8fecde 100644
--- a/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx
@@ -24,6 +24,12 @@ type PrReviewTabSelectorProps = {
* while the PR query is still loading, which draws no badge.
*/
discussionCount?: number;
+ /**
+ * Disables every tab while the PR body is in a failed load state: the
+ * tabs' own reads cannot succeed while the overview that gates them
+ * failed, so the row stays at its size but stops switching.
+ */
+ disabled?: boolean;
};
/**
@@ -36,6 +42,7 @@ export function PrReviewTabSelector({
activeTab,
onChange,
discussionCount,
+ disabled = false,
}: PrReviewTabSelectorProps) {
const { t } = useTranslation();
return (
@@ -50,7 +57,8 @@ export function PrReviewTabSelector({
{
if (active) {
return;
@@ -60,7 +68,8 @@ export function PrReviewTabSelector({
}}
className={cn(
'flex-1 items-center justify-center rounded-md py-2 active:opacity-70',
- active && 'bg-card shadow-sm shadow-[#0000000D]'
+ active && 'bg-card shadow-sm shadow-[#0000000D]',
+ disabled && 'opacity-60'
)}
>
diff --git a/apps/mobile/src/glanceable-android/active-agents-widget.tsx b/apps/mobile/src/glanceable-android/active-agents-widget.tsx
index c859c64049..58c1d57894 100644
--- a/apps/mobile/src/glanceable-android/active-agents-widget.tsx
+++ b/apps/mobile/src/glanceable-android/active-agents-widget.tsx
@@ -106,7 +106,9 @@ function startEdge(rtl: boolean): 'flex-start' | 'flex-end' {
/** Lay a row's children out in reading order. */
function inReadingOrder(children: React.ReactNode[], rtl: boolean): React.ReactNode[] {
- return rtl ? children.toReversed() : children;
+ // Hermes does not implement Array.prototype.toReversed; reverse the copy
+ // so the caller's array is untouched.
+ return rtl ? [...children].reverse() : children;
}
function dotColor(kind: GlanceableCountKind, palette: Palette): HexColor {
diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index c6ff663a92..d8125efced 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -674,7 +674,7 @@
"terms": {
"pullRequest": "Pull request",
"mergeRequest": "Merge request",
- "mergeRequestNumber": "!{{number}}",
+ "mergeRequestNumber": "Merge request !{{number}}",
"shareMergeRequest": "Share merge request",
"viewOnProvider": "View on {{provider}}",
"mergeRequestUnavailable": "Merge request unavailable",
diff --git a/apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts b/apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts
index ed1862e4b4..82e92f1831 100644
--- a/apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts
+++ b/apps/mobile/src/lib/pr-review/use-provider-inbox.test.ts
@@ -133,6 +133,35 @@ describe('mergeProviderInboxSources', () => {
expect(merged.items.map(row => row.title)).toEqual(['GitLab MR', 'GitHub PR', 'Bitbucket PR']);
});
+ it('sorts without the ES2023 copy-returning methods Hermes lacks', () => {
+ // The device runtime is Hermes: Array.prototype.{toSorted,toReversed,
+ // toSpliced,with} do not exist there, so the merge must go through the
+ // mutating API. Deleting them in Node reproduces the device crash the
+ // inbox shipped with (e12: `undefined is not a function` on `.toSorted`).
+ const methods = ['toSorted', 'toReversed', 'toSpliced', 'with'];
+ const restored = methods.map(
+ method => [method, Object.getOwnPropertyDescriptor(Array.prototype, method)] as const
+ );
+ for (const method of methods) {
+ // oxlint-disable-next-line typescript-eslint/no-dynamic-delete -- the test deletes the ES2023 built-ins to reproduce the Hermes runtime, then restores them below
+ delete (Array.prototype as unknown as Record)[method];
+ }
+ try {
+ const merged = mergeProviderInboxSources([
+ source({ platform: 'github', rows: githubRows }),
+ source({ platform: 'gitlab', rows: gitlabRows }),
+ ]);
+ expect(merged.items.map(row => row.title)).toEqual(['GitLab MR', 'GitHub PR']);
+ } finally {
+ for (const [method, descriptor] of restored) {
+ if (descriptor) {
+ // oxlint-disable-next-line no-extend-native -- restores exactly the built-ins deleted above, so later tests keep the real Array prototype
+ Object.defineProperty(Array.prototype, method, descriptor);
+ }
+ }
+ }
+ });
+
it('ignores a provider the user has not connected', () => {
const merged = mergeProviderInboxSources([
source({ platform: 'github', rows: githubRows }),
diff --git a/apps/mobile/src/lib/pr-review/use-provider-inbox.ts b/apps/mobile/src/lib/pr-review/use-provider-inbox.ts
index 501246f894..1fd3b853c8 100644
--- a/apps/mobile/src/lib/pr-review/use-provider-inbox.ts
+++ b/apps/mobile/src/lib/pr-review/use-provider-inbox.ts
@@ -137,7 +137,8 @@ export function mergeProviderInboxSources(
const active = sources.filter(source => source.enabled);
const items = active
.flatMap(source => source.rows)
- .toSorted((left, right) => updatedAtMs(right) - updatedAtMs(left));
+ // eslint-disable-next-line unicorn/no-array-sort -- Hermes does not implement Array.prototype.toSorted; flatMap already copies so nothing shared is mutated
+ .sort((left, right) => updatedAtMs(right) - updatedAtMs(left));
const failed = active.filter(source => source.error !== null && source.error !== undefined);
const firstPageFailures = failed.filter(source => !source.hasLoadedPages);
const allFailed = active.length > 0 && firstPageFailures.length === active.length;
diff --git a/apps/web/src/lib/provider-review/bitbucket-read.test.ts b/apps/web/src/lib/provider-review/bitbucket-read.test.ts
index fe675db5e0..0f9d039620 100644
--- a/apps/web/src/lib/provider-review/bitbucket-read.test.ts
+++ b/apps/web/src/lib/provider-review/bitbucket-read.test.ts
@@ -257,9 +257,6 @@ beforeEach(() => {
if (parsed.pathname.endsWith('/branch-restrictions')) {
return jsonResponse(branchRestrictionsFixture);
}
- if (parsed.pathname === '/2.0/pullrequests') {
- return jsonResponse({ pagelen: 50, values: [], next: null });
- }
if (parsed.pathname.endsWith('/pullrequests/12')) return jsonResponse(prDetail);
if (parsed.pathname.includes('/src/')) {
return new Response('line one\nline two\nline three', {
@@ -695,35 +692,37 @@ describe('listChecks', () => {
});
describe('listInbox', () => {
- it('requests the reviewer inbox for the connected workspace and carries full identity', async () => {
+ const inboxPr = (id: number, updatedOn: string, fullName = 'acme/repo') => ({
+ id,
+ title: `PR ${id}`,
+ state: 'OPEN',
+ draft: false,
+ author: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' },
+ updated_on: updatedOn,
+ source: { branch: { name: 'feature/retry' }, repository: { full_name: fullName } },
+ destination: { branch: { name: 'main' }, repository: { full_name: fullName } },
+ });
+
+ it('fans out over the workspace repositories and carries full identity', async () => {
fetchMock.mockImplementation(async (url: string | URL) => {
const full = url.toString();
if (full.includes('token-service.example.com')) {
return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE });
}
const parsed = new URL(full);
- if (parsed.pathname === '/2.0/pullrequests') {
- expect(parsed.searchParams.get('role')).toBe('REVIEWER');
+ if (parsed.pathname === '/2.0/repositories/acme') {
+ return jsonResponse({
+ pagelen: 100,
+ values: [{ slug: 'repo' }, { slug: 'empty-repo' }],
+ next: null,
+ });
+ }
+ if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') {
expect(parsed.searchParams.get('q')).toBe('state="OPEN"');
return jsonResponse({
pagelen: 50,
values: [
- {
- id: 12,
- title: 'Add retry fingerprints',
- state: 'OPEN',
- draft: false,
- author: { uuid: '{author-uuid}', nickname: 'alice', display_name: 'Alice' },
- updated_on: '2026-09-03T00:00:00.000000+00:00',
- source: {
- branch: { name: 'feature/retry' },
- repository: { full_name: 'acme/repo' },
- },
- destination: {
- branch: { name: 'main' },
- repository: { full_name: 'acme/repo' },
- },
- },
+ inboxPr(12, '2026-09-02T00:00:00.000000+00:00'),
{
id: 13,
title: 'Foreign workspace PR',
@@ -732,17 +731,14 @@ describe('listInbox', () => {
updated_on: '2026-09-03T00:00:00.000000+00:00',
destination: { repository: { full_name: 'other-ws/other-repo' } },
},
- {
- id: 14,
- title: 'No repository identity',
- state: 'OPEN',
- draft: false,
- updated_on: null,
- },
+ { id: 14, title: 'No repository identity', state: 'OPEN', draft: false, updated_on: null },
],
next: null,
});
}
+ if (parsed.pathname === '/2.0/repositories/acme/empty-repo/pullrequests') {
+ return jsonResponse({ pagelen: 50, values: [], next: null });
+ }
return jsonResponse({ pagelen: 50, values: [], next: null });
});
@@ -751,11 +747,76 @@ describe('listInbox', () => {
expect(result.items).toHaveLength(1);
expect(result.items[0]).toMatchObject({
ref: { platform: 'bitbucket', workspace: 'acme', repoSlug: 'repo', prId: 12 },
- title: 'Add retry fingerprints',
+ title: 'PR 12',
author: { login: 'alice' },
state: 'open',
draft: false,
});
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it('merges pages across repositories newest first and continues with a page cursor', async () => {
+ fetchMock.mockImplementation(async (url: string | URL) => {
+ const full = url.toString();
+ if (full.includes('token-service.example.com')) {
+ return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE });
+ }
+ const parsed = new URL(full);
+ if (parsed.pathname === '/2.0/repositories/acme') {
+ return jsonResponse({ pagelen: 100, values: [{ slug: 'repo' }], next: null });
+ }
+ if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') {
+ const page = parsed.searchParams.get('page');
+ if (page === '2') {
+ return jsonResponse({
+ pagelen: 50,
+ values: [inboxPr(21, '2026-09-04T00:00:00.000000+00:00')],
+ next: null,
+ });
+ }
+ return jsonResponse({
+ pagelen: 50,
+ values: Array.from({ length: 50 }, (_, index) =>
+ inboxPr(100 + index, `2026-09-02T00:00:${String(index).padStart(2, '0')}+00:00`)
+ ),
+ next: null,
+ });
+ }
+ return jsonResponse({ pagelen: 50, values: [], next: null });
+ });
+
+ const first = await listInbox(ORG_OWNER);
+ expect(first.items).toHaveLength(50);
+ expect(first.items[0]?.ref).toMatchObject({ prId: 149 });
+ expect(first.nextCursor).toBeTruthy();
+
+ const second = await listInbox(ORG_OWNER, first.nextCursor!);
+ expect(second.items[0]?.ref).toMatchObject({ prId: 21 });
+ expect(second.nextCursor).toBeNull();
+ });
+
+ it('ignores a cursor minted for another workspace', async () => {
+ fetchMock.mockImplementation(async (url: string | URL) => {
+ const full = url.toString();
+ if (full.includes('token-service.example.com')) {
+ return jsonResponse({ status: 'available', token: 'at-mock-token', workspace: WORKSPACE });
+ }
+ const parsed = new URL(full);
+ if (parsed.pathname === '/2.0/repositories/acme') {
+ return jsonResponse({ pagelen: 100, values: [{ slug: 'repo' }], next: null });
+ }
+ if (parsed.pathname === '/2.0/repositories/acme/repo/pullrequests') {
+ expect(parsed.searchParams.get('page')).toBe('1');
+ return jsonResponse({ pagelen: 50, values: [inboxPr(12, '2026-09-02T00:00:00.000000+00:00')], next: null });
+ }
+ return jsonResponse({ pagelen: 50, values: [], next: null });
+ });
+
+ const foreign = Buffer.from(
+ JSON.stringify({ identity: 'bitbucket-inbox:evil', page: 7 })
+ ).toString('base64url');
+ const result = await listInbox(ORG_OWNER, foreign);
+ expect(result.items).toHaveLength(1);
});
});
diff --git a/apps/web/src/lib/provider-review/bitbucket-read.ts b/apps/web/src/lib/provider-review/bitbucket-read.ts
index 292818f158..5a430256a4 100644
--- a/apps/web/src/lib/provider-review/bitbucket-read.ts
+++ b/apps/web/src/lib/provider-review/bitbucket-read.ts
@@ -46,6 +46,11 @@ const MAX_BITBUCKET_RESPONSE_BYTES = 10 * 1024 * 1024;
const MAX_SUMMARY_DIFFSTAT_PAGES = 3;
/** The merge gate checks at most this many pages of the latest builds. */
const MAX_BUILD_PAGES = 3;
+/** The inbox enumerates at most this many pages of this size of workspace repositories. */
+const INBOX_REPOSITORY_PAGE_SIZE = 100;
+const INBOX_REPOSITORY_PAGES = 3;
+/** How many repository PR collections the inbox fetches at once. */
+const INBOX_REPOSITORY_CONCURRENCY = 8;
/**
* The task-collection page bound for the discussion task walk: the same
* bounded walk the write layer's thread resolution uses, so one discussion
@@ -387,10 +392,6 @@ function repositoryPathGuard(repository: BitbucketRepositoryAccess): (pathname:
export { repositoryPathGuard };
-function inboxPathGuard(pathname: string): boolean {
- return pathname === '/2.0/pullrequests';
-}
-
/**
* One page of any Bitbucket collection. When a cursor carries a validated
* next URL the page is fetched there (with the caller's fresh token); the
@@ -836,9 +837,16 @@ export async function listChecks(
}
/**
- * Open pull requests awaiting review, for the connected workspace membership.
+ * Open pull requests across the connected workspace, for the PR Review inbox.
* Each item carries platform, workspace, and repository identity, so the list
* can never navigate into a different provider's repo.
+ *
+ * Bitbucket removed the aggregate collections that used to answer this in one
+ * request (`/2.0/pullrequests?role=REVIEWER` and the workspace-level twin
+ * both answer "There is no API hosted at this URL" today), and a workspace
+ * access token cannot resolve its own account (`/2.0/user` answers 403), so
+ * "reviewer = me" is not reproducible. The inbox therefore lists every open
+ * PR of the workspace's repositories, newest first.
*/
export async function listInbox(
owner: BitbucketReviewOwner,
@@ -847,12 +855,32 @@ export async function listInbox(
const access = await authorizeWorkspace(owner);
try {
const identity = `bitbucket-inbox:${access.workspace.slug}`;
- const page = await fetchPage(access, '/2.0/pullrequests', identity, cursor, inboxPathGuard, {
- role: 'REVIEWER',
- q: 'state="OPEN"',
- });
+ const page = decodeInboxPageCursor(cursor, identity);
+ const slugs = await listWorkspaceRepositorySlugs(access, access.workspace.slug);
+ const values: unknown[] = [];
+ let hasMore = false;
+ for (let offset = 0; offset < slugs.length; offset += INBOX_REPOSITORY_CONCURRENCY) {
+ const batch = slugs.slice(offset, offset + INBOX_REPOSITORY_CONCURRENCY);
+ const pages = await Promise.all(
+ batch.map(slug =>
+ requestBitbucketJson(
+ access,
+ `/2.0/repositories/${encodeURIComponent(access.workspace.slug)}/${encodeURIComponent(slug)}/pullrequests`,
+ { query: { pagelen: BITBUCKET_PAGE_SIZE, page, q: 'state="OPEN"' } }
+ )
+ )
+ );
+ for (const payload of pages) {
+ const parsedPage = BitbucketPageSchema.safeParse(payload);
+ if (!parsedPage.success) {
+ throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected page.');
+ }
+ values.push(...parsedPage.data.values);
+ if (parsedPage.data.values.length >= BITBUCKET_PAGE_SIZE) hasMore = true;
+ }
+ }
const items: ProviderPrInboxItem[] = [];
- for (const value of page.values) {
+ for (const value of values) {
const parsed = BitbucketInboxPullRequestSchema.safeParse(value);
if (!parsed.success) continue;
const ref = inboxRefFrom(parsed.data, access.workspace.slug);
@@ -866,12 +894,79 @@ export async function listInbox(
updatedAt: parsed.data.updated_on ?? '',
});
}
- return { items, nextCursor: page.nextCursor };
+ items.sort((left, right) => inboxUpdatedMs(right) - inboxUpdatedMs(left));
+ const trimmed = items.slice(0, BITBUCKET_PAGE_SIZE);
+ if (items.length > trimmed.length) hasMore = true;
+ return {
+ items: trimmed,
+ nextCursor: hasMore ? encodeInboxPageCursor(identity, page + 1) : null,
+ };
} catch (error) {
throw classifyBitbucketError(error);
}
}
+function inboxUpdatedMs(item: ProviderPrInboxItem): number {
+ const ms = Date.parse(item.updatedAt);
+ return Number.isNaN(ms) ? 0 : ms;
+}
+
+/**
+ * The inbox cursor is a plain page counter, not a provider `next` URL: one
+ * inbox page fans out over the workspace's repositories, so no single next
+ * link can represent it. A cursor minted for another workspace, or in the
+ * old next-URL shape, decodes to page 1 — a cursor can never switch the
+ * workspace a request reads.
+ */
+function encodeInboxPageCursor(identity: string, page: number): string {
+ return Buffer.from(JSON.stringify({ identity, page })).toString('base64url');
+}
+
+function decodeInboxPageCursor(cursor: string | undefined, identity: string): number {
+ if (!cursor) return 1;
+ try {
+ const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as {
+ identity?: unknown;
+ page?: unknown;
+ };
+ if (parsed.identity !== identity || !Number.isInteger(parsed.page)) return 1;
+ return Math.max(1, parsed.page as number);
+ } catch {
+ return 1;
+ }
+}
+
+/**
+ * Repository slugs of the workspace, newest enumeration capped: at most
+ * INBOX_REPOSITORY_PAGES pages of INBOX_REPOSITORY_PAGE_SIZE. A workspace
+ * larger than the cap shows PRs of the repositories Bitbucket enumerates
+ * first — a bounded inbox beats an unbounded crawl.
+ */
+async function listWorkspaceRepositorySlugs(
+ access: { accessToken: string },
+ workspaceSlug: string
+): Promise {
+ const slugs: string[] = [];
+ for (let repoPage = 1; repoPage <= INBOX_REPOSITORY_PAGES; repoPage += 1) {
+ const payload = await requestBitbucketJson(
+ access,
+ `/2.0/repositories/${encodeURIComponent(workspaceSlug)}`,
+ { query: { pagelen: INBOX_REPOSITORY_PAGE_SIZE, page: repoPage } }
+ );
+ const parsed = z
+ .object({ values: z.array(z.object({ slug: z.string().min(1).nullable().optional() })).default([]) })
+ .safeParse(payload);
+ if (!parsed.success) {
+ throw new BitbucketReviewError('retryable', 'Bitbucket returned an unexpected page.');
+ }
+ for (const repository of parsed.data.values) {
+ if (repository.slug) slugs.push(repository.slug);
+ }
+ if (parsed.data.values.length < INBOX_REPOSITORY_PAGE_SIZE) break;
+ }
+ return slugs;
+}
+
/**
* The ref of an inbox row: `workspace/repo-slug` from the destination
* repository full name. A row whose identity is unparseable — or outside the
diff --git a/patches/expo-router@57.0.15.patch b/patches/expo-router@57.0.15.patch
index 3fe2a498d5..bf21f48d07 100644
--- a/patches/expo-router@57.0.15.patch
+++ b/patches/expo-router@57.0.15.patch
@@ -1,4 +1,36 @@
+diff --git a/android/src/main/java/expo/modules/router/ExpoRouterModule.kt b/android/src/main/java/expo/modules/router/ExpoRouterModule.kt
+deleted file mode 100644
+index 369cd65886415cb894b858e38604dc59b5c6ba95..0000000000000000000000000000000000000000
+diff --git a/build/fork/getStateFromPath-forks.js b/build/fork/getStateFromPath-forks.js
+index 63374a73c024945b5b7dc9db410ebb248f1b67e1..1e31faa0cebd6b8a37801328f1c1841b3c816804 100644
+--- a/build/fork/getStateFromPath-forks.js
++++ b/build/fork/getStateFromPath-forks.js
+@@ -343,6 +343,23 @@ function getRouteConfigSorter(previousSegments = []) {
+ return -1;
+ }
+ }
++ /*
++ * START FORK
++ * A catch-all index route and its static-suffix sibling (e.g.
++ * `*identity` + `merge`) tie on every comparison above: pushing
++ * 'index' into the index config's parts equalizes their lengths and
++ * static counts, and both regexes match the same paths. Without this
++ * tie-break the stable sort keeps the tree order, the index wins, and
++ * the static suffix is swallowed into the catch-all param. The static
++ * sibling consumes fewer catch-all segments, so it must rank first.
++ */
++ if (a.isIndex && !b.isIndex) {
++ return 1;
++ }
++ else if (!a.isIndex && b.isIndex) {
++ return -1;
++ }
++ // END FORK
+ /*
+ * Both configs are identical in specificity and segments count/type
+ * Try and sort by initial instead.
diff --git a/build/fork/useLinking.native.js b/build/fork/useLinking.native.js
+index c080a5853fa71f2708c6ed0f15092b6f43231f76..5da1348eaa9d3530253b683f8427b3c5a33f717c 100644
--- a/build/fork/useLinking.native.js
+++ b/build/fork/useLinking.native.js
@@ -114,20 +114,30 @@ function useLinking(ref, { enabled = true, prefixes, filter, config, getInitialU
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 668ff49be2..1a66232f6e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -152,7 +152,7 @@ overrides:
packageExtensionsChecksum: sha256-1pgKZxx87NNMe1poF5N5u5kZB/qlEyILBQPxofM1shE=
patchedDependencies:
- expo-router@57.0.15: 616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed
+ expo-router@57.0.15: a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784
expo-server-sdk: 7850520582b5b394397b35d1ea195192fe78589d8a6a748fe15177b818c4ed0b
expo-widgets@57.0.11: 1e56973d7097e3ff0eb5e8e339cdda799cece44b89ce2e13a43f00873f280adc
react-native-appsflyer@6.18.0: 82df99378c830e774b0f01796d8be595da114d1d13393d85ddd47d565c5c2aab
@@ -515,7 +515,7 @@ importers:
version: 57.0.13(expo@57.0.15)(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)
expo-router:
specifier: ~57.0.15
- version: 57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(082c608c44cb8ce85ad58ca6a80585cf)
+ version: 57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(082c608c44cb8ce85ad58ca6a80585cf)
expo-screen-capture:
specifier: 57.0.2
version: 57.0.2(expo@57.0.15)(react@19.2.3)
@@ -21317,7 +21317,7 @@ snapshots:
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
zod: 3.25.76
optionalDependencies:
- expo-router: 57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(082c608c44cb8ce85ad58ca6a80585cf)
+ expo-router: 57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(082c608c44cb8ce85ad58ca6a80585cf)
react-native: 0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- '@expo/metro-runtime'
@@ -21394,7 +21394,7 @@ snapshots:
ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)
zod: 3.25.76
optionalDependencies:
- expo-router: 57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(0fddfe01ab5b870203118fef5ae0fa5d)
+ expo-router: 57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(0fddfe01ab5b870203118fef5ae0fa5d)
react-native: 0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6)
transitivePeerDependencies:
- '@expo/metro-runtime'
@@ -21891,7 +21891,7 @@ snapshots:
react: 19.2.3
optionalDependencies:
'@expo/metro-runtime': 57.0.12(expo@57.0.15)(react-dom@19.2.6(react@19.2.3))(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
- expo-router: 57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(082c608c44cb8ce85ad58ca6a80585cf)
+ expo-router: 57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(082c608c44cb8ce85ad58ca6a80585cf)
react-dom: 19.2.6(react@19.2.3)
transitivePeerDependencies:
- supports-color
@@ -21906,7 +21906,7 @@ snapshots:
react: 19.2.6
optionalDependencies:
'@expo/metro-runtime': 57.0.12(expo@57.0.15)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
- expo-router: 57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(0fddfe01ab5b870203118fef5ae0fa5d)
+ expo-router: 57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(0fddfe01ab5b870203118fef5ae0fa5d)
react-dom: 19.2.6(react@19.2.6)
transitivePeerDependencies:
- supports-color
@@ -30326,7 +30326,7 @@ snapshots:
- supports-color
- typescript
- expo-router@57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(082c608c44cb8ce85ad58ca6a80585cf):
+ expo-router@57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(082c608c44cb8ce85ad58ca6a80585cf):
dependencies:
'@expo/log-box': 57.0.3(expo@57.0.15)(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
'@expo/metro-runtime': 57.0.12(expo@57.0.15)(react-dom@19.2.6(react@19.2.3))(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
@@ -30373,7 +30373,7 @@ snapshots:
- react-native-worklets
- supports-color
- expo-router@57.0.15(patch_hash=616f8a79932a3ebc7d31343b53b3fc425a14bfb89bb86cdafa614cefa4e603ed)(0fddfe01ab5b870203118fef5ae0fa5d):
+ expo-router@57.0.15(patch_hash=a4b34f8ab2f1fa05490cc8b030a881f48c624878f4287d8f53dc42aca54e0784)(0fddfe01ab5b870203118fef5ae0fa5d):
dependencies:
'@expo/log-box': 57.0.3(expo@57.0.15)(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)
'@expo/metro-runtime': 57.0.12(expo@57.0.15)(react-dom@19.2.6(react@19.2.6))(react-native@0.86.2(@types/react@19.2.14)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6)