diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index a2c59d4f82..9d533c25a6 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -74,6 +74,7 @@
"expo-tracking-transparency": "55.0.15",
"expo-web-browser": "55.0.17",
"jotai": "2.18.1",
+ "lowlight": "^3.3.0",
"lucide-react-native": "1.7.0",
"nativewind": "5.0.0-preview.3",
"posthog-react-native": "^4.54.3",
diff --git a/apps/mobile/src/app/(app)/_layout.tsx b/apps/mobile/src/app/(app)/_layout.tsx
index 20ec428c07..4d6a5cf1c1 100644
--- a/apps/mobile/src/app/(app)/_layout.tsx
+++ b/apps/mobile/src/app/(app)/_layout.tsx
@@ -25,6 +25,12 @@ export default function AppLayout() {
}}
>
+
();
+ const owner = parseParam(params.owner);
+ const repo = parseParam(params.repo);
+ const rawNumber = parseParam(params.number);
+ const number = rawNumber ? Number.parseInt(rawNumber, 10) : Number.NaN;
+ const { fullSheetDetent } = useFormSheetDetents();
+
+ if (!owner || !repo || !Number.isInteger(number) || number <= 0) {
+ return ;
+ }
+
+ const sheetOptions = {
+ presentation: 'formSheet' as const,
+ sheetAllowedDetents: [0.5, fullSheetDetent] as [number, number],
+ sheetGrabberVisible: true,
+ headerShown: false,
+ };
+
+ // The connect gate wraps every PR-review surface, including this nested
+ // route reached directly by deep link / chat tap, so a disconnected or
+ // revoked user can never reach the authenticated queries and mutations.
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx
new file mode 100644
index 0000000000..2872ee62b1
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/comment-composer.tsx
@@ -0,0 +1,5 @@
+import { PrReviewCommentComposerScreen } from '@/components/pr-review/pr-review-comment-composer-screen';
+
+export default function PrReviewCommentComposerRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx
new file mode 100644
index 0000000000..5b34be2218
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/file-navigator.tsx
@@ -0,0 +1,5 @@
+import { PrReviewFileNavigatorScreen } from '@/components/pr-review/pr-review-file-navigator-screen';
+
+export default function PrReviewFileNavigatorRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx
new file mode 100644
index 0000000000..b38a5c9be7
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/index.tsx
@@ -0,0 +1,21 @@
+import { Stack, useLocalSearchParams } from 'expo-router';
+
+import { PrReviewScreen } from '@/components/pr-review/pr-review-screen';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+};
+
+export default function PrReviewNumberIndexRoute() {
+ const { owner, repo, number } = useLocalSearchParams();
+ const numberValue = Number.parseInt(number, 10);
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx
new file mode 100644
index 0000000000..bef38a58f7
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/merge.tsx
@@ -0,0 +1,5 @@
+import { PrReviewMergeScreen } from '@/components/pr-review/pr-review-merge-screen';
+
+export default function PrReviewMergeRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx
new file mode 100644
index 0000000000..a28b1f7a14
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/review-submit.tsx
@@ -0,0 +1,5 @@
+import { PrReviewReviewSubmitScreen } from '@/components/pr-review/pr-review-review-submit-screen';
+
+export default function PrReviewReviewSubmitRoute() {
+ return ;
+}
diff --git a/apps/mobile/src/app/(app)/pr-review/index.tsx b/apps/mobile/src/app/(app)/pr-review/index.tsx
new file mode 100644
index 0000000000..2514d0c877
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/index.tsx
@@ -0,0 +1,10 @@
+import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate';
+import { PrReviewEntryScreen } from '@/components/pr-review/pr-review-entry-screen';
+
+export default function PrReviewIndexRoute() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/agents/chat-link-actions.test.ts b/apps/mobile/src/components/agents/chat-link-actions.test.ts
index e9da2fe15d..8b43bdad5f 100644
--- a/apps/mobile/src/components/agents/chat-link-actions.test.ts
+++ b/apps/mobile/src/components/agents/chat-link-actions.test.ts
@@ -7,6 +7,7 @@ import { openExternalUrl } from '@/lib/external-link';
import {
buildChatLinkActionSheet,
+ buildPrLinkTapActionSheet,
getSelectedChatLinkAction,
performChatLinkAction,
} from './chat-link-actions';
@@ -41,6 +42,43 @@ describe('chat link actions', () => {
expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull();
});
+ it('prepends Review PR as the first option for PR links and keeps the existing order', () => {
+ const sheet = buildChatLinkActionSheet({ isPrLink: true });
+
+ expect(sheet.options).toEqual(['Review PR', 'Open link', 'Copy link', 'Share link', 'Cancel']);
+ expect(sheet.cancelButtonIndex).toBe(4);
+ expect(getSelectedChatLinkAction(sheet, 0)).toBe('review-pr');
+ expect(getSelectedChatLinkAction(sheet, 1)).toBe('open');
+ expect(getSelectedChatLinkAction(sheet, 2)).toBe('copy');
+ expect(getSelectedChatLinkAction(sheet, 3)).toBe('share');
+ expect(getSelectedChatLinkAction(sheet, 4)).toBeNull();
+ });
+
+ it('builds the tap sheet with exactly Review PR / Open in browser / Cancel', () => {
+ const sheet = buildPrLinkTapActionSheet();
+
+ expect(sheet.options).toEqual(['Review PR', 'Open in browser', 'Cancel']);
+ expect(sheet.cancelButtonIndex).toBe(2);
+ expect(getSelectedChatLinkAction(sheet, 0)).toBe('review-pr');
+ expect(getSelectedChatLinkAction(sheet, 1)).toBe('open');
+ expect(getSelectedChatLinkAction(sheet, 2)).toBeNull();
+ expect(getSelectedChatLinkAction(sheet, undefined)).toBeNull();
+ });
+
+ it('leaves non-PR link sheets byte-identical to the pre-PR behavior', () => {
+ const sheet = buildChatLinkActionSheet({ isPrLink: false });
+
+ expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']);
+ expect(sheet.cancelButtonIndex).toBe(3);
+ });
+
+ it('omits Review PR when the flag is not supplied', () => {
+ const sheet = buildChatLinkActionSheet();
+
+ expect(sheet.options).toEqual(['Open link', 'Copy link', 'Share link', 'Cancel']);
+ expect(getSelectedChatLinkAction(sheet, 0)).toBe('open');
+ });
+
it('opens the exact URL through the existing browser helper with retry enabled', async () => {
await performChatLinkAction('open', 'https://kilo.ai/docs?source=chat#links');
diff --git a/apps/mobile/src/components/agents/chat-link-actions.ts b/apps/mobile/src/components/agents/chat-link-actions.ts
index 8ead408ead..45c9ec985a 100644
--- a/apps/mobile/src/components/agents/chat-link-actions.ts
+++ b/apps/mobile/src/components/agents/chat-link-actions.ts
@@ -4,14 +4,15 @@ import { toast } from 'sonner-native';
import { openExternalUrl } from '@/lib/external-link';
-type ChatLinkAction = 'open' | 'copy' | 'share';
+type ChatLinkAction = 'open' | 'copy' | 'share' | 'review-pr';
type ChatLinkActionOption =
| { kind: ChatLinkAction; label: string }
| { kind: 'cancel'; label: 'Cancel' };
-export function buildChatLinkActionSheet() {
+export function buildChatLinkActionSheet({ isPrLink = false }: { isPrLink?: boolean } = {}) {
const actions: ChatLinkActionOption[] = [
+ ...(isPrLink ? ([{ kind: 'review-pr', label: 'Review PR' }] as const) : []),
{ kind: 'open', label: 'Open link' },
{ kind: 'copy', label: 'Copy link' },
{ kind: 'share', label: 'Share link' },
@@ -25,8 +26,28 @@ export function buildChatLinkActionSheet() {
};
}
+/**
+ * The tap (not long-press) action sheet for a GitHub PR link. The accepted
+ * contract is exactly three options: Review PR, Open in browser, Cancel.
+ * This is intentionally distinct from the long-press sheet, which also
+ * offers Copy/Share.
+ */
+export function buildPrLinkTapActionSheet() {
+ const actions: ChatLinkActionOption[] = [
+ { kind: 'review-pr', label: 'Review PR' },
+ { kind: 'open', label: 'Open in browser' },
+ { kind: 'cancel', label: 'Cancel' },
+ ];
+
+ return {
+ actions,
+ options: actions.map(action => action.label),
+ cancelButtonIndex: actions.length - 1,
+ };
+}
+
export function getSelectedChatLinkAction(
- sheet: ReturnType,
+ sheet: ReturnType | ReturnType,
index: number | undefined
): ChatLinkAction | null {
if (index === undefined) {
diff --git a/apps/mobile/src/components/agents/chat-markdown-text.tsx b/apps/mobile/src/components/agents/chat-markdown-text.tsx
index 1f2afbd6bc..40d6fdd39e 100644
--- a/apps/mobile/src/components/agents/chat-markdown-text.tsx
+++ b/apps/mobile/src/components/agents/chat-markdown-text.tsx
@@ -1,25 +1,82 @@
import { useActionSheet } from '@expo/react-native-action-sheet';
+import { type Href, useRouter } from 'expo-router';
import { useCallback } from 'react';
import { type GestureResponderEvent } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { openExternalUrl } from '@/lib/external-link';
+import { parseGitHubPrUrl } from '@/lib/github-pr-url';
+
import {
buildChatLinkActionSheet,
+ buildPrLinkTapActionSheet,
getSelectedChatLinkAction,
performChatLinkAction,
} from './chat-link-actions';
import { MarkdownText, type MarkdownTextProps } from './markdown-text';
-type ChatMarkdownTextProps = Omit;
+type ChatMarkdownTextProps = Omit;
+
+function buildPrReviewHref(href: string): Href | null {
+ const parsed = parseGitHubPrUrl(href);
+ if (!parsed) {
+ return null;
+ }
+ return {
+ pathname: '/(app)/pr-review/[owner]/[repo]/[number]',
+ params: {
+ owner: parsed.owner,
+ repo: parsed.repo,
+ number: String(parsed.number),
+ },
+ };
+}
export function ChatMarkdownText(props: Readonly) {
const { showActionSheetWithOptions } = useActionSheet();
const { bottom } = useSafeAreaInsets();
+ const router = useRouter();
+
+ const handlePressLink = useCallback(
+ (href: string) => {
+ if (!parseGitHubPrUrl(href)) {
+ return false;
+ }
+ // Tap on a PR link shows exactly three options: Review PR / Open in
+ // browser / Cancel. The richer Copy/Share sheet is long-press only.
+ const sheet = buildPrLinkTapActionSheet();
+ showActionSheetWithOptions(
+ {
+ options: sheet.options,
+ cancelButtonIndex: sheet.cancelButtonIndex,
+ title: 'PR link actions',
+ message: href,
+ containerStyle: { paddingBottom: bottom },
+ },
+ index => {
+ const action = getSelectedChatLinkAction(sheet, index);
+ if (action === 'review-pr') {
+ const reviewHref = buildPrReviewHref(href);
+ if (reviewHref) {
+ router.push(reviewHref);
+ }
+ return;
+ }
+ if (action === 'open') {
+ void openExternalUrl(href, { label: 'link' });
+ }
+ }
+ );
+ return true;
+ },
+ [bottom, router, showActionSheetWithOptions]
+ );
const handleLongPressLink = useCallback(
(href: string, event?: GestureResponderEvent) => {
event?.stopPropagation();
- const sheet = buildChatLinkActionSheet();
+ const isPrLink = parseGitHubPrUrl(href) !== null;
+ const sheet = buildChatLinkActionSheet({ isPrLink });
showActionSheetWithOptions(
{
options: sheet.options,
@@ -30,14 +87,23 @@ export function ChatMarkdownText(props: Readonly) {
},
index => {
const action = getSelectedChatLinkAction(sheet, index);
+ if (action === 'review-pr') {
+ const reviewHref = buildPrReviewHref(href);
+ if (reviewHref) {
+ router.push(reviewHref);
+ }
+ return;
+ }
if (action) {
void performChatLinkAction(action, href);
}
}
);
},
- [bottom, showActionSheetWithOptions]
+ [bottom, router, showActionSheetWithOptions]
);
- return ;
+ return (
+
+ );
}
diff --git a/apps/mobile/src/components/agents/markdown-text.tsx b/apps/mobile/src/components/agents/markdown-text.tsx
index aecafa114c..9c91176020 100644
--- a/apps/mobile/src/components/agents/markdown-text.tsx
+++ b/apps/mobile/src/components/agents/markdown-text.tsx
@@ -29,11 +29,20 @@ import { MarkdownTable } from './markdown-table';
export type MarkdownLinkLongPressHandler = (href: string, event?: GestureResponderEvent) => void;
+export type MarkdownLinkPressHandler = (href: string) => boolean;
+
export type MarkdownTextProps = {
value: string;
variant?: MarkdownVariant;
selectable?: boolean;
onLongPressLink?: MarkdownLinkLongPressHandler;
+ /**
+ * Optional tap handler invoked when a rendered link is pressed. When this
+ * callback is omitted, or when it returns a falsy value, the link is opened
+ * with `openExternalUrl`. Returning `true` signals that the caller has
+ * fully handled the press and the default browser open should be skipped.
+ */
+ onPressLink?: MarkdownLinkPressHandler;
};
// The library's default `Renderer` renders code blocks with the `em` text
@@ -50,20 +59,23 @@ export type MarkdownTextProps = {
// loses to the chat bubble's swipe-to-reply pan. We render code as a plain
// wrapping Text and tables behind a chip instead — no horizontal ScrollView
// ever renders inside a bubble.
+type MarkdownRendererHandlers = {
+ onLongPressLink?: MarkdownLinkLongPressHandler;
+ onPressLink?: MarkdownLinkPressHandler;
+};
+
class MarkdownRenderer extends Renderer {
private readonly palette: MarkdownPalette;
private readonly selectable: boolean;
private readonly onLongPressLink?: MarkdownLinkLongPressHandler;
+ private readonly onPressLink?: MarkdownLinkPressHandler;
- constructor(
- palette: MarkdownPalette,
- selectable = true,
- onLongPressLink?: MarkdownLinkLongPressHandler
- ) {
+ constructor(palette: MarkdownPalette, selectable: boolean, handlers: MarkdownRendererHandlers) {
super();
this.palette = palette;
this.selectable = selectable;
- this.onLongPressLink = onLongPressLink;
+ this.onLongPressLink = handlers.onLongPressLink;
+ this.onPressLink = handlers.onPressLink;
}
private textNode(children: string | ReactNode[], styles?: TextStyle): ReactNode {
@@ -128,6 +140,10 @@ class MarkdownRenderer extends Renderer {
}}
onLongPress={getLinkLongPressHandler(this.onLongPressLink, href)}
onPress={() => {
+ const handled = this.onPressLink?.(href);
+ if (handled) {
+ return;
+ }
void openExternalUrl(href, { label: accessibilityLabel });
}}
style={styles}
@@ -182,6 +198,7 @@ export function MarkdownText({
variant = 'assistant',
selectable = true,
onLongPressLink,
+ onPressLink,
}: Readonly) {
const colorScheme = useColorScheme();
const colors = useThemeColors();
@@ -190,7 +207,7 @@ export function MarkdownText({
const palette = getPalette(variant, colors);
return {
styles: getMarkdownStyles(palette),
- renderer: new MarkdownRenderer(palette, selectable, onLongPressLink),
+ renderer: new MarkdownRenderer(palette, selectable, { onLongPressLink, onPressLink }),
theme: {
colors: {
text: palette.textColor,
@@ -200,7 +217,7 @@ export function MarkdownText({
},
},
};
- }, [variant, colors, selectable, onLongPressLink]);
+ }, [variant, colors, selectable, onLongPressLink, onPressLink]);
const elements = useMarkdown(value, {
colorScheme,
diff --git a/apps/mobile/src/components/pr-review/diff/diff-line.tsx b/apps/mobile/src/components/pr-review/diff/diff-line.tsx
new file mode 100644
index 0000000000..a77fad4226
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/diff-line.tsx
@@ -0,0 +1,163 @@
+// A single line of a diff, rendered in JetBrains Mono with syntax
+// highlighting, a gutter for old/new line numbers, and a tinted
+// background that signals add / del / context.
+//
+// We render fixed-height rows (height = lineHeight + vertical padding)
+// so FlashList can virtualize without measuring each row. The diff
+// surface renders thousands of lines and remeasuring on every scroll
+// frame would destroy scroll perf on mid-tier Android devices.
+//
+// S7a adds two opt-in behaviours, both passed from the diff list:
+// - `onTap` makes the line tappable; the diff list runs the
+// selection reducer and updates the bridge / floating action.
+// - `isSelected` paints a focus ring around the line when it
+// falls inside the current selection range.
+
+import { memo, useMemo } from 'react';
+import { Pressable, Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native';
+
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight';
+import { type ParsedDiffLine } from '@/lib/pr-review/diff/parse-patch';
+import { MUTED_COLOR, tokenColorFor } from '@/lib/pr-review/diff/syntax-colors';
+import { cn } from '@/lib/utils';
+
+const LINE_HEIGHT = 18;
+const VERTICAL_PADDING = 2;
+const GUTTER_WIDTH = 56;
+const NO_NEWLINE_INDICATOR = '\u26A0\uFE0F no newline at end of file';
+
+const ROW_MIN_HEIGHT = LINE_HEIGHT + VERTICAL_PADDING * 2;
+const ROW_STYLE: ViewStyle = { minHeight: ROW_MIN_HEIGHT };
+const GUTTER_STYLE: ViewStyle = {
+ width: GUTTER_WIDTH,
+ height: ROW_MIN_HEIGHT,
+};
+const CODE_CONTAINER_STYLE: ViewStyle = { paddingVertical: VERTICAL_PADDING };
+const CODE_BASE_STYLE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 12,
+ lineHeight: LINE_HEIGHT,
+};
+const GUTTER_TEXT_BASE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 11,
+ lineHeight: LINE_HEIGHT,
+};
+const NO_NEWLINE_BASE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 11,
+ lineHeight: LINE_HEIGHT,
+};
+
+type DiffLineProps = {
+ line: ParsedDiffLine;
+ language: string | null;
+ keyId: string;
+ /** When set, the whole row is pressable; pressing invokes the handler. */
+ onTap?: () => void;
+ /** When true, the row is painted with the selection focus ring. */
+ isSelected?: boolean;
+};
+
+function gutterTextFor(line: ParsedDiffLine): string {
+ if (line.type === 'add') {
+ return `${line.newLine ?? ''}`;
+ }
+ if (line.type === 'del') {
+ return `${line.oldLine ?? ''}`;
+ }
+ return `${line.oldLine ?? line.newLine ?? ''}`;
+}
+
+function rowBackgroundFor(type: ParsedDiffLine['type']): string {
+ if (type === 'add') {
+ return 'bg-good-tile-bg';
+ }
+ if (type === 'del') {
+ return 'bg-danger-tile-bg';
+ }
+ return 'bg-transparent';
+}
+
+function DiffLineImpl({ line, language, onTap, isSelected }: Readonly) {
+ const colors = useThemeColors();
+ const isDark = colors.background === '#0E0E10';
+
+ const tokens = useMemo(
+ () => highlightLine(line.text, language),
+ [language, line.text]
+ );
+
+ const gutterText = gutterTextFor(line);
+ const rowBackground = rowBackgroundFor(line.type);
+ const gutterColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light;
+ const noNewlineColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light;
+ const noNewlineLabel = ` ${NO_NEWLINE_INDICATOR}`;
+
+ // Selection ring: painted as a thick left border in the primary color
+ // (works on both add/del/context backgrounds). Concrete Tailwind color
+ // is required — CSS-var opacity modifiers don't work on theme tokens.
+ const selectionClass = isSelected ? 'border-l-2 border-primary' : 'border-l-2 border-transparent';
+
+ const content = (
+
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for gutter */}
+
+ {gutterText}
+
+
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme color + mono font for code */}
+
+ {tokens.map((token, index) => {
+ const tokenColor = tokenColorFor(token.className, isDark);
+ return (
+ // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- per-token syntax color
+
+ {token.text}
+
+ );
+ })}
+ {line.noNewlineAtEndOfFile ? (
+ // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic muted color for no-newline marker
+ {noNewlineLabel}
+ ) : null}
+
+
+
+ );
+
+ if (!onTap) {
+ return content;
+ }
+ return (
+
+ {content}
+
+ );
+}
+
+export const DiffLine = memo(
+ DiffLineImpl,
+ (prev, next) =>
+ prev.keyId === next.keyId &&
+ prev.language === next.language &&
+ prev.line === next.line &&
+ prev.onTap === next.onTap &&
+ prev.isSelected === next.isSelected
+);
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx
new file mode 100644
index 0000000000..83384b0ccc
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-header.tsx
@@ -0,0 +1,172 @@
+// Compact header row for the PR Files tab. Shown above the FlashList
+// with two responsibilities:
+// 1. Navigator entry: "Files · n of m viewed" pressable that opens
+// the file-navigator sheet route. Always visible.
+// 2. Tablet layout toggle: unified vs side-by-side. Only on tablets
+// (the `useIsTablet` hook gates it). The selection is local
+// component state — not persisted across mounts.
+//
+// Phones see the navigator entry only. Tablets see both.
+
+import { type Href, useRouter } from 'expo-router';
+import { Columns2, Rows3 } from 'lucide-react-native';
+import { useCallback, useMemo, useState } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { useIsTablet } from '@/lib/hooks/use-is-tablet';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { cn } from '@/lib/utils';
+
+type PrDiffFileListHeaderProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ readonly viewedCount: number;
+ readonly totalListed: number;
+ readonly isTruncated: boolean;
+ readonly viewMode: DiffViewMode;
+ readonly onViewModeChange: (mode: DiffViewMode) => void;
+};
+
+const FILE_NAVIGATOR_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/file-navigator' as const;
+
+export function PrDiffFileListHeader({
+ owner,
+ repo,
+ number,
+ viewedCount,
+ totalListed,
+ isTruncated,
+ viewMode,
+ onViewModeChange,
+}: PrDiffFileListHeaderProps) {
+ const router = useRouter();
+ const isTablet = useIsTablet();
+ const colors = useThemeColors();
+
+ const navigatorHref = useMemo(
+ () => ({ pathname: FILE_NAVIGATOR_PATH, params: { owner, repo, number } }),
+ [owner, repo, number]
+ );
+
+ const handleOpenNavigator = useCallback(() => {
+ router.push(navigatorHref);
+ }, [router, navigatorHref]);
+
+ return (
+
+
+
+
+ Files · {viewedCount.toLocaleString()} of {totalListed.toLocaleString()} viewed
+
+ {isTruncated ? (
+
+ (listed)
+
+ ) : null}
+
+ {isTablet ? : null}
+
+ );
+}
+
+function ViewModeToggle({
+ viewMode,
+ onChange,
+}: {
+ viewMode: DiffViewMode;
+ onChange: (mode: DiffViewMode) => void;
+}) {
+ const colors = useThemeColors();
+ return (
+
+ {
+ onChange('unified');
+ }}
+ testId="diff-mode-unified"
+ colors={colors}
+ />
+ {
+ onChange('side-by-side');
+ }}
+ testId="diff-mode-side-by-side"
+ colors={colors}
+ />
+
+ );
+}
+
+function ViewModeButton({
+ active,
+ label,
+ onPress,
+ testId,
+ colors,
+}: {
+ active: boolean;
+ label: string;
+ onPress: () => void;
+ testId: string;
+ colors: ReturnType;
+}) {
+ return (
+
+
+
+ {label}
+
+
+ );
+}
+
+/**
+ * Convenience hook for the diff list screen: returns the current view
+ * mode plus a setter. Default is `unified`. The toggle is local state
+ * (not persisted) per the S6c spec.
+ */
+export function useDiffViewMode(): {
+ viewMode: DiffViewMode;
+ setViewMode: (mode: DiffViewMode) => void;
+} {
+ const [viewMode, setViewMode] = useState('unified');
+ return { viewMode, setViewMode };
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx
new file mode 100644
index 0000000000..00ee8a5125
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list-render.tsx
@@ -0,0 +1,186 @@
+// `renderItem` for the PR diff FlashList. Extracted out of
+// `pr-diff-file-list.tsx` so that file stays under the max-lines
+// limit. Receives the full set of state needed to switch on item kind
+// and dispatch to the right row component.
+
+import { useCallback } from 'react';
+
+import { DiffLine } from '@/components/pr-review/diff/diff-line';
+import {
+ ExpandSeparatorRow,
+ FileHeaderRow,
+ HunkHeaderRow,
+ PaginationRow,
+ PatchMissingRow,
+ TruncationBannerRow,
+} from '@/components/pr-review/diff/pr-diff-rows';
+import {
+ HunkSideBySideHeader,
+ SideBySideRow,
+} from '@/components/pr-review/diff/pr-diff-side-by-side-row';
+import { type ExpandSeparatorItem, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { type ParsedHunk } from '@/lib/pr-review/diff/parse-patch';
+import { sideForDiffLineType } from '@/lib/pr-review/diff-selection';
+import {
+ type FetchToCompletionResult,
+ type UsePrReviewFileListQueryResult,
+} from '@/lib/pr-review/diff/pr-review-file-list-state';
+
+type UseDiffRenderItemArgs = {
+ viewed: {
+ isViewed: (path: string) => boolean;
+ toggle: (path: string) => Promise;
+ };
+ query: UsePrReviewFileListQueryResult['query'];
+ fetchToCompletion: FetchToCompletionResult;
+ handleLoadContext: (item: ExpandSeparatorItem, windowSize: number) => void;
+ setExpanded: React.Dispatch>>;
+ /** Producer-side tap handler. Receives the parsed data needed to run
+ * the diff-selection reducer. S7a wires this from `PrDiffFileList`. */
+ onLineTap: (args: LineTapArgs) => void;
+ /** `null` when no selection; otherwise the current selection range. */
+ selection: SelectionView;
+};
+
+/** Lightweight view of the current selection — what the rows need to
+ * decide whether to paint the focus ring. The full `DiffSelection`
+ * (incl. `selectedText`) is in the bridge, not here. */
+type SelectionView = {
+ filePath: string;
+ side: 'LEFT' | 'RIGHT';
+ startLine: number;
+ line: number;
+} | null;
+
+export type LineTapArgs = {
+ filePath: string;
+ hunkKey: string;
+ side: 'LEFT' | 'RIGHT';
+ line: number;
+ text: string;
+ /** The full hunk — the reducer needs the line-number → text map. */
+ hunk: ParsedHunk;
+};
+
+export function useDiffRenderItem({
+ viewed,
+ query,
+ fetchToCompletion,
+ handleLoadContext,
+ setExpanded,
+ onLineTap,
+ selection,
+}: UseDiffRenderItemArgs) {
+ return useCallback(
+ ({ item }: { item: ListItem }) => {
+ switch (item.kind) {
+ case 'truncation-banner': {
+ return ;
+ }
+ case 'file-header': {
+ return (
+ {
+ setExpanded(prev => ({ ...prev, [item.file.path]: !prev[item.file.path] }));
+ }}
+ onToggleViewed={() => {
+ void viewed.toggle(item.file.path);
+ }}
+ />
+ );
+ }
+ case 'file-patch-missing': {
+ return (
+ {
+ void viewed.toggle(item.file.path);
+ }}
+ />
+ );
+ }
+ case 'hunk-header': {
+ return ;
+ }
+ case 'hunk-side-by-side': {
+ return ;
+ }
+ case 'side-by-side-row': {
+ // Commenting is done from the unified view; side-by-side is read-only.
+ return ;
+ }
+ case 'diff-line': {
+ const parsedLine = item.line;
+ const side = sideForDiffLineType(parsedLine.type);
+ const lineNumber = side === 'LEFT' ? parsedLine.oldLine : parsedLine.newLine;
+ const hunk = item.parsed.hunks[item.hunkIndex];
+ const isSelectable = item.selectable !== false;
+ return (
+ {
+ onLineTap({
+ filePath: item.filePath,
+ hunkKey: `${item.filePath}:${item.hunkIndex}`,
+ side,
+ line: lineNumber,
+ text: parsedLine.text,
+ hunk,
+ });
+ }
+ : undefined
+ }
+ isSelected={
+ selection !== null &&
+ selection.filePath === item.filePath &&
+ selection.side === side &&
+ typeof lineNumber === 'number' &&
+ lineNumber >= selection.startLine &&
+ lineNumber <= selection.line
+ }
+ />
+ );
+ }
+ case 'expand-separator': {
+ return (
+ {
+ handleLoadContext(item, windowSize);
+ }}
+ />
+ );
+ }
+ case 'pagination-row': {
+ return (
+ {
+ void query.fetchNextPage();
+ }}
+ onFetchAll={() => {
+ void fetchToCompletion.run();
+ }}
+ />
+ );
+ }
+ default: {
+ return null;
+ }
+ }
+ },
+ [viewed, query, fetchToCompletion, handleLoadContext, setExpanded, onLineTap, selection]
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx
new file mode 100644
index 0000000000..4e21e556a6
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-list.tsx
@@ -0,0 +1,285 @@
+// PR diff viewer core: the file list that the orchestrator drops into
+// `pr-review-files-tab.tsx` (replacing the S5 placeholder body).
+//
+// Architecture:
+// * A single FlashList with mixed item kinds (see `pr-diff-list-items`)
+// * `usePrReviewFileListQuery` drives a tRPC infinite query for `listFiles`
+// * `usePrReviewViewedFiles` reads + toggles the per-PR viewed set
+// * `useFetchToCompletion` lets S6c's navigator drive the query to its end
+// * `subscribeFileNavigatorRequest` is consumed here so a "scroll to file"
+// request (emitted by S6c) snaps the list to the right section
+// * S7a adds diff-line selection: tapping a line runs the pure
+// `selectLine` reducer; the result is mirrored into the
+// `diff-selection-bridge` (so the comment composer can read it on
+// mount) and a floating action bar (`PrDiffFloatingActions`)
+// hosts the "Comment" and "Finish review" affordances.
+
+import { FlashList, type FlashListRef } from '@shopify/flash-list';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { View } from 'react-native';
+
+import { QueryError } from '@/components/query-error';
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import {
+ PrDiffFileListHeader,
+ useDiffViewMode,
+} from '@/components/pr-review/diff/pr-diff-file-list-header';
+import { PrDiffFloatingActions } from '@/components/pr-review/diff/pr-diff-floating-actions';
+import { useDiffRenderItem } from '@/components/pr-review/diff/pr-diff-file-list-render';
+import { useDiffSelection } from '@/components/pr-review/diff/use-diff-selection';
+import {
+ EmptyFilesView,
+ LIST_CONTENT_STYLE,
+ TabStateMessage,
+} from '@/components/pr-review/diff/pr-diff-rows';
+import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder';
+import { fileHeaderKey, itemTypeFor, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { usePrDiffContextLoader } from '@/lib/pr-review/diff/use-pr-diff-context-loader';
+import {
+ useFetchToCompletion,
+ usePrReviewFileListQuery,
+ usePrReviewViewedFiles,
+} from '@/lib/pr-review/diff/pr-review-file-list-state';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge';
+import {
+ type FileNavigatorRequest,
+ subscribeFileNavigatorRequest,
+} from '@/lib/pr-review/file-navigator-bridge';
+import { useIsTablet } from '@/lib/hooks/use-is-tablet';
+
+type PrReviewFileListProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ readonly headSha: string;
+ readonly changedFiles: number;
+ /** Optional callback for the 0-changed-files empty state. */
+ readonly onRequestOverview?: () => void;
+};
+
+export function PrReviewFileList({
+ owner,
+ repo,
+ number,
+ headSha,
+ changedFiles,
+ onRequestOverview,
+}: PrReviewFileListProps) {
+ const listRef = useRef>(null);
+
+ const { query, firstPageErrorState } = usePrReviewFileListQuery({
+ owner,
+ repo,
+ number,
+ enabled: true,
+ });
+ const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha);
+ const fetchToCompletion = useFetchToCompletion(query, changedFiles);
+
+ const [expanded, setExpanded] = useState>({});
+ const { expandedContext, handleLoadContext } = usePrDiffContextLoader({
+ owner,
+ repo,
+ headSha,
+ });
+ const { viewMode, setViewMode } = useDiffViewMode();
+ const isTablet = useIsTablet();
+ const { selection, selectionView, handleLineTap, clearSelection } = useDiffSelection({
+ owner,
+ repo,
+ number,
+ viewMode,
+ isTablet,
+ });
+
+ // When the component unmounts (user navigates away from the PR or
+ // pops the PR screen off the stack), drop the bridge so a stale
+ // selection can never leak into the next mount. Re-mounting this
+ // list always starts with no selection.
+ useEffect(() => clearDiffSelection, []);
+
+ const files = useMemo(() => {
+ const all: PrReviewFile[] = [];
+ for (const page of query.data?.pages ?? []) {
+ for (const f of page.files) {
+ all.push(f);
+ }
+ }
+ return all;
+ }, [query.data]);
+
+ const viewedCount = useMemo(() => {
+ let count = 0;
+ for (const file of files) {
+ if (viewed.isViewed(file.path)) {
+ count += 1;
+ }
+ }
+ return count;
+ }, [files, viewed]);
+
+ const items = useMemo(
+ () =>
+ buildItems({
+ files,
+ expanded,
+ expandedContext,
+ viewed: viewed.isViewed,
+ headSha,
+ owner,
+ repo,
+ number,
+ changedFiles,
+ isLoading: query.isLoading,
+ isFetchingNextPage: query.isFetchingNextPage,
+ hasNextPage: query.hasNextPage,
+ laterPageError: query.isError && files.length > 0,
+ fetchToCompletionRunning: fetchToCompletion.isRunning,
+ fetchToCompletionLoaded: fetchToCompletion.loadedFiles,
+ totalFiles: changedFiles,
+ viewMode: isTablet ? viewMode : 'unified',
+ }),
+ [
+ files,
+ expanded,
+ expandedContext,
+ viewed,
+ headSha,
+ owner,
+ repo,
+ number,
+ changedFiles,
+ query.isLoading,
+ query.isFetchingNextPage,
+ query.hasNextPage,
+ query.isError,
+ fetchToCompletion.isRunning,
+ fetchToCompletion.loadedFiles,
+ viewMode,
+ isTablet,
+ ]
+ );
+
+ const indexByKey = useMemo(() => {
+ const map = new Map();
+ for (let index = 0; index < items.length; index += 1) {
+ const item = items[index];
+ if (item) {
+ map.set(item.key, index);
+ }
+ }
+ return map;
+ }, [items]);
+ const indexByKeyRef = useRef(indexByKey);
+ indexByKeyRef.current = indexByKey;
+
+ useEffect(() => {
+ const unsubscribe = subscribeFileNavigatorRequest(
+ { owner, repo, number },
+ (request: FileNavigatorRequest) => {
+ const targetKey = fileHeaderKey(request.path);
+ const index = indexByKeyRef.current.get(targetKey);
+ if (typeof index === 'number' && index !== -1) {
+ setExpanded(prev => (prev[request.path] ? prev : { ...prev, [request.path]: true }));
+ void listRef.current?.scrollToIndex({ index, animated: true, viewPosition: 0 });
+ }
+ }
+ );
+ return unsubscribe;
+ }, [owner, repo, number]);
+
+ const renderItem = useDiffRenderItem({
+ viewed,
+ query,
+ fetchToCompletion,
+ handleLoadContext,
+ setExpanded,
+ onLineTap: handleLineTap,
+ selection: selectionView,
+ });
+
+ if (files.length === 0) {
+ if (firstPageErrorState?.kind === 'not-found') {
+ return (
+
+ );
+ }
+ if (firstPageErrorState?.kind === 'permission') {
+ return (
+
+ );
+ }
+ if (firstPageErrorState?.kind === 'reconnect') {
+ return (
+
+
+
+ );
+ }
+ if (firstPageErrorState?.kind === 'retryable') {
+ return (
+
+ {
+ void query.refetch();
+ }}
+ isRetrying={query.isFetching}
+ />
+
+ );
+ }
+ }
+
+ if (!query.isLoading && files.length === 0) {
+ return ;
+ }
+
+ const isTruncated = query.hasNextPage || Boolean(fetchToCompletion.error);
+ const effectiveViewMode = isTablet ? viewMode : 'unified';
+
+ return (
+
+
+ item.key}
+ getItemType={item => itemTypeFor(item)}
+ onEndReached={() => {
+ if (query.hasNextPage && !query.isFetchingNextPage) {
+ void query.fetchNextPage();
+ }
+ }}
+ onEndReachedThreshold={0.5}
+ contentContainerStyle={LIST_CONTENT_STYLE}
+ ItemSeparatorComponent={null}
+ />
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx
new file mode 100644
index 0000000000..e412886090
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-navigator.tsx
@@ -0,0 +1,313 @@
+// File navigator sheet content. The orchestrator mounts this inside the
+// `/(app)/pr-review/[owner]/[repo]/[number]/file-navigator` route file
+// (which still shows the S4b stub until the orchestrator wires this
+// component in at the barrier).
+//
+// Responsibilities:
+// - share `usePrReviewFileListQuery` with the mounted Files tab so
+// react-query dedupes by key and the navigator and the file list
+// stay in sync
+// - drive `useFetchToCompletion(...).run()` on mount so the full
+// listed file set is available for search/jump
+// - render a search input (uncontrolled per iOS rules: ref +
+// onChangeText, no `value`) and a list of file rows
+// - on tap, `requestScrollToFile(...)` and dismiss
+// - render the four states: loading, retryable (fetch-to-completion
+// error), empty (0 listed files), happy
+
+import { useRouter } from 'expo-router';
+import { Search } from 'lucide-react-native';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { NavigatorFileRow } from '@/components/pr-review/diff/pr-diff-navigator-file-row';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { requestScrollToFile } from '@/lib/pr-review/file-navigator-bridge';
+import {
+ useFetchToCompletion,
+ usePrReviewFileListQuery,
+ usePrReviewViewedFiles,
+} from '@/lib/pr-review/diff/pr-review-file-list-state';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+type PrDiffFileNavigatorProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ readonly headSha: string;
+ /** Overview `changedFiles` count: the authoritative total for progress + truncation. */
+ readonly changedFiles: number;
+ readonly onDismiss?: () => void;
+};
+
+function filterFiles(files: PrReviewFile[], query: string): PrReviewFile[] {
+ const needle = query.trim().toLowerCase();
+ if (needle.length === 0) {
+ return files;
+ }
+ return files.filter(file => file.path.toLowerCase().includes(needle));
+}
+
+function countViewed(files: PrReviewFile[], isViewed: (path: string) => boolean): number {
+ let count = 0;
+ for (const file of files) {
+ if (isViewed(file.path)) {
+ count += 1;
+ }
+ }
+ return count;
+}
+
+export function PrDiffFileNavigator({
+ owner,
+ repo,
+ number,
+ headSha,
+ changedFiles,
+ onDismiss,
+}: PrDiffFileNavigatorProps) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const searchRef = useRef('');
+ // Re-render trigger when the uncontrolled search field changes — refs
+ // alone don't cause re-renders, but we don't want to re-mount the
+ // TextInput on every keystroke (per iOS rule), so the value lives in
+ // a ref and a version counter drives the filtered list.
+ const [searchVersion, setSearchVersion] = useState(0);
+ const inputRef = useRef(null);
+
+ const { query, firstPageErrorState } = usePrReviewFileListQuery({
+ owner,
+ repo,
+ number,
+ enabled: true,
+ });
+ const viewed = usePrReviewViewedFiles({ owner, repo, number }, headSha);
+ const fetchAll = useFetchToCompletion(query, changedFiles);
+
+ // Drive the query to completion so search/navigation cover the full listed
+ // set. `run()` no-ops while the first page is in flight, so re-run it
+ // reactively once the query becomes eligible (first page settled, more pages
+ // remain), and stop once complete or after a surfaced error (the user can
+ // then tap the "Failed to load all files" retry to resume).
+ const runRef = useRef(fetchAll.run);
+ runRef.current = fetchAll.run;
+ useEffect(() => {
+ if (!query.isFetching && query.hasNextPage && !fetchAll.isRunning && !fetchAll.error) {
+ void runRef.current();
+ }
+ }, [query.isFetching, query.hasNextPage, fetchAll.isRunning, fetchAll.error]);
+
+ const files = useMemo(() => {
+ const all: PrReviewFile[] = [];
+ for (const page of query.data?.pages ?? []) {
+ for (const f of page.files) {
+ all.push(f);
+ }
+ }
+ return all;
+ }, [query.data]);
+
+ const filtered = useMemo(
+ () => filterFiles(files, searchRef.current),
+ // `searchVersion` is the only thing that signals "the ref changed",
+ // so it has to be in the dep list even though `files` is the only
+ // real data input.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [files, searchVersion]
+ );
+
+ const viewedCount = useMemo(() => countViewed(files, viewed.isViewed), [files, viewed]);
+
+ const handleSelectFile = (path: string) => {
+ requestScrollToFile({ owner, repo, number, path });
+ if (onDismiss) {
+ onDismiss();
+ return;
+ }
+ if (router.canGoBack()) {
+ router.back();
+ }
+ };
+
+ if (firstPageErrorState?.kind === 'not-found') {
+ return (
+
+
+ Pull request unavailable
+
+ This PR can't be opened. It may have been deleted, the repository is private, or the
+ Kilo GitHub App isn't installed on it.
+
+
+
+ );
+ }
+ if (firstPageErrorState?.kind === 'permission') {
+ return (
+
+
+ Access denied
+
+ You don't have permission to view this pull request.
+
+
+
+ );
+ }
+ if (firstPageErrorState?.kind === 'retryable' || firstPageErrorState?.kind === 'reconnect') {
+ return (
+
+
+ Couldn't load files
+
+ Check your connection and try again.
+
+ {
+ void query.refetch();
+ }}
+ className="mt-1 rounded-md border border-border bg-card px-4 py-2 active:opacity-70"
+ accessibilityRole="button"
+ accessibilityLabel="Retry loading files"
+ >
+ Retry
+
+
+
+ );
+ }
+
+ if (query.isLoading && files.length === 0) {
+ return (
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4].map(index => (
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+ }
+
+ if (!query.isLoading && files.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ const showLoadAllRetry = Boolean(fetchAll.error) && !fetchAll.isRunning && query.hasNextPage;
+ // Truncated when pagination hasn't finished, errored, or GitHub's 3,000-file
+ // listing cap left fewer listed files than the overview's changed-file count.
+ const isTruncated = query.hasNextPage || Boolean(fetchAll.error) || changedFiles > files.length;
+
+ return (
+
+
+
+ {
+ searchRef.current = value;
+ setSearchVersion(version => version + 1);
+ }}
+ className="flex-1 text-sm leading-5 text-foreground"
+ returnKeyType="search"
+ autoCorrect={false}
+ autoCapitalize="none"
+ clearButtonMode="while-editing"
+ />
+
+
+
+
+ {viewedCount.toLocaleString()} of {files.length.toLocaleString()} viewed
+ {isTruncated ? ' of listed files' : ''}
+
+ {fetchAll.isRunning ? (
+
+
+
+ Loading {fetchAll.loadedFiles.toLocaleString()} of {changedFiles.toLocaleString()}…
+
+
+ ) : null}
+
+
+ {showLoadAllRetry ? (
+
+ Failed to load all files
+ {
+ void fetchAll.run();
+ }}
+ className="rounded-md border border-border bg-card px-3 py-1 active:opacity-70"
+ accessibilityRole="button"
+ accessibilityLabel="Retry loading all files"
+ >
+ Retry
+
+
+ ) : null}
+
+
+ {filtered.length === 0 ? (
+
+
+ No files match "{searchRef.current}"
+
+
+ ) : null}
+ {filtered.map(file => (
+ {
+ handleSelectFile(file.path);
+ }}
+ onToggleViewed={() => {
+ void viewed.toggle(file.path);
+ }}
+ />
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx
new file mode 100644
index 0000000000..f12011af89
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-rows.tsx
@@ -0,0 +1,142 @@
+// File-level row components for the PR diff FlashList.
+
+import { ChevronDown, ChevronRight, File, Link2 } from 'lucide-react-native';
+import { Pressable, View } from 'react-native';
+
+import { fileStatusIcon, fileStatusLabel } from '@/components/pr-review/diff/pr-diff-file-status';
+import { ChoiceRow } from '@/components/ui/choice-row';
+import { Text } from '@/components/ui/text';
+import { openExternalUrl } from '@/lib/external-link';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+
+function ExpandChevron({ hasDiff, expanded }: { hasDiff: boolean; expanded: boolean }) {
+ const colors = useThemeColors();
+ if (!hasDiff) {
+ return ;
+ }
+ if (expanded) {
+ return ;
+ }
+ return ;
+}
+
+export function TruncationBannerRow({ text }: { text: string }) {
+ return (
+
+ {text}
+
+ );
+}
+
+export function FileHeaderRow({
+ file,
+ expanded,
+ hasDiff,
+ viewed,
+ onToggleExpand,
+ onToggleViewed,
+}: {
+ file: PrReviewFile;
+ expanded: boolean;
+ hasDiff: boolean;
+ viewed: boolean;
+ onToggleExpand: () => void;
+ onToggleViewed: () => void;
+}) {
+ const colors = useThemeColors();
+ const StatusIcon = fileStatusIcon(file.status);
+ const isRename = Boolean(file.previousPath) && file.previousPath !== file.path;
+ const pathLine = isRename ? `${file.previousPath} → ${file.path}` : file.path;
+
+ return (
+
+
+
+
+
+
+
+
+ {pathLine}
+
+
+
+ {fileStatusLabel(file.status)}
+
+
+ +{file.additions}
+
+
+ -{file.deletions}
+
+
+
+
+
+
+ {viewed ? 'Viewed' : 'Mark viewed'}
+
+
+
+
+
+ );
+}
+
+export function PatchMissingRow({
+ file,
+ viewed,
+ githubUrl,
+ onToggleViewed,
+}: {
+ file: PrReviewFile;
+ viewed: boolean;
+ githubUrl: string;
+ onToggleViewed: () => void;
+}) {
+ const colors = useThemeColors();
+ return (
+
+
+
+
+ Diff too large to display
+
+ {file.path}
+
+
+
+
+
+ {viewed ? 'Viewed' : 'Mark viewed'}
+
+
+
+
+ {
+ if (githubUrl) {
+ void openExternalUrl(githubUrl, { label: 'GitHub diff' });
+ }
+ }}
+ accessibilityRole="link"
+ accessibilityLabel="Open this file's diff on GitHub"
+ className="mt-2 flex-row items-center gap-1.5 self-start"
+ >
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */}
+
+ Open on GitHub
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts b/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts
new file mode 100644
index 0000000000..d1c7c433fd
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-file-status.ts
@@ -0,0 +1,43 @@
+// Shared status helpers for PR file rows.
+
+import { File, FileMinus, FilePlus } from 'lucide-react-native';
+
+export function fileStatusLabel(status: string): string {
+ switch (status) {
+ case 'added': {
+ return 'Added';
+ }
+ case 'removed': {
+ return 'Deleted';
+ }
+ case 'modified': {
+ return 'Modified';
+ }
+ case 'renamed': {
+ return 'Renamed';
+ }
+ case 'copied': {
+ return 'Copied';
+ }
+ case 'changed': {
+ return 'Changed';
+ }
+ default: {
+ return status;
+ }
+ }
+}
+
+export function fileStatusIcon(status: string) {
+ switch (status) {
+ case 'added': {
+ return FilePlus;
+ }
+ case 'removed': {
+ return FileMinus;
+ }
+ default: {
+ return File;
+ }
+ }
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx
new file mode 100644
index 0000000000..1c7ceb12b0
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-floating-actions.tsx
@@ -0,0 +1,141 @@
+// Floating action bar rendered over the PR diff FlashList. Hosts:
+// - The "Comment" affordance that pushes the comment-composer route
+// when a diff-line selection exists, plus a "Clear" button that
+// drops the selection.
+// - The "Finish review" button shown when the pending review queue
+// is non-empty, which pushes the review-submit route.
+//
+// Extracted from `pr-diff-file-list.tsx` to keep that file under the
+// 300-line repo cap.
+
+import { type Href, useRouter } from 'expo-router';
+import { MessageCirclePlus } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { clearDiffSelection } from '@/lib/pr-review/diff-selection-bridge';
+import { type SelectionState } from '@/lib/pr-review/diff-selection';
+import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { usePendingReview } from '@/lib/pr-review/pending-review-provider';
+import { cn } from '@/lib/utils';
+
+const COMMENT_COMPOSER_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/comment-composer' as const;
+const REVIEW_SUBMIT_PATH = '/(app)/pr-review/[owner]/[repo]/[number]/review-submit' as const;
+
+type PrDiffFloatingActionsProps = Readonly<{
+ owner: string;
+ repo: string;
+ number: number;
+ /** Unified (default) or side-by-side (tablet only). */
+ viewMode: DiffViewMode;
+ /** `null` when no selection exists. Drives the "Comment" affordance. */
+ selection: SelectionState | null;
+ /** Setter for the parent's selection state — `null` clears. */
+ onClearSelection: () => void;
+}>;
+
+export function PrDiffFloatingActions({
+ owner,
+ repo,
+ number,
+ viewMode,
+ selection,
+ onClearSelection,
+}: PrDiffFloatingActionsProps) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const pending = usePendingReview();
+
+ const showSelectionAction = viewMode === 'unified' && selection !== null;
+ const showFinishReview = pending.items.length > 0;
+ if (!showSelectionAction && !showFinishReview) {
+ return null;
+ }
+
+ function openCommentComposer() {
+ if (!selection) {
+ return;
+ }
+ const href: Href = {
+ pathname: COMMENT_COMPOSER_PATH,
+ params: {
+ owner,
+ repo,
+ number,
+ path: selection.path,
+ side: selection.side,
+ line: selection.line,
+ ...(selection.startLine !== selection.line ? { startLine: selection.startLine } : {}),
+ },
+ };
+ router.push(href);
+ }
+
+ function openReviewSubmit() {
+ const href: Href = {
+ pathname: REVIEW_SUBMIT_PATH,
+ params: { owner, repo, number },
+ };
+ router.push(href);
+ }
+
+ return (
+
+
+ {showSelectionAction ? (
+
+
+ {selectionDescription(selection)}
+
+
+
+
+ ) : null}
+ {showFinishReview ? (
+
+ ) : null}
+
+
+ );
+}
+
+function selectionDescription(selection: SelectionState): string {
+ const range =
+ selection.startLine === selection.line
+ ? `L${selection.startLine}`
+ : `L${selection.startLine}–L${selection.line}`;
+ return `${selection.path} ${selection.side} ${range}`;
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx
new file mode 100644
index 0000000000..8372220d02
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-hunk-rows.tsx
@@ -0,0 +1,252 @@
+// Hunk / expand / pagination / empty-state rows for the PR diff FlashList.
+
+import { Check, ChevronDown, File, GitCommit, X } from 'lucide-react-native';
+import { Pressable, View, type ViewStyle } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { type ExpandSeparatorItem } from '@/lib/pr-review/diff/pr-diff-list-items';
+
+const DEFAULT_EXPAND_WINDOW = 20;
+const EXPAND_ALL_MAX = 100;
+
+// Module-level style constants so FlashList content containers avoid
+// recreating object literals (and so no-inline-styles is satisfied).
+export const LIST_CONTENT_STYLE: ViewStyle = { paddingBottom: 24 };
+
+export function HunkHeaderRow({ header }: { header: string }) {
+ const colors = useThemeColors();
+ return (
+
+
+ {header}
+
+
+ );
+}
+
+export function ExpandSeparatorRow({
+ item,
+ onLoad,
+}: {
+ item: ExpandSeparatorItem;
+ onLoad: (windowSize: number) => void;
+}) {
+ const colors = useThemeColors();
+ const { startLine, endLine } = item.context;
+ const isUnknownEnd = !Number.isFinite(endLine);
+ const gapSize = isUnknownEnd ? DEFAULT_EXPAND_WINDOW : endLine - startLine + 1;
+ const canExpandAll = !isUnknownEnd && gapSize <= EXPAND_ALL_MAX;
+ const isPartial = item.state === 'partial';
+
+ if (item.state === 'unavailable') {
+ return (
+
+
+
+ Context unavailable at this ref
+
+
+ );
+ }
+
+ if (item.state === 'error') {
+ return (
+ {
+ onLoad(DEFAULT_EXPAND_WINDOW);
+ }}
+ className="flex-row items-center justify-center gap-2 border-y border-hair-soft bg-secondary px-4 py-2 active:opacity-70"
+ accessibilityRole="button"
+ accessibilityLabel="Retry loading context"
+ >
+
+ Failed to load context — tap to retry
+
+
+ );
+ }
+
+ if (item.state === 'loading') {
+ return (
+
+
+
+ {isUnknownEnd
+ ? 'Loading context…'
+ : `Loading ${Math.min(gapSize, DEFAULT_EXPAND_WINDOW)} of ${gapSize} lines…`}
+
+
+ );
+ }
+
+ const windowEnd = isUnknownEnd
+ ? startLine + DEFAULT_EXPAND_WINDOW - 1
+ : Math.min(startLine + DEFAULT_EXPAND_WINDOW - 1, endLine);
+ const expandLabel = isPartial ? 'Expand more' : 'Expand';
+
+ return (
+
+ {
+ onLoad(DEFAULT_EXPAND_WINDOW);
+ }}
+ className="flex-row items-center gap-1 active:opacity-70"
+ accessibilityRole="button"
+ accessibilityLabel={
+ isUnknownEnd ? 'Expand context' : `Expand ${DEFAULT_EXPAND_WINDOW} lines of context`
+ }
+ >
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */}
+
+ {isUnknownEnd
+ ? `${expandLabel} context`
+ : `${expandLabel} ${Math.min(gapSize, DEFAULT_EXPAND_WINDOW)} lines (${startLine}–${windowEnd})`}
+
+
+ {canExpandAll ? (
+ {
+ onLoad(gapSize);
+ }}
+ className="ml-3 flex-row items-center gap-1 active:opacity-70"
+ accessibilityRole="button"
+ accessibilityLabel={`Expand all ${gapSize} lines`}
+ >
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme info color */}
+
+ Expand all
+
+
+ ) : null}
+
+ );
+}
+
+export function PaginationRow({
+ state,
+ loadedFiles,
+ totalFiles,
+ onRetry,
+ onFetchAll,
+}: {
+ state: 'loading' | 'error' | 'fetch-to-completion' | 'all-loaded' | 'no-pages';
+ loadedFiles: number;
+ totalFiles: number | null;
+ onRetry: () => void;
+ onFetchAll: () => void;
+}) {
+ const colors = useThemeColors();
+ if (state === 'loading') {
+ return (
+
+
+ Loading more files…
+
+
+ );
+ }
+ if (state === 'error') {
+ return (
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic destructive color */}
+
+ Failed to load more files — tap to retry
+
+
+ );
+ }
+ if (state === 'fetch-to-completion') {
+ return (
+
+
+ Loading all files — {loadedFiles.toLocaleString()}
+ {totalFiles ? ` of ${totalFiles.toLocaleString()}` : ''}…
+
+
+ );
+ }
+ if (state === 'no-pages') {
+ return (
+
+
+ {loadedFiles.toLocaleString()} of {totalFiles?.toLocaleString() ?? '?'} files loaded
+
+
+ Load all
+
+
+ );
+ }
+ return (
+
+
+
+ {loadedFiles.toLocaleString()} file{loadedFiles === 1 ? '' : 's'} loaded
+ {totalFiles ? ` of ${totalFiles.toLocaleString()}` : ''}
+
+
+ );
+}
+
+export function TabStateMessage({ title, message }: { title: string; message: string }) {
+ return (
+
+ {title}
+
+ {message}
+
+
+ );
+}
+
+export function EmptyFilesView({
+ changedFiles,
+ onRequestOverview,
+}: {
+ changedFiles: number;
+ onRequestOverview?: () => void;
+}) {
+ const colors = useThemeColors();
+ return (
+
+
+ No files changed
+
+ {changedFiles === 0
+ ? 'This pull request has no file changes.'
+ : 'Files are still loading. Pull to refresh.'}
+
+ {onRequestOverview ? (
+
+ Go to Overview
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx
new file mode 100644
index 0000000000..767da5608a
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-navigator-file-row.tsx
@@ -0,0 +1,97 @@
+// A single file row in the PR file navigator sheet. Tap to open the
+// file in the diff list (sends a `requestScrollToFile` request and
+// dismisses the sheet). A separate "Mark viewed" pressable toggles
+// the per-PR viewed set without dismissing.
+//
+// Owns no haptics — the row's tap is a navigation action, the viewed
+// toggle is a checkbox, and both flows already play the
+// system/keyboard sound the navigator sheet needs (the row tap goes
+// through the navigator which dismisses; the toggle is a deliberate
+// state change with a visible "Viewed" / "Mark viewed" label).
+
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+
+function splitPath(path: string): { dir: string; basename: string } {
+ const slash = path.lastIndexOf('/');
+ if (slash === -1) {
+ return { dir: '', basename: path };
+ }
+ return { dir: path.slice(0, slash + 1), basename: path.slice(slash + 1) };
+}
+
+export function NavigatorFileRow({
+ file,
+ viewed,
+ onSelect,
+ onToggleViewed,
+}: {
+ file: PrReviewFile;
+ viewed: boolean;
+ onSelect: () => void;
+ onToggleViewed: () => void;
+}) {
+ const colors = useThemeColors();
+ const { dir, basename } = splitPath(file.path);
+ return (
+
+
+
+
+ {dir.length > 0 ? (
+
+ {dir}
+
+ ) : null}
+
+ {basename}
+
+
+
+
+ +{file.additions}
+
+
+ -{file.deletions}
+
+ {file.patchMissing ? (
+
+ diff too large
+
+ ) : null}
+
+
+
+
+
+
+ {viewed ? 'Viewed' : 'Mark viewed'}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx
new file mode 100644
index 0000000000..d58f3a1909
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-rows.tsx
@@ -0,0 +1,16 @@
+// Re-export row components for the PR diff FlashList. Split across
+// sibling modules so each stays under the max-lines limit.
+
+export {
+ FileHeaderRow,
+ PatchMissingRow,
+ TruncationBannerRow,
+} from '@/components/pr-review/diff/pr-diff-file-rows';
+export {
+ EmptyFilesView,
+ ExpandSeparatorRow,
+ HunkHeaderRow,
+ LIST_CONTENT_STYLE,
+ PaginationRow,
+ TabStateMessage,
+} from '@/components/pr-review/diff/pr-diff-hunk-rows';
diff --git a/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx
new file mode 100644
index 0000000000..98c4e7eaf7
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/pr-diff-side-by-side-row.tsx
@@ -0,0 +1,250 @@
+// Side-by-side row component for the tablet PR diff view. Renders a
+// single `SideBySideRow` as two equal columns: the left column shows
+// the old/deleted/context line with its old line number; the right
+// column shows the new/added/context line with its new line number.
+// Either column may be empty (left blank with a placeholder) when the
+// pair is a pure add or pure del.
+//
+// Side-by-side is read-only — commenting is unified-view only — so the
+// row does not accept tap/selection handlers.
+//
+// Renders fixed-height rows so FlashList can virtualize without
+// remeasuring. The row height matches the unified `DiffLine` row so
+// mixed view-mode content (if the toggle changes mid-scroll) would
+// still fit a stable grid.
+
+import { memo, useMemo } from 'react';
+import { Text as RNText, type TextStyle, View, type ViewStyle } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { highlightLine, type HighlightToken } from '@/lib/pr-review/diff/highlight';
+import { type ParsedDiffLine, type ParsedHunk } from '@/lib/pr-review/diff/parse-patch';
+import { type SideBySideRow as SideBySideRowData } from '@/lib/pr-review/diff/side-by-side';
+import { MUTED_COLOR, tokenColorFor } from '@/lib/pr-review/diff/syntax-colors';
+import { cn } from '@/lib/utils';
+
+const LINE_HEIGHT = 18;
+const VERTICAL_PADDING = 2;
+const COLUMN_GUTTER_WIDTH = 56;
+const COLUMN_INNER_PADDING = 2;
+const NO_NEWLINE_INDICATOR = '\u26A0\uFE0F no newline at end of file';
+
+const ROW_MIN_HEIGHT = LINE_HEIGHT + VERTICAL_PADDING * 2;
+const ROW_STYLE: ViewStyle = { minHeight: ROW_MIN_HEIGHT };
+const GUTTER_STYLE: ViewStyle = {
+ width: COLUMN_GUTTER_WIDTH,
+ height: ROW_MIN_HEIGHT,
+};
+const CODE_CONTAINER_STYLE: ViewStyle = { paddingVertical: VERTICAL_PADDING };
+const CODE_BASE_STYLE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 12,
+ lineHeight: LINE_HEIGHT,
+};
+const GUTTER_TEXT_BASE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 11,
+ lineHeight: LINE_HEIGHT,
+};
+const NO_NEWLINE_BASE: TextStyle = {
+ fontFamily: 'JetBrainsMono_500Medium',
+ fontSize: 11,
+ lineHeight: LINE_HEIGHT,
+};
+
+type SideBySideRowProps = {
+ row: SideBySideRowData;
+ language: string | null;
+ rowKeyId: string;
+};
+
+function sideGutterText(line: ParsedDiffLine, side: 'left' | 'right'): string {
+ if (side === 'left') {
+ if (line.type === 'add') {
+ return '';
+ }
+ return `${line.oldLine ?? line.newLine ?? ''}`;
+ }
+ if (line.type === 'del') {
+ return '';
+ }
+ return `${line.newLine ?? line.oldLine ?? ''}`;
+}
+
+function rowBackgroundFor(type: ParsedDiffLine['type']): string {
+ if (type === 'add') {
+ return 'bg-good-tile-bg';
+ }
+ if (type === 'del') {
+ return 'bg-danger-tile-bg';
+ }
+ return 'bg-transparent';
+}
+
+type SideColumnProps = {
+ line: ParsedDiffLine;
+ side: 'left' | 'right';
+ language: string | null;
+ isDark: boolean;
+ foreground: string;
+};
+
+function SideColumnImpl({ line, side, language, isDark, foreground }: SideColumnProps) {
+ const tokens = useMemo(
+ () => highlightLine(line.text, language),
+ [language, line.text]
+ );
+ const gutterColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light;
+ const noNewlineColor = isDark ? MUTED_COLOR.dark : MUTED_COLOR.light;
+ const gutterText = sideGutterText(line, side);
+ const noNewlineLabel = line.noNewlineAtEndOfFile ? ` ${NO_NEWLINE_INDICATOR}` : '';
+
+ return (
+
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme muted color */}
+
+ {gutterText}
+
+
+
+ {/* eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic theme foreground color */}
+
+ {tokens.map((token, index) => {
+ const tokenColor = tokenColorFor(token.className, isDark);
+ return (
+ // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- per-token syntax color
+
+ {token.text}
+
+ );
+ })}
+ {noNewlineLabel ? (
+ // eslint-disable-next-line react-native/no-inline-styles, react-native/no-color-literals -- dynamic muted color for no-newline marker
+ {noNewlineLabel}
+ ) : null}
+
+
+
+ );
+}
+
+const SideColumn = memo(
+ SideColumnImpl,
+ (prev, next) =>
+ prev.line === next.line &&
+ prev.language === next.language &&
+ prev.side === next.side &&
+ // Include theme inputs so a light/dark switch re-renders the colors.
+ prev.isDark === next.isDark &&
+ prev.foreground === next.foreground
+);
+
+function EmptySideColumn() {
+ return (
+
+
+
+
+ );
+}
+
+function describeRow(row: SideBySideRowData): string {
+ if (row.left && row.right) {
+ return `Old: ${row.left.line.text} | New: ${row.right.line.text}`;
+ }
+ if (row.left) {
+ return `Old only: ${row.left.line.text}`;
+ }
+ if (row.right) {
+ return `New only: ${row.right.line.text}`;
+ }
+ return 'Empty diff row';
+}
+
+function SideBySideRowImpl({ row, language, rowKeyId }: Readonly) {
+ const colors = useThemeColors();
+ const isDark = colors.background === '#0E0E10';
+ const leftLine = row.left?.line ?? null;
+ const rightLine = row.right?.line ?? null;
+
+ return (
+
+ {leftLine ? (
+
+ ) : (
+
+ )}
+
+ {rightLine ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export const SideBySideRow = memo(
+ SideBySideRowImpl,
+ (prev, next) =>
+ prev.rowKeyId === next.rowKeyId && prev.language === next.language && prev.row === next.row
+);
+
+type HunkSideBySideHeaderProps = {
+ hunk: ParsedHunk;
+};
+
+export function HunkSideBySideHeader({ hunk }: Readonly) {
+ const colors = useThemeColors();
+ return (
+
+
+ {hunk.header}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts
new file mode 100644
index 0000000000..14946b59da
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/diff/use-diff-selection.ts
@@ -0,0 +1,121 @@
+// Selection state hook for the PR diff file list. Encapsulates the
+// reducer-driven line selection, the bridge mirror, the selection view
+// used for row highlight, and the side-by-side mode guard that clears
+// the selection. Extracted to keep `pr-diff-file-list.tsx` under the
+// max-lines limit.
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import { type LineTapArgs } from '@/components/pr-review/diff/pr-diff-file-list-render';
+import {
+ clearDiffSelection,
+ type DiffSelection,
+ setDiffSelection,
+} from '@/lib/pr-review/diff-selection-bridge';
+import { type SelectionState, selectLine } from '@/lib/pr-review/diff-selection';
+import { type DiffViewMode } from '@/lib/pr-review/diff/pr-diff-list-items';
+
+type UseDiffSelectionArgs = {
+ owner: string;
+ repo: string;
+ number: number;
+ viewMode: DiffViewMode;
+ isTablet: boolean;
+};
+
+type UseDiffSelectionResult = {
+ selection: SelectionState | null;
+ selectionView: SelectionView;
+ handleLineTap: (args: LineTapArgs) => void;
+ clearSelection: () => void;
+};
+
+type SelectionView = {
+ filePath: string;
+ side: 'LEFT' | 'RIGHT';
+ startLine: number;
+ line: number;
+} | null;
+
+export function useDiffSelection({
+ owner,
+ repo,
+ number,
+ viewMode,
+ isTablet,
+}: UseDiffSelectionArgs): UseDiffSelectionResult {
+ const [selection, setSelectionState] = useState(null);
+
+ // Selection/commenting is a unified-view interaction; switching to
+ // side-by-side drops any active selection so a stale unified
+ // selection doesn't leave the floating affordance up.
+ useEffect(() => {
+ if (isTablet && viewMode === 'side-by-side') {
+ setSelectionState(prev => {
+ if (prev) {
+ clearDiffSelection();
+ }
+ return null;
+ });
+ }
+ }, [isTablet, viewMode]);
+
+ // Diff-line tap producer: build the per-side line-number → text map
+ // for this hunk, run the reducer, mirror to the bridge, and store
+ // the result locally so the rows can render the focus ring.
+ const handleLineTap = useCallback(
+ (args: LineTapArgs) => {
+ const map = new Map();
+ for (const hunkLine of args.hunk.lines) {
+ const key = args.side === 'LEFT' ? hunkLine.oldLine : hunkLine.newLine;
+ if (typeof key === 'number') {
+ map.set(key, hunkLine.text);
+ }
+ }
+ setSelectionState(prev => {
+ const next = selectLine(
+ prev,
+ {
+ path: args.filePath,
+ side: args.side,
+ line: args.line,
+ hunkKey: args.hunkKey,
+ text: args.text,
+ },
+ map
+ );
+ const bridgeSelection: DiffSelection = {
+ owner,
+ repo,
+ number,
+ path: next.path,
+ side: next.side,
+ line: next.line,
+ ...(next.startLine !== next.line ? { startLine: next.startLine } : {}),
+ selectedText: next.selectedText,
+ };
+ setDiffSelection(bridgeSelection);
+ return next;
+ });
+ },
+ [owner, repo, number]
+ );
+
+ const selectionView: SelectionView = useMemo(() => {
+ if (!selection) {
+ return null;
+ }
+ return {
+ filePath: selection.path,
+ side: selection.side,
+ startLine: selection.startLine,
+ line: selection.line,
+ };
+ }, [selection]);
+
+ const clearSelection = useCallback(() => {
+ setSelectionState(null);
+ }, []);
+
+ return { selection, selectionView, handleLineTap, clearSelection };
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/comment-row.tsx b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx
new file mode 100644
index 0000000000..00effc3eba
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/comment-row.tsx
@@ -0,0 +1,67 @@
+// Single review-comment row: author block + Markdown body + reactions.
+//
+// `useThemeColors` drives the Lucide / accent colors. Author
+// rendering reuses the same "avatar + login / 'deleted user'"
+// pattern as the Overview tab's `PrAuthorRow`, so a deleted
+// account surfaces as a muted circle + "deleted user" label.
+//
+// Reactions are rendered via the `ReactionsRow` subcomponent; the
+// toggle is a single callback so the comment row does not need to
+// know about the mutations.
+
+import { MarkdownText } from '@/components/agents/markdown-text';
+import { Image } from '@/components/ui/image';
+import { Text } from '@/components/ui/text';
+import { ReactionsRow } from '@/components/pr-review/discussion/reactions-row';
+import {
+ type ReviewComment,
+ type ReviewReactionContent,
+ selectCommentAuthorName,
+} from '@/lib/pr-review/discussion/review-discussion-types';
+import { parseTimestamp, timeAgo } from '@/lib/utils';
+import { View } from 'react-native';
+
+type CommentRowProps = {
+ readonly comment: ReviewComment;
+ readonly onToggleReaction: (content: ReviewReactionContent) => void;
+ readonly reactionsDisabled?: boolean;
+};
+
+export function CommentRow({
+ comment,
+ onToggleReaction,
+ reactionsDisabled,
+}: Readonly) {
+ const authorName = selectCommentAuthorName(comment.author);
+ const timestamp = parseTimestamp(comment.createdAt);
+ const relative = timeAgo(timestamp);
+
+ return (
+
+
+ {comment.author?.avatarUrl ? (
+
+ ) : (
+
+ )}
+
+ {authorName}
+
+
+ {relative}
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx
new file mode 100644
index 0000000000..95c2c88344
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx
@@ -0,0 +1,256 @@
+// Single review-thread card: anchor header + comments list + reply input.
+//
+// - The thread header shows the anchor label ("src/a.ts L10 (RIGHT)"
+// or "File comment on src/a.ts" or "Outdated on ...") and the
+// "Outdated" / "Resolved" badges when applicable.
+// - Resolved threads are COLLAPSED by default (tapping the header
+// expands them). The repo's UI/UX rule for compact product rhythm
+// is to keep the noise level down on the happy path, so an
+// accepted PR's collapsed thread pile shouldn't dominate the tab.
+// - The reply input is uncontrolled (iOS ref pattern) per the
+// repo's iOS rule and per the comment-composer reference
+// implementation. Submit calls the (non-optimistic) reply
+// mutation and re-fetches the list on settle.
+// - The resolve / unresolve / reaction toggles are OPTIMISTIC;
+// the mutation hooks own the cache update + rollback, so the
+// thread just routes the events and lets the cache flow.
+
+import * as Haptics from 'expo-haptics';
+import { Check, CheckCheck, ChevronDown, ChevronUp } from 'lucide-react-native';
+import { useState } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { CommentRow } from '@/components/pr-review/discussion/comment-row';
+import { ReplyInput } from '@/components/pr-review/discussion/reply-input';
+import { Text } from '@/components/ui/text';
+import {
+ type ReviewComment,
+ type ReviewReactionContent,
+ type ReviewThread,
+ selectThreadAnchorLabel,
+ selectThreadBadges,
+} from '@/lib/pr-review/discussion/review-discussion-types';
+import {
+ useAddReactionMutation,
+ useRemoveReactionMutation,
+ useReplyToCommentMutation,
+ useResolveThreadMutation,
+ useUnresolveThreadMutation,
+} from '@/lib/pr-review/discussion/use-review-discussion-mutations';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn, parseTimestamp, timeAgo } from '@/lib/utils';
+
+type DiscussionThreadProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ readonly thread: ReviewThread;
+};
+
+export function DiscussionThread({ owner, repo, number, thread }: Readonly) {
+ // Resolved threads start collapsed; active threads start expanded.
+ const [expanded, setExpanded] = useState(!thread.isResolved);
+
+ const resolve = useResolveThreadMutation();
+ const unresolve = useUnresolveThreadMutation();
+ const addReaction = useAddReactionMutation(thread.threadId);
+ const removeReaction = useRemoveReactionMutation(thread.threadId);
+ const reply = useReplyToCommentMutation();
+
+ const anchorLabel = selectThreadAnchorLabel(thread);
+ const badges = selectThreadBadges(thread);
+ const firstComment = thread.comments[0];
+ const isResolving = resolve.isPending || unresolve.isPending;
+ const isReacting = addReaction.isPending || removeReaction.isPending;
+
+ const onToggleResolve = () => {
+ void Haptics.selectionAsync();
+ if (thread.isResolved) {
+ unresolve.mutate({ threadId: thread.threadId });
+ } else {
+ resolve.mutate({ threadId: thread.threadId });
+ }
+ };
+
+ const onToggleReaction = (comment: ReviewComment, content: ReviewReactionContent) => {
+ // Haptic is emitted by ReactionsRow's press handler; don't double-fire here.
+ const existing = comment.reactions.find(r => r.content === content);
+ if (existing?.viewerHasReacted) {
+ removeReaction.mutate({ commentNodeId: comment.nodeId, content });
+ } else {
+ addReaction.mutate({ commentNodeId: comment.nodeId, content });
+ }
+ };
+
+ return (
+
+ {
+ setExpanded(prev => !prev);
+ }}
+ onToggleResolve={onToggleResolve}
+ resolveDisabled={isResolving}
+ />
+ {expanded ? (
+ <>
+
+ {thread.comments.map((comment, index) => (
+ 0 && 'border-t border-border pt-4')}>
+ {
+ onToggleReaction(comment, content);
+ }}
+ />
+
+ ))}
+
+ {firstComment ? (
+
+ ) : null}
+ >
+ ) : null}
+
+ );
+}
+
+// ── Header ────────────────────────────────────────────────────────────
+
+type ThreadHeaderProps = {
+ readonly anchorLabel: string;
+ readonly resolved: boolean;
+ readonly outdated: boolean;
+ readonly fileLevel: boolean;
+ readonly commentCount: number;
+ readonly firstTimestamp: string | null;
+ readonly expanded: boolean;
+ readonly onToggleExpand: () => void;
+ readonly onToggleResolve: () => void;
+ readonly resolveDisabled: boolean;
+};
+
+function ThreadHeader({
+ anchorLabel,
+ resolved,
+ outdated,
+ fileLevel,
+ commentCount,
+ firstTimestamp,
+ expanded,
+ onToggleExpand,
+ onToggleResolve,
+ resolveDisabled,
+}: Readonly) {
+ const colors = useThemeColors();
+ const relative = firstTimestamp ? timeAgo(parseTimestamp(firstTimestamp)) : null;
+ return (
+
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {anchorLabel}
+
+
+
+
+
+ {resolved ? : null}
+ {outdated ? : null}
+ {fileLevel && !resolved ? : null}
+
+ {commentCount === 1 ? '1 comment' : `${commentCount} comments`}
+ {relative ? ` · started ${relative}` : ''}
+
+
+
+ );
+}
+
+type BadgeProps = {
+ readonly tone: 'good' | 'muted' | 'warn' | 'destructive';
+ readonly icon?: typeof Check;
+ readonly label: string;
+};
+
+const BADGE_TONE_CLASS: Record = {
+ good: 'bg-secondary text-good',
+ warn: 'bg-secondary text-warn',
+ destructive: 'bg-secondary text-destructive',
+ muted: 'bg-secondary text-muted-foreground',
+};
+
+function Badge({ tone, icon: Icon, label }: Readonly) {
+ const colors = useThemeColors();
+ const toneClass = BADGE_TONE_CLASS[tone];
+ // Native Lucide icons don't resolve NativeWind text classes, so set the
+ // icon color explicitly per tone from the theme tokens.
+ const iconColor: Record = {
+ good: colors.good,
+ warn: colors.warn,
+ destructive: colors.destructive,
+ muted: colors.mutedForeground,
+ };
+ return (
+
+ {Icon ? : null}
+ {label}
+
+ );
+}
+
+type ResolveToggleProps = {
+ readonly resolved: boolean;
+ readonly disabled: boolean;
+ readonly onPress: () => void;
+};
+
+function ResolveToggle({ resolved, disabled, onPress }: Readonly) {
+ const colors = useThemeColors();
+ return (
+
+
+
+ {resolved ? 'Resolved' : 'Resolve'}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx
new file mode 100644
index 0000000000..43a2f0d2d5
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/reactions-row.tsx
@@ -0,0 +1,133 @@
+// Reactions row for a single review comment.
+//
+// GitHub's review-comment reactions are a fixed set of 8 emoji
+// (`THUMBS_UP, THUMBS_DOWN, LAUGH, HOORAY, CONFUSED, HEART,
+// ROCKET, EYES`). Each one is rendered as a small pill that shows
+// the current count when > 0 and a darker fill when the viewer
+// has already reacted.
+//
+// Tapping a pill toggles: if the viewer has reacted, fire
+// `removeReaction`; otherwise `addReaction`. The optimistic cache
+// reducer (`applyReactionToggle`) updates the row instantly, so the
+// count + fill flip in the same frame as the tap.
+//
+// Disabled state is exposed for callers that want to lock the row
+// during the mutation's pending phase (rare — the optimistic update
+// makes the row look responsive; the hook will still rollback on
+// error).
+
+import * as Haptics from 'expo-haptics';
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import {
+ REVIEW_REACTION_CONTENTS,
+ type ReviewReactionContent,
+} from '@/lib/pr-review/discussion/review-discussion-types';
+import { cn } from '@/lib/utils';
+
+// Map each GitHub reaction content to the emoji that GitHub itself
+// renders in its UI. Kept inline (not in a shared emoji module) so
+// the discussion tab stays a self-contained slice.
+const REACTION_EMOJI: Record = {
+ THUMBS_UP: '👍',
+ THUMBS_DOWN: '👎',
+ LAUGH: '😄',
+ HOORAY: '🎉',
+ CONFUSED: '😕',
+ HEART: '❤️',
+ ROCKET: '🚀',
+ EYES: '👀',
+};
+
+type ReactionsRowProps = {
+ // Raw reactions from the DTO — `content` is a plain string (GitHub can
+ // return content outside the 8 emoji). We index by string and only render
+ // + toggle the fixed 8 known reactions.
+ readonly reactions: readonly {
+ readonly content: string;
+ readonly count: number;
+ readonly viewerHasReacted: boolean;
+ }[];
+ readonly onToggle: (content: ReviewReactionContent) => void;
+ readonly disabled?: boolean;
+};
+
+export function ReactionsRow({ reactions, onToggle, disabled }: Readonly) {
+ // Index existing reactions by content for O(1) lookup. Missing
+ // reactions render as an empty pill (no count) so the user can
+ // discover the full set.
+ const byContent = new Map();
+ for (const r of reactions) {
+ byContent.set(r.content, { count: r.count, viewerHasReacted: r.viewerHasReacted });
+ }
+ return (
+
+ {REVIEW_REACTION_CONTENTS.map(content => {
+ const existing = byContent.get(content);
+ const count = existing?.count ?? 0;
+ const reacted = existing?.viewerHasReacted ?? false;
+ return (
+ {
+ void Haptics.selectionAsync();
+ onToggle(content);
+ }}
+ />
+ );
+ })}
+
+ );
+}
+
+type ReactionPillProps = {
+ readonly content: ReviewReactionContent;
+ readonly emoji: string;
+ readonly count: number;
+ readonly viewerHasReacted: boolean;
+ readonly disabled: boolean;
+ readonly onPress: () => void;
+};
+
+function ReactionPill({
+ emoji,
+ count,
+ viewerHasReacted,
+ disabled,
+ onPress,
+}: Readonly) {
+ // Reacted pills get the accent-soft fill (same as the rest of the
+ // product's "active toggle" surface) so they read as selected in
+ // both light and dark themes. Unreacted pills use a flat border
+ // so the row stays calm when the user hasn't engaged.
+ return (
+
+ {emoji}
+ {count > 0 ? (
+
+ {count}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
new file mode 100644
index 0000000000..b6d35eed66
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
@@ -0,0 +1,120 @@
+// Reply input for a single review thread. The input is uncontrolled
+// (iOS ref pattern) per the repo's iOS rule. Submit calls the
+// (non-optimistic) reply mutation and re-fetches the list on settle.
+
+import { useEffect, useRef, useState } from 'react';
+import { TextInput, View } from 'react-native';
+
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations';
+
+const REPLY_PLACEHOLDER = 'Reply…';
+
+type ReplyInputProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ readonly commentId: number;
+ readonly reply: ReturnType;
+};
+
+export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) {
+ const colors = useThemeColors();
+ const bodyRef = useRef('');
+ const inputRef = useRef(null);
+ const [inlineError, setInlineError] = useState(null);
+ const [inlineErrorKind, setInlineErrorKind] = useState<
+ 'retryable' | 'bad-request' | 'forbidden' | 'reconnect' | null
+ >(null);
+ const [resetKey, setResetKey] = useState(0);
+
+ // Mirror mutation error into the inline box. Reply is NOT
+ // optimistic, so the user can hit the inline error and retry
+ // without waiting for a re-fetch.
+ useEffect(() => {
+ if (reply.error) {
+ const classification = classifyPrReviewMutationError(reply.error);
+ if (classification.kind === 'bad-request') {
+ setInlineError("This reply can't be posted. The thread may have changed.");
+ setInlineErrorKind('bad-request');
+ } else if (classification.kind === 'forbidden') {
+ setInlineError("You don't have permission to reply to this pull request.");
+ setInlineErrorKind('forbidden');
+ } else if (classification.kind === 'reconnect') {
+ setInlineError('GitHub connection expired.');
+ setInlineErrorKind('reconnect');
+ } else {
+ const message = reply.error instanceof Error ? reply.error.message : 'Could not reply.';
+ setInlineError(message);
+ setInlineErrorKind('retryable');
+ }
+ }
+ }, [reply.error]);
+
+ const submit = () => {
+ const body = bodyRef.current.trim();
+ if (!body || reply.isPending) {
+ return;
+ }
+ setInlineError(null);
+ setInlineErrorKind(null);
+ reply.mutate(
+ { owner, repo, number, commentId, body },
+ {
+ onSuccess: () => {
+ bodyRef.current = '';
+ setResetKey(prev => prev + 1);
+ },
+ }
+ );
+ };
+
+ return (
+
+ {
+ bodyRef.current = value;
+ if (inlineError) {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ }
+ }}
+ multiline
+ textAlignVertical="top"
+ className="min-h-16 rounded-md border border-input bg-background px-3 py-2 text-sm leading-5 text-foreground"
+ />
+ {inlineError && inlineErrorKind !== 'reconnect' ? (
+ {inlineError}
+ ) : null}
+ {inlineErrorKind === 'reconnect' ? : null}
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx
new file mode 100644
index 0000000000..a98144547d
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-icons.tsx
@@ -0,0 +1,48 @@
+// Icon-map for the S8 merge surfaces. Lives here (not in the pure
+// selector) so the selector's tests can load in plain Node without
+// pulling in lucide-react-native.
+
+import {
+ AlertTriangle,
+ GitBranch,
+ GitPullRequest,
+ type LucideIcon,
+ ShieldAlert,
+ XCircle,
+} from 'lucide-react-native';
+
+import {
+ type AllowedMergeMethod,
+ defaultMergeMethodFor,
+ getAllowedMergeMethods,
+ type MergeBlockedReasonId,
+ PR_MERGE_LABELS,
+ type PrOverviewRepoSettings,
+} from '@/lib/pr-review/merge/merge-blocked-reasons';
+
+const BLOCKED_REASON_ICON: Record = {
+ conflicts: XCircle,
+ 'required-reviews': ShieldAlert,
+ 'failing-checks': AlertTriangle,
+ behind: GitBranch,
+ 'unstable-checks': AlertTriangle,
+ draft: GitPullRequest,
+ 'unknown-state': AlertTriangle,
+};
+
+export function mergeBlockedReasonIcon(kind: MergeBlockedReasonId): LucideIcon {
+ return BLOCKED_REASON_ICON[kind];
+}
+
+export type MergeMethodOption = {
+ value: AllowedMergeMethod;
+ label: string;
+};
+
+export function mergeMethodOptionsFor(repo: PrOverviewRepoSettings): MergeMethodOption[] {
+ return getAllowedMergeMethods(repo).map(value => ({ value, label: PR_MERGE_LABELS[value] }));
+}
+
+export function defaultMergeMethodOptionFor(repo: PrOverviewRepoSettings): AllowedMergeMethod {
+ return defaultMergeMethodFor(repo);
+}
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx
new file mode 100644
index 0000000000..3814d3b155
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section-parts.tsx
@@ -0,0 +1,151 @@
+// Sub-components for the S8 merge section. Extracted out of
+// `pr-merge-section.tsx` so the section file stays under the
+// repo's 300-line limit.
+
+import { type LucideIcon, RefreshCw } from 'lucide-react-native';
+import { ActivityIndicator, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { mergeBlockedReasonIcon } from '@/components/pr-review/merge/pr-merge-icons';
+import {
+ type MergeBlockedReason,
+ type PrOverviewDto,
+} from '@/lib/pr-review/merge/merge-blocked-reasons';
+
+export function TerminalChip({ state }: Readonly<{ state: PrOverviewDto['state'] }>) {
+ const label = state === 'merged' ? 'Already merged' : 'This pull request is closed';
+ return (
+
+
+ Merge
+
+
+ {label}
+
+
+ );
+}
+
+export function MergeabilityCheckingRow() {
+ return (
+
+
+ Checking mergeability…
+
+ );
+}
+
+export function MergeabilityTimedOutRow({
+ onRefresh,
+ isRefreshing,
+}: Readonly<{ onRefresh: () => void; isRefreshing: boolean }>) {
+ const colors = useThemeColors();
+ return (
+
+ Couldn't determine mergeability.
+
+
+ );
+}
+
+function BlockedReasonRow({ reason }: Readonly<{ reason: MergeBlockedReason }>) {
+ const colors = useThemeColors();
+ const Icon: LucideIcon = mergeBlockedReasonIcon(reason.iconKind);
+ const tone = (() => {
+ if (reason.severity === 'destructive') {
+ return colors.destructive;
+ }
+ if (reason.severity === 'warn') {
+ return colors.warn;
+ }
+ return colors.mutedForeground;
+ })();
+ return (
+
+
+
+ {reason.title}
+
+ {reason.detail}
+
+
+
+ );
+}
+
+export function BlockedPanel({
+ reasons,
+ allowUpdateBranch,
+ isUpdatePending,
+ onUpdateBranch,
+}: Readonly<{
+ reasons: MergeBlockedReason[];
+ allowUpdateBranch: boolean;
+ isUpdatePending: boolean;
+ onUpdateBranch: () => void;
+}>) {
+ const hasBehindReason = reasons.some(r => r.id === 'behind');
+ return (
+
+
+ Why this can't be merged yet
+
+
+ {reasons.map(reason => (
+
+ ))}
+
+ {hasBehindReason && allowUpdateBranch ? (
+
+ ) : null}
+
+ );
+}
+
+export function AutoMergeEnabledBanner({
+ method,
+ onDisable,
+ isDisabling,
+}: Readonly<{ method: string; onDisable: () => void; isDisabling: boolean }>) {
+ return (
+
+
+ Auto-merge is on
+
+ GitHub will merge this pull request automatically when all required checks pass (method:{' '}
+ {method.toLowerCase()}).
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx
new file mode 100644
index 0000000000..9742bbc010
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-section.tsx
@@ -0,0 +1,291 @@
+// S8 merge section. The orchestrator mounts this at the
+// `{/* S8 merge section mounts here */}` slot inside `PrReviewOverview`.
+// It owns:
+// - the bounded mergeability-polling effect (~3s × 10 → ~30s)
+// - the terminal / blocked / mergeable / auto-merge branching
+// - the open-sheet affordance (the orchestrator wires the route)
+//
+// The sheet content lives in `pr-merge-sheet.tsx`; the rendering
+// sub-components live in `pr-merge-section-parts.tsx` to keep this file
+// under the repo's 300-line limit.
+
+import { type Href, useRouter } from 'expo-router';
+import { GitMerge } from 'lucide-react-native';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import {
+ type AllowedMergeMethod,
+ defaultMergeMethodFor,
+ getMergeabilityStatus,
+ getMergeBlockedReasons,
+ type PrMergeMethod,
+ type PrOverviewDto,
+} from '@/lib/pr-review/merge/merge-blocked-reasons';
+import {
+ useDisableAutoMergeMutation,
+ useUpdateBranchMutation,
+} from '@/lib/pr-review/merge/use-pr-merge-mutations';
+import {
+ AutoMergeEnabledBanner,
+ BlockedPanel,
+ MergeabilityCheckingRow,
+ MergeabilityTimedOutRow,
+ TerminalChip,
+} from '@/components/pr-review/merge/pr-merge-section-parts';
+
+type PrMergeSectionProps = Readonly<{
+ owner: string;
+ repo: string;
+ overview: PrOverviewDto;
+ /** Overview query refetch; used for both bounded polling and post-mutation refresh. */
+ onRefetch: () => Promise;
+ isRefetching: boolean;
+}>;
+
+const MERGEABILITY_POLL_INTERVAL_MS = 3000;
+const MERGEABILITY_POLL_MAX_TICKS = 10;
+
+// Consume a rejecting promise from a timer/event handler. The mutation hooks
+// surface failures via their `onError` toast and the query surfaces its own
+// error state, so we only need to prevent an unhandled promise rejection here.
+function ignoreRejection(promise: Promise): void {
+ void (async () => {
+ try {
+ await promise;
+ } catch {
+ /* handled by the hook's onError toast / query error state */
+ }
+ })();
+}
+
+function mergeSheetHref(args: {
+ owner: string;
+ repo: string;
+ number: number;
+ mode: 'merge' | 'enable-auto-merge';
+ method: PrMergeMethod;
+}): Href {
+ const href: Href = {
+ pathname: '/(app)/pr-review/[owner]/[repo]/[number]/merge',
+ params: {
+ owner: args.owner,
+ repo: args.repo,
+ number: String(args.number),
+ mode: args.mode,
+ method: args.method,
+ },
+ };
+ return href;
+}
+
+export function PrMergeSection({
+ owner,
+ repo,
+ overview,
+ onRefetch,
+ isRefetching,
+}: PrMergeSectionProps) {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const status = getMergeabilityStatus(overview);
+ const reasons = useMemo(
+ () =>
+ getMergeBlockedReasons({
+ state: overview.state,
+ draft: overview.draft,
+ mergeable: overview.mergeable,
+ mergeableState: overview.mergeableState,
+ reviewDecision: overview.reviewDecision,
+ allowUpdateBranch: overview.repo.allowUpdateBranch,
+ }),
+ [overview]
+ );
+
+ // Bounded polling for the brief window after GitHub queues a
+ // mergeability re-check. Poll on a fixed interval up to N ticks, then
+ // surface a retryable row. The timer is cleared on unmount AND on
+ // every status transition away from 'unknown'.
+ const tickRef = useRef(0);
+ const intervalRef = useRef | null>(null);
+ const [hasTimedOut, setHasTimedOut] = useState(false);
+
+ useEffect(() => {
+ if (status !== 'unknown') {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ tickRef.current = 0;
+ setHasTimedOut(false);
+ return;
+ }
+ if (intervalRef.current) {
+ return;
+ }
+ tickRef.current = 0;
+ setHasTimedOut(false);
+ intervalRef.current = setInterval(() => {
+ tickRef.current += 1;
+ if (tickRef.current >= MERGEABILITY_POLL_MAX_TICKS) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ setHasTimedOut(true);
+ return;
+ }
+ ignoreRejection(onRefetch());
+ }, MERGEABILITY_POLL_INTERVAL_MS);
+ }, [status, onRefetch]);
+
+ useEffect(
+ () => () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+ },
+ []
+ );
+
+ const prRef = useMemo(
+ () => ({ owner, repo, number: overview.number }),
+ [owner, repo, overview.number]
+ );
+ const updateBranch = useUpdateBranchMutation(prRef);
+ const disableAutoMerge = useDisableAutoMergeMutation(prRef);
+
+ // Terminal state.
+ if (status === 'terminal') {
+ return ;
+ }
+
+ // Unknown mergeability — poll until it resolves or the timer expires.
+ if (status === 'unknown') {
+ if (hasTimedOut) {
+ return (
+ {
+ ignoreRejection(onRefetch());
+ }}
+ isRefreshing={isRefetching}
+ />
+ );
+ }
+ return ;
+ }
+
+ // Auto-merge active.
+ if (overview.autoMerge) {
+ return (
+
+ {
+ ignoreRejection(
+ disableAutoMerge.mutateAsync({
+ owner,
+ repo,
+ number: overview.number,
+ prNodeId: overview.prNodeId,
+ })
+ );
+ }}
+ isDisabling={disableAutoMerge.isPending}
+ />
+ {status === 'mergeable' ? (
+
+ ) : null}
+
+ );
+ }
+
+ // Blocked.
+ if (status === 'blocked') {
+ return (
+
+ {
+ ignoreRejection(
+ updateBranch.mutateAsync({
+ owner,
+ repo,
+ number: overview.number,
+ expectedHeadSha: overview.headSha,
+ })
+ );
+ }}
+ />
+ {overview.repo.allowAutoMerge ? (
+
+ ) : null}
+
+ );
+ }
+
+ // Mergeable — single "Merge" CTA.
+ return (
+
+
+ Merge
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx
new file mode 100644
index 0000000000..bea76a923c
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet-parts.tsx
@@ -0,0 +1,143 @@
+// Form sub-components for the S8 merge sheet. Extracted out of
+// `pr-merge-sheet.tsx` to keep that file under the repo's 300-line limit.
+
+import { type RefObject } from 'react';
+import { Switch, TextInput, View } from 'react-native';
+
+import { PillGroup } from '@/components/security-agent/settings-pill-group';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+import {
+ type AllowedMergeMethod,
+ PR_MERGE_DESCRIPTIONS,
+} from '@/lib/pr-review/merge/merge-blocked-reasons';
+import { type MergeMethodOption } from '@/components/pr-review/merge/pr-merge-icons';
+
+export function MethodPicker({
+ methodOptions,
+ method,
+ isDisabled,
+ onChange,
+}: Readonly<{
+ methodOptions: MergeMethodOption[];
+ method: AllowedMergeMethod;
+ isDisabled: boolean;
+ onChange: (next: AllowedMergeMethod) => void;
+}>) {
+ return (
+
+
+ Method
+
+ ({ value: o.value, label: o.label }))}
+ value={method}
+ disabled={isDisabled}
+ onChange={value => {
+ onChange(value);
+ }}
+ />
+
+ {PR_MERGE_DESCRIPTIONS[method]}
+
+
+ );
+}
+
+export function CommitTitleField({
+ titleRef,
+ inputRef,
+ placeholder,
+ isDisabled,
+}: Readonly<{
+ titleRef: RefObject;
+ inputRef: RefObject;
+ placeholder: string;
+ isDisabled: boolean;
+}>) {
+ const colors = useThemeColors();
+ return (
+
+ Commit title
+ {
+ titleRef.current = value;
+ }}
+ className={cn(
+ 'rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground',
+ 'focus:border-ring'
+ )}
+ multiline
+ />
+
+ );
+}
+
+export function CommitMessageField({
+ messageRef,
+ inputRef,
+ isDisabled,
+}: Readonly<{
+ messageRef: RefObject;
+ inputRef: RefObject;
+ isDisabled: boolean;
+}>) {
+ const colors = useThemeColors();
+ return (
+
+ Commit message
+ {
+ messageRef.current = value;
+ }}
+ className={cn(
+ 'min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground',
+ 'focus:border-ring'
+ )}
+ multiline
+ textAlignVertical="top"
+ />
+
+ );
+}
+
+export function DeleteBranchToggle({
+ value,
+ onChange,
+ isDisabled,
+}: Readonly<{
+ value: boolean;
+ onChange: (next: boolean) => void;
+ isDisabled: boolean;
+}>) {
+ return (
+
+
+ Delete branch
+
+ Delete the head branch after the merge succeeds.
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx
new file mode 100644
index 0000000000..2fbd3e16bd
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/merge/pr-merge-sheet.tsx
@@ -0,0 +1,345 @@
+// S8 merge sheet. The orchestrator mounts this inside the
+// `[owner]/[repo]/[number]/merge.tsx` route; the orchestrator-wired
+// `PrReviewMergeScreen` fetches the overview DTO, derives the form's
+// initial state, and forwards everything as props.
+//
+// Two modes share the same form:
+// - 'merge' — submits `mergePullRequest`
+// - 'enable-auto-merge' — submits `enableAutoMerge`
+//
+// Toasts paint behind formSheets on iOS, so this sheet ALSO renders
+// inline errors while the underlying mutation hook still calls
+// `toast.error` in `onError`. The form stays open until the user
+// dismisses (cancel) or the mutation succeeds (auto-dismiss).
+
+import * as Haptics from 'expo-haptics';
+import { Alert, ScrollView, type TextInput, View } from 'react-native';
+import { useEffect, useMemo, useRef, useState } from 'react';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import {
+ type AllowedMergeMethod,
+ type PrMergeMethod,
+ type PrOverviewRepoSettings,
+} from '@/lib/pr-review/merge/merge-blocked-reasons';
+import {
+ useEnableAutoMergeMutation,
+ useMergePullRequestMutation,
+} from '@/lib/pr-review/merge/use-pr-merge-mutations';
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+import {
+ defaultMergeMethodOptionFor,
+ mergeMethodOptionsFor,
+} from '@/components/pr-review/merge/pr-merge-icons';
+import {
+ CommitMessageField,
+ CommitTitleField,
+ DeleteBranchToggle,
+ MethodPicker,
+} from '@/components/pr-review/merge/pr-merge-sheet-parts';
+import {
+ defaultCommitMessage,
+ defaultCommitTitle,
+} from '@/lib/pr-review/merge/merge-commit-defaults';
+
+type PrMergeSheetMode = 'merge' | 'enable-auto-merge';
+
+type PrMergeSheetProps = Readonly<{
+ owner: string;
+ /** The GitHub repository name (the `repo` path segment, not the settings object). */
+ repoName: string;
+ number: number;
+ headSha: string;
+ headRef: string;
+ isCrossRepo: boolean;
+ prNodeId: string;
+ title: string;
+ bodyMarkdown: string | null;
+ baseRef: string;
+ repo: PrOverviewRepoSettings;
+ initialMethod: PrMergeMethod;
+ mode: PrMergeSheetMode;
+ /** Called after a successful merge / auto-merge enable so the orchestrator can refetch. */
+ onRefetch: () => Promise;
+ /** Called when the user cancels or after a successful submit. */
+ onDismiss: () => void;
+}>;
+
+type MergePullRequestInput = {
+ owner: string;
+ repo: string;
+ number: number;
+ method: 'merge' | 'squash' | 'rebase';
+ commitTitle?: string;
+ commitMessage?: string;
+ deleteBranch: boolean;
+ expectedHeadSha: string;
+ headRef: string;
+ isCrossRepo: boolean;
+};
+
+type AutoMergeInput = {
+ owner: string;
+ repo: string;
+ number: number;
+ prNodeId: string;
+ method?: 'MERGE' | 'SQUASH' | 'REBASE';
+ commitTitle?: string;
+ commitMessage?: string;
+};
+
+export function PrMergeSheet(props: PrMergeSheetProps) {
+ const {
+ owner,
+ repoName,
+ number,
+ headSha,
+ headRef,
+ isCrossRepo,
+ prNodeId,
+ title,
+ bodyMarkdown,
+ repo: repoSettings,
+ initialMethod,
+ mode,
+ onRefetch,
+ onDismiss,
+ } = props;
+
+ const methodOptions = useMemo(() => mergeMethodOptionsFor(repoSettings), [repoSettings]);
+ const safeInitial: AllowedMergeMethod = useMemo(
+ () =>
+ methodOptions.find(o => o.value === initialMethod)?.value ??
+ defaultMergeMethodOptionFor(repoSettings),
+ [initialMethod, methodOptions, repoSettings]
+ );
+ const [method, setMethod] = useState(safeInitial);
+
+ const showDeleteBranchToggle = !isCrossRepo;
+ const [deleteBranch, setDeleteBranch] = useState(repoSettings.deleteBranchOnMerge);
+
+ // iOS uncontrolled-input pattern: store text in a ref via onChangeText,
+ // use state only for derived UI (the inline error from a failed submit),
+ // read the ref on submit. `defaultValue` is for the first commit only.
+ const titleInputRef = useRef(null);
+ const messageInputRef = useRef(null);
+ const titleRef = useRef(defaultCommitTitle(title, number));
+ const messageRef = useRef(defaultCommitMessage(bodyMarkdown));
+
+ const [inlineError, setInlineError] = useState(null);
+ const [inlineErrorKind, setInlineErrorKind] = useState<
+ 'retryable' | 'non-retryable' | 'reconnect' | null
+ >(null);
+
+ const ref: { owner: string; repo: string; number: number } = useMemo(
+ () => ({ owner, repo: repoName, number }),
+ [owner, repoName, number]
+ );
+
+ const mergeMutation = useMergePullRequestMutation(ref);
+ const enableAutoMergeMutation = useEnableAutoMergeMutation(ref);
+
+ const isMutating =
+ (mode === 'merge' && mergeMutation.isPending) ||
+ (mode === 'enable-auto-merge' && enableAutoMergeMutation.isPending);
+ const lastError = mode === 'merge' ? mergeMutation.error : enableAutoMergeMutation.error;
+
+ useEffect(() => {
+ if (lastError) {
+ const classification = classifyPrReviewMutationError(lastError);
+ if (classification.kind === 'bad-request' || classification.kind === 'forbidden') {
+ setInlineError(
+ classification.kind === 'forbidden'
+ ? "You don't have permission to merge this pull request."
+ : 'This pull request cannot be merged as is.'
+ );
+ setInlineErrorKind('non-retryable');
+ } else if (classification.kind === 'reconnect') {
+ setInlineError('GitHub connection expired.');
+ setInlineErrorKind('reconnect');
+ } else {
+ setInlineError(
+ lastError instanceof Error ? lastError.message : 'Could not merge pull request.'
+ );
+ setInlineErrorKind('retryable');
+ }
+ }
+ }, [lastError]);
+
+ function resetForNewMethod(next: AllowedMergeMethod) {
+ setMethod(next);
+ }
+
+ function buildMergeInput(): MergePullRequestInput {
+ return {
+ owner,
+ repo: repoName,
+ number,
+ method,
+ commitTitle: titleRef.current.trim().length > 0 ? titleRef.current.trim() : undefined,
+ commitMessage: messageRef.current.trim().length > 0 ? messageRef.current.trim() : undefined,
+ deleteBranch: showDeleteBranchToggle ? deleteBranch : false,
+ expectedHeadSha: headSha,
+ headRef,
+ isCrossRepo,
+ };
+ }
+
+ function buildAutoMergeInput(): AutoMergeInput {
+ const autoMethod: 'MERGE' | 'SQUASH' | 'REBASE' = (() => {
+ if (method === 'merge') {
+ return 'MERGE';
+ }
+ if (method === 'squash') {
+ return 'SQUASH';
+ }
+ return 'REBASE';
+ })();
+ return {
+ owner,
+ repo: repoName,
+ number,
+ prNodeId,
+ method: autoMethod,
+ commitTitle: titleRef.current.trim().length > 0 ? titleRef.current.trim() : undefined,
+ commitMessage: messageRef.current.trim().length > 0 ? messageRef.current.trim() : undefined,
+ };
+ }
+
+ async function performSubmit() {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ try {
+ // eslint-disable-next-line typescript-eslint/prefer-ternary -- awaits inside branches can't be a ternary expression
+ if (mode === 'merge') {
+ await mergeMutation.mutateAsync(buildMergeInput());
+ } else {
+ await enableAutoMergeMutation.mutateAsync(buildAutoMergeInput());
+ }
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ await onRefetch();
+ // Dismiss exactly this merge route; `onDismiss` (router.back) leaves the
+ // refreshed PR review screen visible. Do NOT also call router.back()
+ // here or it would pop the review screen too.
+ onDismiss();
+ } catch {
+ // The effect above classifies the mutation error into inlineError;
+ // swallow here to avoid an unhandled promise rejection.
+ }
+ }
+
+ function handleConfirmPress() {
+ if (isMutating || noMethodsAllowed) {
+ return;
+ }
+ setInlineError(null);
+ setInlineErrorKind(null);
+
+ const submit = () => {
+ void performSubmit();
+ };
+
+ if (mode === 'merge') {
+ Alert.alert('Merge pull request?', 'This will merge your changes into the base branch.', [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Merge', style: 'destructive', onPress: submit },
+ ]);
+ return;
+ }
+ Alert.alert(
+ 'Enable auto-merge?',
+ 'GitHub will merge this pull request automatically when all required checks pass.',
+ [
+ { text: 'Cancel', style: 'cancel' },
+ { text: 'Enable auto-merge', style: 'destructive', onPress: submit },
+ ]
+ );
+ }
+
+ const submitLabel = mode === 'merge' ? 'Merge' : 'Enable auto-merge';
+ // A repository can (rarely) have every merge method disabled. GitHub would
+ // reject any submission, so surface it explicitly and block the action
+ // rather than sending a method the repo does not allow.
+ const noMethodsAllowed = methodOptions.length === 0;
+
+ return (
+
+
+ {noMethodsAllowed ? (
+
+
+ This repository has no enabled merge methods. Ask a repository admin to enable merge,
+ squash, or rebase merging.
+
+
+ ) : (
+
+ )}
+
+
+ {showDeleteBranchToggle ? (
+
+ ) : null}
+ {inlineError && inlineErrorKind !== 'reconnect' ? (
+
+ {inlineError}
+
+ ) : null}
+ {inlineErrorKind === 'reconnect' ? : null}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx
new file mode 100644
index 0000000000..78afac2135
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-checks-section.tsx
@@ -0,0 +1,316 @@
+import { useQuery } from '@tanstack/react-query';
+import {
+ AlertTriangle,
+ CheckCircle2,
+ Circle,
+ ExternalLink,
+ Loader2,
+ MinusCircle,
+ XCircle,
+} from 'lucide-react-native';
+import { useMemo } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state';
+import { useTRPC } from '@/lib/trpc';
+import { cn } from '@/lib/utils';
+import { openExternalUrl } from '@/lib/external-link';
+
+type PrReviewChecksSectionProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ /** Head SHA to fetch check runs for. */
+ readonly headSha: string;
+};
+
+type CheckRun = {
+ name: string;
+ status: string;
+ conclusion: string | null;
+ detailsUrl: string | null;
+ appName: string | null;
+};
+
+type CheckTone = 'success' | 'failure' | 'pending' | 'skipped' | 'neutral' | 'warning';
+
+function classifyCheckTone(status: string, conclusion: string | null): CheckTone {
+ // GitHub's CheckRun.status: queued | in_progress | completed | pending | waiting | requested.
+ // conclusion is null unless status === 'completed'.
+ if (status !== 'completed') {
+ return 'pending';
+ }
+ switch (conclusion) {
+ case 'success': {
+ return 'success';
+ }
+ case 'failure':
+ case 'startup_failure': {
+ return 'failure';
+ }
+ case 'skipped':
+ case 'cancelled':
+ case 'stale': {
+ return 'skipped';
+ }
+ case 'timed_out':
+ case 'action_required': {
+ return 'warning';
+ }
+ case 'neutral':
+ case null: {
+ return 'neutral';
+ }
+ default: {
+ return 'neutral';
+ }
+ }
+}
+
+const TONE_COLOR: Record> = {
+ success: 'good',
+ failure: 'destructive',
+ pending: 'mutedForeground',
+ skipped: 'mutedForeground',
+ neutral: 'mutedForeground',
+ warning: 'warn',
+};
+
+const TONE_ICON: Record = {
+ success: CheckCircle2,
+ failure: XCircle,
+ pending: Loader2,
+ skipped: MinusCircle,
+ neutral: Circle,
+ warning: AlertTriangle,
+};
+
+function CheckRow({ run }: Readonly<{ run: CheckRun }>) {
+ const colors = useThemeColors();
+ const tone = classifyCheckTone(run.status, run.conclusion);
+ const Icon = TONE_ICON[tone];
+ const iconColor = colors[TONE_COLOR[tone]];
+
+ const subtitle = run.appName ?? '';
+
+ const body = (
+
+
+
+
+ {run.name}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {run.detailsUrl ? : null}
+
+ );
+
+ if (!run.detailsUrl) {
+ return body;
+ }
+ return (
+ {
+ if (run.detailsUrl) {
+ void openExternalUrl(run.detailsUrl, { label: 'check details' });
+ }
+ }}
+ accessibilityRole="link"
+ accessibilityLabel={`Open ${run.name} details`}
+ >
+ {body}
+
+ );
+}
+
+function buildRollupLine(rollup: {
+ total: number;
+ success: number;
+ failure: number;
+ pending: number;
+ skipped: number;
+}): string {
+ if (rollup.total === 0) {
+ return 'No checks reported';
+ }
+ const parts: string[] = [];
+ if (rollup.success > 0) {
+ parts.push(`${rollup.success} passed`);
+ }
+ if (rollup.failure > 0) {
+ parts.push(`${rollup.failure} failed`);
+ }
+ if (rollup.pending > 0) {
+ parts.push(`${rollup.pending} pending`);
+ }
+ if (rollup.skipped > 0) {
+ parts.push(`${rollup.skipped} skipped`);
+ }
+ return parts.length > 0 ? parts.join(' · ') : `${rollup.total} checks`;
+}
+
+export function PrReviewChecksSection({
+ owner,
+ repo,
+ number,
+ headSha,
+}: PrReviewChecksSectionProps) {
+ const trpc = useTRPC();
+ const colors = useThemeColors();
+ const prUrl = useMemo(
+ () => `https://github.com/${owner}/${repo}/pull/${number}`,
+ [owner, repo, number]
+ );
+
+ const checks = useQuery(
+ trpc.githubPrReview.listChecks.queryOptions({ owner, repo, ref: headSha })
+ );
+
+ // Loading (first time, no cached data): show three skeleton rows in a
+ // card so the section matches the final dimensions once the data lands.
+ if (checks.isLoading) {
+ return (
+
+
+ Checks
+
+
+
+
+
+
+
+ );
+ }
+
+ if (checks.isError) {
+ const state = classifyPrReviewQueryState(checks.error);
+ if (state.kind === 'not-found') {
+ // Section-level terminal: NOT_FOUND here means the ref has no
+ // checks endpoint access (rare; usually means the app isn't
+ // installed on the head repo). No retry — just the message.
+ return (
+
+
+ Checks
+
+
+
+ Checks aren't available yet. The head commit may not have been processed, or the
+ Kilo GitHub App isn't installed on the head repository.
+
+
+
+ );
+ }
+ if (state.kind === 'permission') {
+ return (
+
+
+ Checks
+
+
+
+ You don't have access to checks for this repository.
+
+
+
+ );
+ }
+ if (state.kind === 'reconnect') {
+ return (
+
+
+ Checks
+
+
+
+ );
+ }
+
+ // Retryable (server/offline) — section-level retry button.
+ return (
+
+
+ Checks
+
+
+ Couldn't load checks.
+
+
+
+ );
+ }
+
+ const data = checks.data;
+ const runList = data?.checkRuns ?? [];
+ const rollup = data?.rollup ?? { total: 0, success: 0, failure: 0, pending: 0, skipped: 0 };
+ const rollupLine = buildRollupLine(rollup);
+
+ if (runList.length === 0) {
+ return (
+
+
+ Checks
+
+
+ {rollupLine}
+
+
+
+ );
+ }
+
+ return (
+
+
+ Checks
+
+
+
+
+ {rollupLine}
+
+
+ {runList.map((run, index) => (
+
+
+ {index < runList.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx
new file mode 100644
index 0000000000..0ab6bad88f
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer-screen.tsx
@@ -0,0 +1,91 @@
+import { useQuery } from '@tanstack/react-query';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { type ReactNode } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+
+import { PrReviewCommentComposer } from '@/components/pr-review/pr-review-comment-composer';
+import { QueryError } from '@/components/query-error';
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { ScreenHeader } from '@/components/screen-header';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { parseComposerParams } from '@/lib/pr-review/comment-composer-params';
+import { useTRPC } from '@/lib/trpc';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+ path: string;
+ side?: string;
+ line: string;
+ startLine?: string;
+};
+
+export function PrReviewCommentComposerScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const params = useLocalSearchParams();
+ const parsed = parseComposerParams(params);
+
+ const trpc = useTRPC();
+ const pr = useQuery(
+ trpc.githubPrReview.getPullRequest.queryOptions(
+ { owner: parsed?.owner ?? '', repo: parsed?.repo ?? '', number: parsed?.number ?? 0 },
+ { enabled: parsed !== null }
+ )
+ );
+
+ let content: ReactNode = null;
+ if (!parsed) {
+ content = ;
+ } else if (pr.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (pr.isError || !pr.data) {
+ content = (
+
+ {
+ void pr.refetch();
+ }}
+ isRetrying={pr.isFetching}
+ />
+
+ );
+ } else {
+ content = (
+ {
+ router.back();
+ }}
+ />
+ );
+ }
+
+ return (
+
+ {
+ router.back();
+ }}
+ />
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx
new file mode 100644
index 0000000000..c5ba542a25
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-comment-composer.tsx
@@ -0,0 +1,345 @@
+// S7a comment-composer content component. The orchestrator mounts this
+// inside the `comment-composer.tsx` route (S4b left a thin stub there);
+// the route supplies the route-params; this component owns the form
+// body, the suggestion affordance, and both submit paths.
+//
+// Two submit actions, both available at once:
+// - "Add to review" → enqueue into the `PendingReviewProvider`,
+// keep the selection alive, dismiss. The user keeps editing more
+// lines and submits the whole batch in the review-submit sheet.
+// - "Comment now" → POST a single comment immediately via
+// `createReviewComment` and dismiss. No review state is created.
+//
+// Toasts paint behind formSheets on iOS, so the mutation hook toasts
+// `onError` AND the sheet renders an inline error box. A 422 (e.g.
+// stale line because the head moved) is a non-retryable inline message.
+
+import * as Crypto from 'expo-crypto';
+import * as Haptics from 'expo-haptics';
+import { type RefObject, useEffect, useRef, useState } from 'react';
+import { Alert, ScrollView, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence';
+import { type DiffSelection, getDiffSelection } from '@/lib/pr-review/diff-selection-bridge';
+import { usePendingReview } from '@/lib/pr-review/pending-review-provider';
+import { useCreateReviewCommentMutation } from '@/lib/pr-review/use-pr-review-mutations';
+import { cn } from '@/lib/utils';
+
+type PrReviewCommentComposerProps = Readonly<{
+ owner: string;
+ repo: string;
+ number: number;
+ /** Current PR head SHA — pinned into both the queued and immediate comment. */
+ headSha: string;
+ path: string;
+ side: 'LEFT' | 'RIGHT';
+ line: number;
+ startLine?: number;
+ /** Invoked after the user submits (either path) or cancels. */
+ onDismiss: () => void;
+}>;
+
+const BODY_PLACEHOLDER = 'Leave a comment';
+
+export function PrReviewCommentComposer(props: PrReviewCommentComposerProps) {
+ const { owner, repo, number, headSha, path, side, line, startLine, onDismiss } = props;
+ const pending = usePendingReview();
+ const createComment = useCreateReviewCommentMutation({ owner, repo, number });
+
+ // Read the bridge on mount to render the selected-line context. We
+ // re-read on every render because the diff may have updated the
+ // selection while the user was navigating here.
+ const selection = getDiffSelection({ owner, repo, number });
+
+ // iOS uncontrolled pattern: text lives in a ref, the input's visible
+ // value is set via defaultValue once + setNativeProps for the
+ // suggestion insert. No `value` + state (iOS bug).
+ const bodyRef = useRef('');
+ const bodyInputRef = useRef(null);
+ const [inlineError, setInlineError] = useState(null);
+ const [inlineErrorKind, setInlineErrorKind] = useState<
+ 'retryable' | 'bad-request' | 'forbidden' | 'reconnect' | null
+ >(null);
+
+ const isSubmitting = createComment.isPending;
+ const lineRangeLabel = rangeLabel(line, startLine);
+
+ // When the mutation errors, classify it and mirror a short message into
+ // the inline error box. The hook toasts the same message; this box is
+ // the one the user actually sees in the formSheet.
+ useEffect(() => {
+ if (createComment.error) {
+ const classification = classifyPrReviewMutationError(createComment.error);
+ if (classification.kind === 'bad-request') {
+ setInlineError(
+ "This comment can't be posted. The selected line may have changed, or the PR may have been updated."
+ );
+ setInlineErrorKind('bad-request');
+ } else if (classification.kind === 'forbidden') {
+ setInlineError("You don't have permission to post a comment on this pull request.");
+ setInlineErrorKind('forbidden');
+ } else if (classification.kind === 'reconnect') {
+ setInlineError('GitHub connection expired.');
+ setInlineErrorKind('reconnect');
+ } else {
+ const message =
+ createComment.error instanceof Error
+ ? createComment.error.message
+ : 'Could not post comment.';
+ setInlineError(message);
+ setInlineErrorKind('retryable');
+ }
+ }
+ }, [createComment.error]);
+
+ function handleAddToReview() {
+ const body = bodyRef.current;
+ if (body.trim().length === 0) {
+ setInlineError('Comment body cannot be empty.');
+ return;
+ }
+ setInlineError(null);
+ setInlineErrorKind(null);
+ pending.addComment({
+ id: Crypto.randomUUID(),
+ path,
+ side,
+ line,
+ ...(startLine !== undefined ? { startLine } : {}),
+ body,
+ commitSha: headSha,
+ });
+ void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ onDismiss();
+ }
+
+ async function handleCommentNow() {
+ const body = bodyRef.current;
+ if (body.trim().length === 0) {
+ setInlineError('Comment body cannot be empty.');
+ setInlineErrorKind('bad-request');
+ return;
+ }
+ setInlineError(null);
+ setInlineErrorKind(null);
+ try {
+ await createComment.mutateAsync({
+ owner,
+ repo,
+ number,
+ body,
+ path,
+ line,
+ side,
+ // The S3 Zod refine rejects a partial range: startLine and
+ // startSide must come together. We pass startSide = side so
+ // the body lands on the same side the user is looking at.
+ ...(startLine !== undefined ? { startLine, startSide: side } : {}),
+ commitSha: headSha,
+ });
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ onDismiss();
+ } catch {
+ // The mutation's error is already classified into inlineError by
+ // the effect above; swallow here so it doesn't become an unhandled
+ // promise rejection.
+ }
+ }
+
+ function handleCancel() {
+ if (isSubmitting) {
+ return;
+ }
+ if (bodyRef.current.trim().length > 0) {
+ Alert.alert('Discard comment?', 'Your draft will be lost.', [
+ { text: 'Keep editing', style: 'cancel' },
+ { text: 'Discard', style: 'destructive', onPress: onDismiss },
+ ]);
+ return;
+ }
+ onDismiss();
+ }
+
+ function handleInsertSuggestion() {
+ if (side === 'LEFT') {
+ return;
+ }
+ const selectedText = selection?.selectedText ?? '';
+ const block = buildSuggestionFence(selectedText);
+ if (block === null) {
+ return;
+ }
+ bodyRef.current = block;
+ bodyInputRef.current?.setNativeProps({
+ text: block,
+ selection: { start: block.length, end: block.length },
+ });
+ bodyInputRef.current?.focus();
+ }
+
+ const suggestionAvailable = side === 'RIGHT' && Boolean(selection?.selectedText);
+ let suggestionDisabledReason: string | null = null;
+ if (side === 'LEFT') {
+ suggestionDisabledReason = 'Suggestions only apply to added lines.';
+ } else if (!selection?.selectedText) {
+ suggestionDisabledReason = 'Tap a diff line to enable suggestions.';
+ }
+
+ return (
+
+
+
+
+ Comment
+
+
+
+ {inlineError && inlineErrorKind !== 'reconnect' ? (
+
+ {inlineError}
+
+ ) : null}
+ {inlineErrorKind === 'reconnect' ? : null}
+
+
+
+
+
+
+
+
+ );
+}
+
+function CommentBodyField({
+ bodyRef,
+ inputRef,
+ isDisabled,
+}: {
+ bodyRef: RefObject;
+ inputRef: RefObject;
+ isDisabled: boolean;
+}) {
+ const colors = useThemeColors();
+ return (
+ {
+ bodyRef.current = value;
+ }}
+ multiline
+ textAlignVertical="top"
+ // Explicit line-height (no `text-center` per the repo's iOS rule).
+ // `leading-5` = line-height 20, font-size 14, matching the merge
+ // sheet's commit message field.
+ className={cn(
+ 'min-h-32 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground',
+ 'focus:border-ring'
+ )}
+ />
+ );
+}
+
+function ContextPreview({
+ selection,
+ fallbackPath,
+ fallbackLineLabel,
+ fallbackSide,
+}: {
+ selection: DiffSelection | null;
+ fallbackPath: string;
+ fallbackLineLabel: string;
+ fallbackSide: 'LEFT' | 'RIGHT';
+}) {
+ const path = selection?.path ?? fallbackPath;
+ const side = selection?.side ?? fallbackSide;
+ const lineLabel = selection ? rangeLabel(selection.line, selection.startLine) : fallbackLineLabel;
+ const previewText = selection?.selectedText ?? '';
+ return (
+
+
+ {path} {side} {lineLabel}
+
+ {previewText.length > 0 ? (
+
+ {previewText}
+
+ ) : (
+
+ Selected line context will appear here.
+
+ )}
+
+ );
+}
+
+function rangeLabel(line: number, startLine?: number): string {
+ if (startLine !== undefined && startLine !== line) {
+ return `L${startLine}–L${line}`;
+ }
+ return `L${line}`;
+}
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
new file mode 100644
index 0000000000..e512e40cb8
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-connect-gate.tsx
@@ -0,0 +1,169 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { PlugZap, RefreshCcw, ShieldAlert } from 'lucide-react-native';
+import { type ReactNode, useEffect, useRef, useState } from 'react';
+import { ActivityIndicator, AppState, type AppStateStatus, Platform, View } from 'react-native';
+import { toast } from 'sonner-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { openAuthorizationAndWaitForReturn } from '@/lib/pr-review/connect-gate-platform';
+import { useTRPC } from '@/lib/trpc';
+
+type PrReviewConnectGateProps = {
+ readonly children: ReactNode;
+};
+
+/**
+ * Wraps every PR-review surface. The user's GitHub identity (separate from
+ * a per-org GitHub App installation) is required to post review comments
+ * via the mobile app — without it, every mutation would 401 in the same
+ * way. The gate is the single place that handles:
+ *
+ * - happy: connected → render children
+ * - retryable: getUserAuthorization fails → QueryError + Retry
+ * - empty: not connected / revoked → EmptyState CTA
+ * - non-retryable: structurally n/a (this is a configuration gate, not a
+ * transient server failure).
+ *
+ * The CTA calls `githubApps.connectUserAuthorization` and opens the
+ * returned URL with the platform-appropriate browser launcher (iOS native
+ * auth session that resolves on sheet close; Android custom tab that
+ * resolves on app-foreground via AppState). Cancellation on either
+ * platform simply leaves the gate showing — there's nothing to roll
+ * back because the auth flow is server-driven.
+ */
+export function PrReviewConnectGate({ children }: PrReviewConnectGateProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const colors = useThemeColors();
+ const authorization = useQuery(trpc.githubApps.getUserAuthorization.queryOptions());
+ const connect = useMutation(
+ trpc.githubApps.connectUserAuthorization.mutationOptions({
+ onError: error => {
+ toast.error(error.message);
+ },
+ })
+ );
+
+ // Track the in-flight launch so a stale AppState 'active' transition
+ // (from the user backgrounding the app before tapping Connect) doesn't
+ // trigger a refetch on its own.
+ const launchedAt = useRef(null);
+ const [connecting, setConnecting] = useState(false);
+
+ // iOS: openAuthSessionAsync already resolves on sheet close, so we await
+ // it and refetch right there. Android: openBrowserAsync is fire-and-
+ // forget, so we listen for AppState returning to 'active' and refetch
+ // then. Same split as use-device-auth.ts.
+ useEffect(() => {
+ if (Platform.OS !== 'android') {
+ return undefined;
+ }
+ const handleChange = (nextState: AppStateStatus) => {
+ if (nextState !== 'active') {
+ return;
+ }
+ if (launchedAt.current === null) {
+ return;
+ }
+ launchedAt.current = null;
+ void authorization.refetch();
+ };
+ const subscription = AppState.addEventListener('change', handleChange);
+ return () => {
+ subscription.remove();
+ };
+ }, [authorization]);
+
+ const handleConnect = async () => {
+ setConnecting(true);
+ try {
+ const result = await connect.mutateAsync();
+ launchedAt.current = Date.now();
+ const trigger = await openAuthorizationAndWaitForReturn(Platform.OS, result.authorizationUrl);
+ if (trigger === 'sheet-close') {
+ // iOS: refetch immediately. Clear the launch sentinel so the
+ // AppState handler (if it ever fires) doesn't double-refetch.
+ launchedAt.current = null;
+ await authorization.refetch();
+ await queryClient.invalidateQueries({
+ queryKey: trpc.githubApps.getUserAuthorization.queryKey(),
+ });
+ }
+ // Android: refetch is handled by the AppState listener when the app
+ // returns to foreground. `openBrowserAsync` resolves as soon as the
+ // browser is launched, so we must NOT clear the sentinel here — the
+ // foreground handler clears it once it has consumed it.
+ } catch {
+ // mutateAsync already toasted; the openAuthorizationAndWaitForReturn
+ // rejection means the browser failed to open — clear the sentinel so
+ // a later unrelated foreground doesn't trigger a stray refetch, and
+ // keep the gate showing.
+ launchedAt.current = null;
+ } finally {
+ setConnecting(false);
+ }
+ };
+
+ if (authorization.isError) {
+ return (
+
+ {
+ void authorization.refetch();
+ }}
+ isRetrying={authorization.isFetching}
+ />
+
+ );
+ }
+
+ if (authorization.isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!authorization.data?.connected) {
+ const revoked = authorization.data?.revoked === true;
+ return (
+
+ {
+ void handleConnect();
+ }}
+ >
+ {connecting ? (
+
+ ) : (
+
+ )}
+ {revoked ? 'Reconnect GitHub' : 'Connect GitHub'}
+
+ }
+ />
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx
new file mode 100644
index 0000000000..1b83131c86
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx
@@ -0,0 +1,289 @@
+// PR review Discussion tab body.
+//
+// State matrix (per S7b §6 Discussion):
+// - happy: threads render, grouped by file path; first
+// page auto-loads, "Load more" paginates.
+// - loading: first page in flight; render `Skeleton`
+// placeholders matching the row dimensions.
+// - retryable: first page failed with a transient error;
+// render `QueryError` with the standard Retry
+// CTA wired to `refetch()`.
+// - permission: first page failed with FORBIDDEN / UNAUTHORIZED;
+// terminal message, no CTA (per the repo's
+// rule that permanent permission errors must not
+// offer a retry).
+// - not-found: first page failed with NOT_FOUND; terminal
+// message, no CTA (the PR is gone).
+// - reconnect: first page failed with PRECONDITION_FAILED;
+// terminal message pointing the user at the
+// connect gate (the connect flow is owned by the
+// screen-level `PrReviewConnectGate`, which is
+// already mounted by the parent screen).
+// - empty: first page returned zero threads AND no
+// terminal error; render `EmptyState` with the
+// "No review comments yet" copy and a "Review
+// files" CTA that switches to the Files tab via
+// the `onRequestFiles` prop (the screen must
+// pass it; we degrade gracefully if it's
+// omitted).
+//
+// - later-page error: a per-page refetch failure during a
+// "Load more" tap. The current loaded
+// threads are kept and a small retry row
+// renders at the bottom of the list.
+//
+// The component does NOT own a ScrollView — the tab is mounted
+// inside the screen's tab shell and needs a fresh FlatList so the
+// list can virtualize when a PR has hundreds of threads. (Same
+// approach as the Files tab.)
+
+import { FlashList } from '@shopify/flash-list';
+import { MessageSquarePlus } from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { DiscussionThread } from '@/components/pr-review/discussion/discussion-thread';
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { Button } from '@/components/ui/button';
+import { Skeleton } from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import {
+ groupThreadsByPath,
+ type ReviewThread,
+} from '@/lib/pr-review/discussion/review-discussion-types';
+import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads';
+import { cn } from '@/lib/utils';
+
+type PrReviewDiscussionTabProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ /**
+ * Invoked by the empty state ("No review comments yet") to switch
+ * to the Files tab. Optional; if absent, the CTA is hidden.
+ */
+ readonly onRequestFiles?: () => void;
+};
+
+const SKELETON_ROW_COUNT = 4;
+const DISCUSSION_LIST_CONTENT_STYLE = { paddingTop: 12 };
+
+export function PrReviewDiscussionTab({
+ owner,
+ repo,
+ number,
+ onRequestFiles,
+}: PrReviewDiscussionTabProps) {
+ const { query, threads, firstPageErrorState, laterPageError } = usePrReviewDiscussionThreads({
+ owner,
+ repo,
+ number,
+ });
+
+ // ── First-page error / terminal states ─────────────────────────────
+ if (firstPageErrorState) {
+ if (firstPageErrorState.kind === 'permission') {
+ return (
+
+ );
+ }
+ if (firstPageErrorState.kind === 'not-found') {
+ return (
+
+ );
+ }
+ if (firstPageErrorState.kind === 'reconnect') {
+ return (
+
+
+
+ );
+ }
+ // retryable
+ return (
+ {
+ void query.refetch();
+ }}
+ isRetrying={query.isFetching}
+ />
+ );
+ }
+
+ // ── Loading (first page in flight) ─────────────────────────────────
+ if (query.isPending) {
+ return (
+
+ {Array.from({ length: SKELETON_ROW_COUNT }).map((_, index) => (
+ // eslint-disable-next-line react/no-array-index-key -- skeleton placeholders have no stable id
+
+
+
+
+
+
+ ))}
+
+ );
+ }
+
+ // ── Empty ──────────────────────────────────────────────────────────
+ if (threads.length === 0) {
+ return (
+
+
+ Review files
+
+ ) : null
+ }
+ />
+
+ );
+ }
+
+ // ── Happy / paginated list ─────────────────────────────────────────
+ const groups = groupThreadsByPath(threads);
+ // Flatten the grouped list into a single list with separator
+ // rows between groups. Separator rows have `type: 'separator'`
+ // and the threads have `type: 'thread'`.
+ const listItems: ListItem[] = [];
+ for (const group of groups) {
+ if (groups.length > 1) {
+ listItems.push({ type: 'separator', path: group.path });
+ }
+ for (const thread of group.threads) {
+ listItems.push({ type: 'thread', thread });
+ }
+ }
+
+ return (
+ item.type}
+ renderItem={({ item }) => {
+ if (item.type === 'separator') {
+ return ;
+ }
+ return (
+
+
+
+ );
+ }}
+ contentContainerStyle={DISCUSSION_LIST_CONTENT_STYLE}
+ keyboardShouldPersistTaps="handled"
+ automaticallyAdjustKeyboardInsets
+ ListFooterComponent={
+ {
+ void query.fetchNextPage();
+ }}
+ onRetryLoadMore={() => {
+ void query.refetch();
+ }}
+ />
+ }
+ />
+ );
+}
+
+// ── List item shape ──────────────────────────────────────────────────
+
+type ListItem =
+ | { readonly type: 'separator'; readonly path: string }
+ | {
+ readonly type: 'thread';
+ readonly thread: ReviewThread;
+ };
+
+function keyForItem(item: ListItem): string {
+ return item.type === 'separator' ? `sep:${item.path}` : `thread:${item.thread.threadId}`;
+}
+
+function GroupSeparator({ path }: Readonly<{ path: string }>) {
+ return (
+
+
+ {path}
+
+
+
+ );
+}
+
+// ── Footer (Load more / error row) ───────────────────────────────────
+
+type ListFooterProps = {
+ readonly hasNextPage: boolean;
+ readonly isFetchingNextPage: boolean;
+ readonly laterPageError: boolean;
+ readonly onLoadMore: () => void;
+ readonly onRetryLoadMore: () => void;
+};
+
+function ListFooter({
+ hasNextPage,
+ isFetchingNextPage,
+ laterPageError,
+ onLoadMore,
+ onRetryLoadMore,
+}: Readonly) {
+ if (laterPageError) {
+ return (
+
+
+ Could not load more comments.
+
+
+
+ );
+ }
+ if (!hasNextPage) {
+ return ;
+ }
+ return (
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx
new file mode 100644
index 0000000000..21c54415b0
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-entry-screen.tsx
@@ -0,0 +1,204 @@
+import { useFocusEffect, useRouter } from 'expo-router';
+import { ChevronRight, Clock, Link2, SearchX } from 'lucide-react-native';
+import { type ReactNode, useCallback, useRef, useState } from 'react';
+import { ActivityIndicator, Pressable, ScrollView, TextInput, View } from 'react-native';
+
+import { EmptyState } from '@/components/empty-state';
+import { ScreenHeader } from '@/components/screen-header';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { parseGitHubPrUrl } from '@/lib/github-pr-url';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { getPrReviewPath } from '@/lib/profile-agent-navigation';
+import { getRecentPrs, type RecentPr, upsertRecentPr } from '@/lib/pr-review/recent-prs';
+
+const URL_PLACEHOLDER = 'https://github.com/owner/repo/pull/123';
+
+export function PrReviewEntryScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ // Uncontrolled iOS input — keep the raw text in a ref so the submit
+ // handler reads the latest value without re-rendering on every
+ // keystroke. State is only for derived UI (whether there's any text,
+ // whether the user already tried an invalid value). The TextInput
+ // component ref is for focus() (e.g. the empty-state CTA).
+ const inputRef = useRef(null);
+ const inputValueRef = useRef('');
+ const [hasInput, setHasInput] = useState(false);
+ const [invalid, setInvalid] = useState(false);
+ const [recent, setRecent] = useState(null);
+
+ useFocusEffect(
+ useCallback(() => {
+ const state = { cancelled: false };
+ void (async () => {
+ const list = await getRecentPrs();
+ if (!state.cancelled) {
+ setRecent(list);
+ }
+ })();
+ return () => {
+ state.cancelled = true;
+ };
+ }, [])
+ );
+
+ const handleSubmit = async () => {
+ const raw = inputValueRef.current;
+ const parsed = parseGitHubPrUrl(raw.trim());
+ if (!parsed) {
+ setInvalid(true);
+ return;
+ }
+ setInvalid(false);
+ // Title is backfilled on first successful load (S5).
+ await upsertRecentPr({
+ owner: parsed.owner,
+ repo: parsed.repo,
+ number: parsed.number,
+ title: '',
+ lastOpenedAt: Date.now(),
+ });
+ router.push(getPrReviewPath(parsed.owner, parsed.repo, parsed.number));
+ };
+
+ const focusInput = () => {
+ inputRef.current?.focus();
+ };
+
+ const handleRecentPress = async (entry: RecentPr) => {
+ await upsertRecentPr({
+ ...entry,
+ lastOpenedAt: Date.now(),
+ });
+ router.push(getPrReviewPath(entry.owner, entry.repo, entry.number));
+ };
+
+ let helper: ReactNode = null;
+ if (invalid) {
+ helper = Not a GitHub pull request link;
+ } else if (!hasInput) {
+ helper = (
+
+ Paste a link like {URL_PLACEHOLDER}
+
+ );
+ }
+
+ let recentsBody: ReactNode = null;
+ if (recent === null) {
+ recentsBody = ;
+ } else if (recent.length === 0) {
+ recentsBody = (
+
+ Paste a PR link
+
+ }
+ />
+ );
+ } else {
+ recentsBody = (
+
+ {recent.map((entry, index) => {
+ const isLast = index === recent.length - 1;
+ return (
+ {
+ void handleRecentPress(entry);
+ }}
+ className={`flex-row items-center gap-3 px-3 py-3 active:opacity-70 ${
+ isLast ? '' : 'border-b-[0.5px] border-hair-soft'
+ }`}
+ >
+
+
+ {entry.title || `${entry.owner}/${entry.repo}#${entry.number}`}
+
+
+ {entry.owner}/{entry.repo}#{entry.number}
+
+
+
+
+ );
+ })}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Paste a PR link
+
+
+ {
+ // Don't setState on every keystroke; track only whether the
+ // input has any text and whether the user has tried to
+ // submit an invalid value. The raw value lives in the ref
+ // so handleSubmit reads the latest text without re-rendering.
+ inputValueRef.current = value;
+ setHasInput(value.length > 0);
+ if (invalid) {
+ setInvalid(false);
+ }
+ }}
+ // explicit line-height so the placeholder + typed text render
+ // at the same vertical position on every iOS version.
+ className="rounded-md border border-border bg-card px-3 py-3 text-base text-foreground leading-[22px]"
+ accessibilityLabel="GitHub pull request URL"
+ returnKeyType="go"
+ onSubmitEditing={() => {
+ void handleSubmit();
+ }}
+ />
+ {helper}
+
+
+
+
+
+
+
+ Recent
+
+
+ {recentsBody}
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx
new file mode 100644
index 0000000000..2b9a02a03d
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-file-navigator-screen.tsx
@@ -0,0 +1,89 @@
+import { useQuery } from '@tanstack/react-query';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { type ReactNode } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+
+import { PrDiffFileNavigator } from '@/components/pr-review/diff/pr-diff-file-navigator';
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { parseParam } from '@/lib/route-params';
+import { useTRPC } from '@/lib/trpc';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+};
+
+/**
+ * File-navigator formSheet route. Fetches the PR overview for the head SHA
+ * (the navigator keys viewed state + the diff list on it) and mounts the S6c
+ * navigator content. Rendered inside the `[number]` layout's formSheet stack.
+ */
+export function PrReviewFileNavigatorScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const params = useLocalSearchParams();
+ const owner = parseParam(params.owner) ?? '';
+ const repo = parseParam(params.repo) ?? '';
+ const rawNumber = parseParam(params.number) ?? '';
+ const number = Number.parseInt(rawNumber, 10);
+
+ const trpc = useTRPC();
+ const pr = useQuery(
+ trpc.githubPrReview.getPullRequest.queryOptions(
+ { owner, repo, number },
+ { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 }
+ )
+ );
+
+ let content: ReactNode = null;
+ if (pr.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (pr.isError || !pr.data) {
+ content = (
+
+ {
+ void pr.refetch();
+ }}
+ isRetrying={pr.isFetching}
+ />
+
+ );
+ } else {
+ content = (
+ {
+ router.back();
+ }}
+ />
+ );
+ }
+
+ return (
+
+ {
+ router.back();
+ }}
+ />
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx
new file mode 100644
index 0000000000..87f015aa75
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-files-tab.tsx
@@ -0,0 +1,38 @@
+import { PrReviewFileList } from '@/components/pr-review/diff/pr-diff-file-list';
+
+type PrReviewFilesTabProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ /** Head SHA at the time the Overview was loaded — keeps the file list stable. */
+ readonly headSha: string;
+ /** File count from the Overview DTO for the >3,000-file truncation banner. */
+ readonly changedFiles: number;
+ /** Invoked by the empty state ("0 changed files") to jump back to Overview. */
+ readonly onRequestOverview?: () => void;
+};
+
+/**
+ * Files tab: hosts the S6b diff file list (a virtualized FlashList, so the
+ * screen renders this outside its Overview ScrollView). S6c layers the file
+ * navigator sheet and the tablet unified/side-by-side toggle on top of this.
+ */
+export function PrReviewFilesTab({
+ owner,
+ repo,
+ number,
+ headSha,
+ changedFiles,
+ onRequestOverview,
+}: PrReviewFilesTabProps) {
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx
new file mode 100644
index 0000000000..64f59878c1
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-merge-screen.tsx
@@ -0,0 +1,109 @@
+import { useQuery } from '@tanstack/react-query';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { type ReactNode } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { PrMergeSheet } from '@/components/pr-review/merge/pr-merge-sheet';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { type PrMergeMethod } from '@/lib/pr-review/merge/merge-blocked-reasons';
+import { parseParam } from '@/lib/route-params';
+import { useTRPC } from '@/lib/trpc';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+ mode?: string;
+ method?: string;
+};
+
+const MERGE_METHODS = new Set(['merge', 'squash', 'rebase']);
+
+/**
+ * Merge formSheet route. Reads the PR + mode/method from params, fetches the
+ * overview so the sheet has the repo settings + head SHA fence, and mounts the
+ * S8 merge sheet. Rendered inside the `[number]` layout's formSheet stack.
+ */
+export function PrReviewMergeScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const params = useLocalSearchParams();
+ const owner = parseParam(params.owner) ?? '';
+ const repo = parseParam(params.repo) ?? '';
+ const rawNumber = parseParam(params.number) ?? '';
+ const number = Number.parseInt(rawNumber, 10);
+ const mode = params.mode === 'enable-auto-merge' ? 'enable-auto-merge' : 'merge';
+ const method: PrMergeMethod = MERGE_METHODS.has(params.method as PrMergeMethod)
+ ? (params.method as PrMergeMethod)
+ : 'merge';
+
+ const trpc = useTRPC();
+ const pr = useQuery(
+ trpc.githubPrReview.getPullRequest.queryOptions(
+ { owner, repo, number },
+ { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 }
+ )
+ );
+
+ let content: ReactNode = null;
+ if (pr.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (pr.isError || !pr.data) {
+ content = (
+
+ {
+ void pr.refetch();
+ }}
+ isRetrying={pr.isFetching}
+ />
+
+ );
+ } else {
+ content = (
+ {
+ await pr.refetch();
+ }}
+ onDismiss={() => {
+ router.back();
+ }}
+ />
+ );
+ }
+
+ return (
+
+ {
+ router.back();
+ }}
+ />
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx
new file mode 100644
index 0000000000..905eb53f4f
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-overview-parts.tsx
@@ -0,0 +1,178 @@
+import {
+ GitBranch,
+ GitCommit,
+ GitMerge,
+ GitPullRequest,
+ type LucideIcon,
+ Plus,
+} from 'lucide-react-native';
+import { View } from 'react-native';
+
+import { Image } from '@/components/ui/image';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { cn } from '@/lib/utils';
+
+type PrStateChipTone = 'good' | 'warn' | 'muted' | 'destructive';
+
+type PrStateChipDescriptor = {
+ label: string;
+ tone: PrStateChipTone;
+ icon: LucideIcon;
+};
+
+export function describePrState(args: {
+ state: 'open' | 'closed' | 'merged';
+ draft: boolean;
+ reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null;
+}): PrStateChipDescriptor {
+ if (args.state === 'merged') {
+ return { label: 'Merged', tone: 'muted', icon: GitMerge };
+ }
+ if (args.state === 'closed') {
+ return { label: 'Closed', tone: 'muted', icon: GitPullRequest };
+ }
+ if (args.draft) {
+ return { label: 'Draft', tone: 'muted', icon: GitPullRequest };
+ }
+ // state === 'open'
+ if (args.reviewDecision === 'APPROVED') {
+ return { label: 'Open · Approved', tone: 'good', icon: GitPullRequest };
+ }
+ if (args.reviewDecision === 'CHANGES_REQUESTED') {
+ return { label: 'Open · Changes requested', tone: 'destructive', icon: GitPullRequest };
+ }
+ if (args.reviewDecision === 'REVIEW_REQUIRED') {
+ return { label: 'Open · Review required', tone: 'warn', icon: GitPullRequest };
+ }
+ return { label: 'Open', tone: 'muted', icon: GitPullRequest };
+}
+
+// Theme colors are CSS variables — Tailwind opacity modifiers like
+// `bg-good/10` don't work on them. The chip uses a flat muted background
+// and lets the foreground color carry the tone so it stays legible in
+// both themes without needing per-tone backgrounds.
+const TONE_FG_CLASS: Record = {
+ good: 'text-good',
+ warn: 'text-warn',
+ destructive: 'text-destructive',
+ muted: 'text-muted-foreground',
+};
+
+export function PrStateChip({ descriptor }: Readonly<{ descriptor: PrStateChipDescriptor }>) {
+ const Icon = descriptor.icon;
+ return (
+
+
+
+ {descriptor.label}
+
+
+ );
+}
+
+export function PrAuthorRow({
+ author,
+}: Readonly<{ author: { login: string; avatarUrl: string | null } | null }>) {
+ if (!author) {
+ return (
+
+
+
+ Unknown author
+
+
+ );
+ }
+ return (
+
+ {author.avatarUrl ? (
+
+ ) : (
+
+ )}
+
+ {author.login}
+
+
+ );
+}
+
+export function PrRefsRow({
+ baseRef,
+ headRef,
+ headRepoFullName,
+ isCrossRepo,
+}: Readonly<{
+ baseRef: string;
+ headRef: string;
+ headRepoFullName: string | null;
+ isCrossRepo: boolean;
+}>) {
+ const colors = useThemeColors();
+ return (
+
+
+
+ {headRepoFullName && isCrossRepo ? `${headRepoFullName}:` : ''}
+ {headRef}
+
+
+ ←
+
+
+ {baseRef}
+
+
+ );
+}
+
+export function PrCountsLine({
+ commits,
+ changedFiles,
+ additions,
+ deletions,
+}: Readonly<{
+ commits: number;
+ changedFiles: number;
+ additions: number;
+ deletions: number;
+}>) {
+ const colors = useThemeColors();
+ return (
+
+
+
+
+ {commits.toLocaleString()} {commits === 1 ? 'commit' : 'commits'}
+
+
+
+
+
+ {changedFiles.toLocaleString()} {changedFiles === 1 ? 'file' : 'files'}
+
+
+
+
+ {additions.toLocaleString()}
+
+ / −{deletions.toLocaleString()}
+
+
+
+ );
+}
+
+function localizeNumber(n: number): string {
+ return n.toLocaleString();
+}
+
+export function formatPrCounts(additions: number, deletions: number): string {
+ return `+${localizeNumber(additions)} / −${localizeNumber(deletions)}`;
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-overview.tsx b/apps/mobile/src/components/pr-review/pr-review-overview.tsx
new file mode 100644
index 0000000000..61f79b42a6
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-overview.tsx
@@ -0,0 +1,217 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { GitPullRequest } from 'lucide-react-native';
+import { useCallback } from 'react';
+import { View } from 'react-native';
+import * as WebBrowser from 'expo-web-browser';
+
+import { EmptyState } from '@/components/empty-state';
+import { QueryError } from '@/components/query-error';
+import { MarkdownText } from '@/components/agents/markdown-text';
+import { PrReviewChecksSection } from '@/components/pr-review/pr-review-checks-section';
+import { PrMergeSection } from '@/components/pr-review/merge/pr-merge-section';
+import {
+ describePrState,
+ formatPrCounts,
+ PrAuthorRow,
+ PrCountsLine,
+ PrRefsRow,
+ PrStateChip,
+} from '@/components/pr-review/pr-review-overview-parts';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { getGitHubIntegrationUrl } from '@/lib/agent-github-integration';
+import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state';
+import { WEB_BASE_URL } from '@/lib/config';
+import { useTRPC } from '@/lib/trpc';
+
+type PrReviewOverviewProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+ /**
+ * True when this tab is the live, visible body in the tab container.
+ * Reserved for a future focus-driven refetch — the Overview is
+ * otherwise a self-contained consumer of `getPullRequest` + the
+ * inner `listChecks` consumer in `PrReviewChecksSection`.
+ */
+ readonly isActive: boolean;
+};
+
+function OverviewSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function PrReviewOverview({
+ owner,
+ repo,
+ number,
+ isActive: _isActive,
+}: PrReviewOverviewProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+
+ const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number }));
+
+ const handleReconnect = useCallback(() => {
+ // A PRECONDITION_FAILED here means the gate's authorization is no
+ // longer valid even though the gate already passed. Forcing a
+ // refetch of the gate's query will either (a) flip it to
+ // disconnected/revoked, in which case the gate renders its own
+ // empty state with the Connect CTA, or (b) return connected, in
+ // which case the user can tap Retry to reload the PR.
+ void queryClient.invalidateQueries({
+ queryKey: trpc.githubApps.getUserAuthorization.queryKey(),
+ });
+ }, [queryClient, trpc.githubApps.getUserAuthorization]);
+
+ const handleInstallApp = useCallback(() => {
+ void WebBrowser.openBrowserAsync(getGitHubIntegrationUrl(WEB_BASE_URL));
+ }, []);
+
+ if (pr.isLoading) {
+ return ;
+ }
+
+ if (pr.isError) {
+ const state = classifyPrReviewQueryState(pr.error);
+
+ if (state.kind === 'not-found') {
+ return (
+
+ Install the Kilo GitHub App
+
+ }
+ />
+ );
+ }
+ if (state.kind === 'permission') {
+ // Terminal — no CTA. The user has no recourse from this screen.
+ return (
+
+ );
+ }
+ if (state.kind === 'reconnect') {
+ return (
+
+ Check connection
+
+ }
+ />
+ );
+ }
+ // retryable
+ return (
+ {
+ void pr.refetch();
+ }}
+ isRetrying={pr.isFetching}
+ />
+ );
+ }
+
+ const data = pr.data;
+ if (!data) {
+ // Belt-and-suspenders guard for TS — the isLoading + isError branches
+ // above already cover the runtime cases. If we got here, tanstack is
+ // reporting neither loading nor error but also has no data (e.g.
+ // enabled=false with no cached value). Render the skeleton rather
+ // than dereferencing an undefined DTO.
+ return ;
+ }
+ const chip = describePrState({
+ state: data.state,
+ draft: data.draft,
+ reviewDecision: data.reviewDecision,
+ });
+
+ return (
+
+
+
+
+ {data.title}
+
+
+
+
+
+
+
+
+ Description
+
+ {data.bodyMarkdown && data.bodyMarkdown.trim().length > 0 ? (
+
+
+
+ ) : (
+
+ No description provided.
+
+ )}
+
+
+
+
+ {
+ await pr.refetch();
+ }}
+ isRefetching={pr.isFetching}
+ />
+
+
+ {formatPrCounts(data.counts.additions, data.counts.deletions)} · head{' '}
+ {data.headSha.slice(0, 7)}
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx
new file mode 100644
index 0000000000..6315100bd3
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-reconnect-notice.tsx
@@ -0,0 +1,39 @@
+import { useQueryClient } from '@tanstack/react-query';
+import { View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useTRPC } from '@/lib/trpc';
+
+/**
+ * Shared reconnect affordance for PR Review surfaces. A
+ * PRECONDITION_FAILED on a query or mutation means the gate's GitHub
+ * authorization is no longer valid even though the gate passed. We
+ * force a refetch of the gate's query so the wrapping
+ * `PrReviewConnectGate` renders its own connect/reconnect CTA. The
+ * caller owns section/tab framing; this component is just the
+ * message + button.
+ */
+export function PrReviewReconnectNotice() {
+ const queryClient = useQueryClient();
+ const trpc = useTRPC();
+
+ const handleReconnect = () => {
+ void queryClient.invalidateQueries({
+ queryKey: trpc.githubApps.getUserAuthorization.queryKey(),
+ });
+ };
+
+ return (
+
+ GitHub connection expired
+
+ Your GitHub connection is no longer valid. Re-check your connection — you'll be
+ prompted to reconnect if needed.
+
+
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx
new file mode 100644
index 0000000000..5ca9e00cb7
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-review-submit-screen.tsx
@@ -0,0 +1,83 @@
+import { useQuery } from '@tanstack/react-query';
+import { useLocalSearchParams, useRouter } from 'expo-router';
+import { type ReactNode } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+
+import { PrReviewSubmit } from '@/components/pr-review/pr-review-submit';
+import { QueryError } from '@/components/query-error';
+import { ScreenHeader } from '@/components/screen-header';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { parseParam } from '@/lib/route-params';
+import { useTRPC } from '@/lib/trpc';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+};
+
+export function PrReviewReviewSubmitScreen() {
+ const router = useRouter();
+ const colors = useThemeColors();
+ const params = useLocalSearchParams();
+ const owner = parseParam(params.owner) ?? '';
+ const repo = parseParam(params.repo) ?? '';
+ const rawNumber = parseParam(params.number) ?? '';
+ const number = Number.parseInt(rawNumber, 10);
+
+ const trpc = useTRPC();
+ const pr = useQuery(
+ trpc.githubPrReview.getPullRequest.queryOptions(
+ { owner, repo, number },
+ { enabled: Boolean(owner) && Boolean(repo) && Number.isInteger(number) && number > 0 }
+ )
+ );
+
+ let content: ReactNode = null;
+ if (pr.isLoading) {
+ content = (
+
+
+
+ );
+ } else if (pr.isError || !pr.data) {
+ content = (
+
+ {
+ void pr.refetch();
+ }}
+ isRetrying={pr.isFetching}
+ />
+
+ );
+ } else {
+ content = (
+ {
+ router.back();
+ }}
+ />
+ );
+ }
+
+ return (
+
+ {
+ router.back();
+ }}
+ />
+ {content}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
new file mode 100644
index 0000000000..164b35ea2f
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
@@ -0,0 +1,145 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+import { type ReactNode, useCallback, useEffect, useState } from 'react';
+import { RefreshControl, ScrollView, View } from 'react-native';
+
+import { PrReviewDiscussionTab } from '@/components/pr-review/pr-review-discussion-tab';
+import { PrReviewFilesTab } from '@/components/pr-review/pr-review-files-tab';
+import { PrReviewOverview } from '@/components/pr-review/pr-review-overview';
+import {
+ type PrReviewTabId,
+ PrReviewTabSelector,
+} from '@/components/pr-review/pr-review-tab-selector';
+import { ScreenHeader } from '@/components/screen-header';
+import { upsertRecentPr } from '@/lib/pr-review/recent-prs';
+import { useTRPC } from '@/lib/trpc';
+
+type PrReviewScreenProps = {
+ readonly owner: string;
+ readonly repo: string;
+ readonly number: number;
+};
+
+/**
+ * Tab shell for the PR review surface. S5 owns:
+ * - the tab container API (PrReviewTabSelector + per-tab body slots)
+ * - the local tab state
+ * - pull-to-refresh across the Overview + Checks queries
+ * - the recents title backfill (upsertRecentPr with the real title
+ * on the first successful `getPullRequest`).
+ *
+ * The screen intentionally fetches the PR DTO once and passes the
+ * `headSha` and `changedFiles` down to the Files tab so the placeholder
+ * can show useful info and S6b can drop in without a new fetch layer.
+ * S6b and S7b own the file/diff and discussion bodies respectively;
+ * S8 owns the merge section that mounts in the slot inside
+ * `PrReviewOverview`.
+ */
+export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const [tab, setTab] = useState('overview');
+ const [refreshing, setRefreshing] = useState(false);
+
+ // The screen owns the PR query so it can drive the recents backfill
+ // and pass `headSha` / `changedFiles` to the Files tab. The Overview
+ // re-uses the same query — tanstack-query dedupes by key, so this is
+ // a single network round-trip even though both components subscribe.
+ const pr = useQuery(trpc.githubPrReview.getPullRequest.queryOptions({ owner, repo, number }));
+
+ // Recents title backfill. S4b left the title empty so the recents row
+ // can be written before the PR loads. Once we have the real title,
+ // upsert it so the recents list shows it next time.
+ useEffect(() => {
+ const data = pr.data;
+ if (!data?.title) {
+ return;
+ }
+ void upsertRecentPr({
+ owner,
+ repo,
+ number,
+ title: data.title,
+ lastOpenedAt: Date.now(),
+ });
+ }, [pr.data, owner, repo, number]);
+
+ const handleRefresh = useCallback(() => {
+ void (async () => {
+ setRefreshing(true);
+ try {
+ const headSha = pr.data?.headSha;
+ await Promise.all([
+ queryClient.invalidateQueries({
+ queryKey: trpc.githubPrReview.getPullRequest.queryKey({
+ owner,
+ repo,
+ number,
+ }),
+ }),
+ // Only invalidate checks when we know the head SHA; invalidating with
+ // an empty ref would target a key that never matches the live query.
+ ...(headSha
+ ? [
+ queryClient.invalidateQueries({
+ queryKey: trpc.githubPrReview.listChecks.queryKey({ owner, repo, ref: headSha }),
+ }),
+ ]
+ : []),
+ ]);
+ } finally {
+ setRefreshing(false);
+ }
+ })();
+ }, [queryClient, trpc, owner, repo, number, pr.data?.headSha]);
+
+ // Each tab owns its own scroll: Overview is a ScrollView with
+ // pull-to-refresh; the Files tab hosts a virtualized FlashList and must
+ // NOT be nested inside a ScrollView.
+ let body: ReactNode = null;
+ if (tab === 'overview') {
+ body = (
+ }
+ >
+
+
+ );
+ } else if (tab === 'files') {
+ body = (
+ {
+ setTab('overview');
+ }}
+ />
+ );
+ } else {
+ body = (
+ {
+ setTab('files');
+ }}
+ />
+ );
+ }
+
+ return (
+
+
+
+
+
+ {body}
+
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/pr-review-submit.tsx b/apps/mobile/src/components/pr-review/pr-review-submit.tsx
new file mode 100644
index 0000000000..2ccfd1ff0f
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-submit.tsx
@@ -0,0 +1,262 @@
+// S7a review-submit content component. The orchestrator mounts this
+// inside the `review-submit.tsx` route (S4b left a thin stub there);
+// the route supplies the route-params; this component owns the form
+// body, the event radio, and the single batched submit.
+//
+// The sheet drains the `PendingReviewProvider` queue into ONE
+// `submitReview` call (per the S3 contract). The head SHA submitted
+// is the LATEST one — the per-item `commitSha` is only used by the
+// sheet's "may be outdated" hint. The queue is cleared on success
+// and retained on failure so the user can decide what to drop or retry.
+//
+// Submit failures are classified: 422/BAD_REQUEST validation errors
+// (approve-own-PR, stale) are non-retryable and remove the submit
+// affordance until the user changes the event or body; everything
+// else is retryable and keeps the submit button enabled.
+//
+// Toasts paint behind formSheets on iOS, so the mutation hook toasts
+// `onError` AND the sheet renders an inline error box.
+
+import * as Haptics from 'expo-haptics';
+import { type RefObject, useEffect, useRef, useState } from 'react';
+import { ScrollView, TextInput, View } from 'react-native';
+
+import { Button } from '@/components/ui/button';
+import { PillGroup } from '@/components/security-agent/settings-pill-group';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import {
+ buildSubmitReviewInput,
+ type ReviewEvent,
+} from '@/lib/pr-review/build-submit-review-input';
+import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+import { usePendingReview } from '@/lib/pr-review/pending-review-provider';
+import { useSubmitReviewMutation } from '@/lib/pr-review/use-pr-review-mutations';
+import { cn } from '@/lib/utils';
+
+type PrReviewSubmitProps = Readonly<{
+ owner: string;
+ repo: string;
+ number: number;
+ /** Current PR head SHA — submitted as `commitSha` for the review. */
+ headSha: string;
+ /** Invoked after a successful submit or a cancel. */
+ onDismiss: () => void;
+}>;
+
+const EVENT_OPTIONS: readonly { value: ReviewEvent; label: string }[] = [
+ { value: 'COMMENT', label: 'Comment' },
+ { value: 'REQUEST_CHANGES', label: 'Request changes' },
+ { value: 'APPROVE', label: 'Approve' },
+];
+
+export function PrReviewSubmit(props: PrReviewSubmitProps) {
+ const { owner, repo, number, headSha, onDismiss } = props;
+ const pending = usePendingReview();
+ const submitReview = useSubmitReviewMutation({ owner, repo, number });
+
+ const [event, setEvent] = useState('COMMENT');
+ const [inlineError, setInlineError] = useState(null);
+ const [inlineErrorKind, setInlineErrorKind] = useState<
+ 'retryable' | 'non-retryable' | 'reconnect' | null
+ >(null);
+
+ // iOS uncontrolled pattern: body lives in a ref, the input's visible
+ // value is set via defaultValue once. No `value` + state.
+ const bodyRef = useRef('');
+ const bodyInputRef = useRef(null);
+
+ const isSubmitting = submitReview.isPending;
+ const queuedCount = pending.items.length;
+
+ // Flag when any queued item was queued against a different head SHA
+ // than the current one. Submission still uses the latest head SHA.
+ const hasStaleItems = pending.items.some(item => item.commitSha !== headSha);
+
+ useEffect(() => {
+ if (submitReview.error) {
+ const classification = classifyPrReviewMutationError(submitReview.error);
+ if (classification.kind === 'bad-request' || classification.kind === 'forbidden') {
+ setInlineError(
+ classification.kind === 'forbidden'
+ ? "You don't have permission to submit this review."
+ : "This review can't be submitted as is. The PR may have changed, or you can't approve your own pull request."
+ );
+ setInlineErrorKind('non-retryable');
+ } else if (classification.kind === 'reconnect') {
+ setInlineError('GitHub connection expired.');
+ setInlineErrorKind('reconnect');
+ } else {
+ setInlineError('Could not submit review. Check your connection and try again.');
+ setInlineErrorKind('retryable');
+ }
+ }
+ }, [submitReview.error]);
+
+ async function handleSubmit() {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ try {
+ const body = bodyRef.current.trim();
+ await submitReview.mutateAsync(
+ buildSubmitReviewInput({
+ owner,
+ repo,
+ number,
+ event,
+ ...(body.length > 0 ? { body } : {}),
+ commitSha: headSha,
+ items: pending.items,
+ })
+ );
+ pending.clear();
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ onDismiss();
+ } catch {
+ // The effect above classifies the mutation error into inlineError;
+ // swallow here to avoid an unhandled promise rejection.
+ }
+ }
+
+ function handleCancel() {
+ if (isSubmitting) {
+ return;
+ }
+ onDismiss();
+ }
+
+ return (
+
+
+ {
+ setEvent(next);
+ setInlineError(null);
+ setInlineErrorKind(null);
+ }}
+ />
+
+ Summary (optional)
+ {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ }}
+ />
+
+
+
+
+ {queuedCount} pending {queuedCount === 1 ? 'comment' : 'comments'}
+
+
+
+
+ {inlineError && inlineErrorKind !== 'reconnect' ? (
+
+ {inlineError}
+
+ ) : null}
+ {inlineErrorKind === 'reconnect' ? : null}
+
+
+
+
+
+
+
+ );
+}
+
+function PendingQueueHint({
+ queuedCount,
+ hasStaleItems,
+}: {
+ queuedCount: number;
+ hasStaleItems: boolean;
+}) {
+ let message = '';
+ if (queuedCount === 0) {
+ message = 'No comments queued. The review will be submitted with just the event above.';
+ } else if (hasStaleItems) {
+ message =
+ 'Some comments may be outdated because the PR head changed after they were queued. Submission will use the current head.';
+ } else {
+ message = 'All comments will be sent in a single batched request.';
+ }
+ return (
+
+ {message}
+
+ );
+}
+
+function ReviewBodyField({
+ bodyRef,
+ inputRef,
+ isDisabled,
+ onChange,
+}: {
+ bodyRef: RefObject;
+ inputRef: RefObject;
+ isDisabled: boolean;
+ onChange: () => void;
+}) {
+ const colors = useThemeColors();
+ return (
+ {
+ bodyRef.current = value;
+ onChange();
+ }}
+ multiline
+ textAlignVertical="top"
+ className={cn(
+ 'min-h-24 rounded-md border border-input bg-background px-3 py-2.5 text-sm leading-5 text-foreground',
+ 'focus:border-ring'
+ )}
+ />
+ );
+}
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
new file mode 100644
index 0000000000..f2e9b538d5
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/pr-review-tab-selector.tsx
@@ -0,0 +1,66 @@
+import * as Haptics from 'expo-haptics';
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { cn } from '@/lib/utils';
+
+export type PrReviewTabId = 'overview' | 'files' | 'discussion';
+
+type PrReviewTab = {
+ id: PrReviewTabId;
+ label: string;
+};
+
+const TABS: readonly PrReviewTab[] = [
+ { id: 'overview', label: 'Overview' },
+ { id: 'files', label: 'Files' },
+ { id: 'discussion', label: 'Discussion' },
+];
+
+type PrReviewTabSelectorProps = {
+ activeTab: PrReviewTabId;
+ onChange: (tab: PrReviewTabId) => void;
+};
+
+/**
+ * Horizontal pill row at the top of the PR review surface that picks
+ * between the Overview, Files, and Discussion tabs. S5 owns the API;
+ * S6b (Files body) and S7b (Discussion body) only ever render their
+ * respective tab bodies — the parent screen owns the tab state.
+ */
+export function PrReviewTabSelector({ activeTab, onChange }: PrReviewTabSelectorProps) {
+ return (
+
+ {TABS.map(tab => {
+ const active = tab.id === activeTab;
+ return (
+ {
+ if (active) {
+ return;
+ }
+ void Haptics.selectionAsync();
+ onChange(tab.id);
+ }}
+ className={cn(
+ 'flex-1 items-center justify-center rounded-md py-2 active:opacity-70',
+ active && 'bg-card shadow-sm shadow-black/5'
+ )}
+ >
+
+ {tab.label}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx
index de87cd3876..ad90b39c7d 100644
--- a/apps/mobile/src/components/profile-screen.tsx
+++ b/apps/mobile/src/components/profile-screen.tsx
@@ -3,6 +3,7 @@ import * as Application from 'expo-application';
import { type Href, useRouter } from 'expo-router';
import {
Building2,
+ GitMerge,
GitPullRequest,
KeyRound,
Lock,
@@ -30,7 +31,11 @@ import { showFeedbackPrompt } from '@/lib/feedback';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useOrganization } from '@/lib/organization-context';
-import { getCodeReviewerProfilePath, getProfileAgentScope } from '@/lib/profile-agent-navigation';
+import {
+ getCodeReviewerProfilePath,
+ getProfileAgentScope,
+ getPrReviewEntryPath,
+} from '@/lib/profile-agent-navigation';
import { getSecurityAgentPath } from '@/lib/security-agent';
import { useTRPC } from '@/lib/trpc';
@@ -161,6 +166,23 @@ export function ProfileScreen() {
/>
+ {/* PR Review */}
+
+
+ Reviews
+
+ {
+ router.push(getPrReviewEntryPath());
+ }}
+ />
+
+
{/* Organization */}
{organizationId != null && (
diff --git a/apps/mobile/src/lib/auth/auth-context.tsx b/apps/mobile/src/lib/auth/auth-context.tsx
index 1867cef55b..6be8061962 100644
--- a/apps/mobile/src/lib/auth/auth-context.tsx
+++ b/apps/mobile/src/lib/auth/auth-context.tsx
@@ -17,6 +17,8 @@ import { clearAgentModelPreference } from '@/lib/hooks/use-persisted-agent-model
import { clearReasoningPreference } from '@/lib/hooks/use-reasoning-preference';
import { clearLastActiveInstance } from '@/lib/last-active-instance';
import { resetPurchaseErrorToastDedup } from '@/lib/kilo-pass/use-store-kilo-pass-purchase';
+import { clearRecentPrs } from '@/lib/pr-review/recent-prs';
+import { clearViewedFiles } from '@/lib/pr-review/viewed-files';
import {
AUTH_TOKEN_KEY,
NOTIFICATION_PROMPT_SEEN_KEY,
@@ -66,6 +68,12 @@ export function AuthProvider({ children }: { readonly children: ReactNode }) {
await SecureStore.deleteItemAsync(SESSION_FILTERS_KEY);
await SecureStore.deleteItemAsync(NOTIFICATION_PROMPT_SEEN_KEY);
await clearLastActiveInstance();
+ // PR-review storage is keyed by (owner, repo, number) so it's not
+ // obviously user-scoped, but the user-facing recents list absolutely
+ // is — clear it so the next signed-in account doesn't inherit the
+ // previous user's recently-opened PRs.
+ await clearRecentPrs();
+ await clearViewedFiles();
clearAgentModelPreference();
clearReasoningPreference();
resetAnalyticsUser();
diff --git a/apps/mobile/src/lib/github-pr-url.test.ts b/apps/mobile/src/lib/github-pr-url.test.ts
new file mode 100644
index 0000000000..c121f19139
--- /dev/null
+++ b/apps/mobile/src/lib/github-pr-url.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseGitHubPrUrl } from './github-pr-url';
+
+describe('parseGitHubPrUrl', () => {
+ it('parses a canonical PR URL', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ });
+ });
+
+ it('parses a PR URL with the /files subpath', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42/files')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ });
+ });
+
+ it('parses a PR URL with a query string', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42?diff=split')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ });
+ });
+
+ it('parses a PR URL with a trailing slash', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42/')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ });
+ });
+
+ it('parses http URLs', () => {
+ expect(parseGitHubPrUrl('http://github.com/octocat/hello-world/pull/1')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 1,
+ });
+ });
+
+ it('parses www.github.com host', () => {
+ expect(parseGitHubPrUrl('https://www.github.com/octocat/hello-world/pull/7')).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 7,
+ });
+ });
+
+ it('parses a PR URL with a hash fragment', () => {
+ expect(
+ parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/42#discussion_r1')
+ ).toEqual({
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ });
+ });
+
+ it('returns null for an issue URL', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/issues/42')).toBeNull();
+ });
+
+ it('returns null for a tree URL', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/tree/main')).toBeNull();
+ });
+
+ it('returns null for a plain repo URL', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world')).toBeNull();
+ });
+
+ it('returns null for a non-GitHub host', () => {
+ expect(parseGitHubPrUrl('https://gitlab.com/octocat/hello-world/pull/42')).toBeNull();
+ });
+
+ it('returns null for a malformed URL', () => {
+ expect(parseGitHubPrUrl('not a url at all')).toBeNull();
+ });
+
+ it('returns null for a PR URL with a non-numeric number', () => {
+ expect(parseGitHubPrUrl('https://github.com/octocat/hello-world/pull/abc')).toBeNull();
+ });
+
+ it('returns null for an empty string', () => {
+ expect(parseGitHubPrUrl('')).toBeNull();
+ });
+
+ it('rejects owner or repo composed solely of dots', () => {
+ expect(parseGitHubPrUrl('https://github.com/./repo/pull/5')).toBeNull();
+ expect(parseGitHubPrUrl('https://github.com/owner/./pull/5')).toBeNull();
+ expect(parseGitHubPrUrl('https://github.com/../repo/pull/5')).toBeNull();
+ expect(parseGitHubPrUrl('https://github.com/owner/../pull/5')).toBeNull();
+ });
+
+ it('parses owners and repos with dots, dashes and underscores', () => {
+ expect(parseGitHubPrUrl('https://github.com/my.repo/a-b_c/pull/1')).toEqual({
+ owner: 'my.repo',
+ repo: 'a-b_c',
+ number: 1,
+ });
+ });
+});
diff --git a/apps/mobile/src/lib/github-pr-url.ts b/apps/mobile/src/lib/github-pr-url.ts
new file mode 100644
index 0000000000..4fa29addc3
--- /dev/null
+++ b/apps/mobile/src/lib/github-pr-url.ts
@@ -0,0 +1,45 @@
+type GitHubPrUrl = {
+ owner: string;
+ repo: string;
+ number: number;
+};
+
+const GITHUB_PR_PATTERN =
+ /^https?:\/\/(www\.)?github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)(?:[/?#][^\s]*)?$/i;
+
+function isValidIdentifier(value: string): boolean {
+ return value !== '.' && value !== '..';
+}
+
+/**
+ * Parse a GitHub pull request URL into its owner, repo and PR number.
+ *
+ * Matches `http(s)://(www.)github.com///pull/` and
+ * tolerates any trailing subpath (e.g. `/files`), query string and trailing
+ * slash. Returns `null` for non-PR URLs (issues, tree, plain repo), uppercase
+ * host, non-GitHub hosts, or malformed input.
+ */
+export function parseGitHubPrUrl(href: string): GitHubPrUrl | null {
+ if (typeof href !== 'string' || href.length === 0) {
+ return null;
+ }
+ const match = GITHUB_PR_PATTERN.exec(href);
+ if (!match) {
+ return null;
+ }
+ // The pattern guarantees the digit group is present when exec returns.
+ const numberString = match[4];
+ if (numberString === undefined) {
+ return null;
+ }
+ const number = Number.parseInt(numberString, 10);
+ if (!Number.isInteger(number) || number <= 0) {
+ return null;
+ }
+ const owner = match[2] ?? '';
+ const repo = match[3] ?? '';
+ if (!isValidIdentifier(owner) || !isValidIdentifier(repo)) {
+ return null;
+ }
+ return { owner, repo, number };
+}
diff --git a/apps/mobile/src/lib/hooks/is-tablet.ts b/apps/mobile/src/lib/hooks/is-tablet.ts
new file mode 100644
index 0000000000..633a43a41c
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/is-tablet.ts
@@ -0,0 +1,12 @@
+// Pure threshold check — kept separate from the hook so it can be unit-tested
+// without pulling in the React Native runtime (which doesn't parse under the
+// vitest/Vite environment).
+
+const TABLET_MIN_SHORT_EDGE = 600;
+
+export function isTabletFromDimensions(width: number, height: number, isPad: boolean): boolean {
+ if (isPad) {
+ return true;
+ }
+ return Math.min(width, height) >= TABLET_MIN_SHORT_EDGE;
+}
diff --git a/apps/mobile/src/lib/hooks/use-is-tablet.test.ts b/apps/mobile/src/lib/hooks/use-is-tablet.test.ts
new file mode 100644
index 0000000000..8fa909ffe5
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-is-tablet.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import { isTabletFromDimensions } from './is-tablet';
+
+describe('isTabletFromDimensions', () => {
+ it('reports iPad hardware as tablet regardless of dimensions', () => {
+ expect(isTabletFromDimensions(320, 480, true)).toBe(true);
+ expect(isTabletFromDimensions(1024, 768, true)).toBe(true);
+ });
+
+ it('reports phone-portrait dimensions (short edge < 600) as phone', () => {
+ expect(isTabletFromDimensions(390, 844, false)).toBe(false);
+ expect(isTabletFromDimensions(320, 568, false)).toBe(false);
+ });
+
+ it('reports phone-landscape (short edge < 600) as phone', () => {
+ // A landscape phone is wider than tall but still has a short edge < 600.
+ expect(isTabletFromDimensions(844, 390, false)).toBe(false);
+ });
+
+ it('reports a window with a 400-short-edge as phone', () => {
+ expect(isTabletFromDimensions(600, 400, false)).toBe(false);
+ expect(isTabletFromDimensions(400, 600, false)).toBe(false);
+ });
+
+ it('reports windows with a short edge >= 600 as tablet', () => {
+ expect(isTabletFromDimensions(600, 1200, false)).toBe(true);
+ expect(isTabletFromDimensions(1200, 600, false)).toBe(true);
+ expect(isTabletFromDimensions(1024, 768, false)).toBe(true);
+ expect(isTabletFromDimensions(768, 1024, false)).toBe(true);
+ });
+});
diff --git a/apps/mobile/src/lib/hooks/use-is-tablet.ts b/apps/mobile/src/lib/hooks/use-is-tablet.ts
new file mode 100644
index 0000000000..10639a20e7
--- /dev/null
+++ b/apps/mobile/src/lib/hooks/use-is-tablet.ts
@@ -0,0 +1,23 @@
+import { Platform, useWindowDimensions } from 'react-native';
+
+import { isTabletFromDimensions } from '@/lib/hooks/is-tablet';
+
+// `Platform.isPad` is a runtime constant on iOS hardware (not in the public
+// TS types for cross-platform code), so we narrow at the call site.
+function readIsPad(): boolean {
+ if (Platform.OS !== 'ios') {
+ return false;
+ }
+ return (Platform as { isPad?: boolean }).isPad === true;
+}
+
+/**
+ * Live-recomputed tablet/phone split. Use for layouts that branch on form
+ * factor (single-pane vs split-view, sheet detents, etc.). The threshold
+ * itself is a pure decision in `isTabletFromDimensions` so it stays
+ * unit-testable without RN's window/Platform globals.
+ */
+export function useIsTablet(): boolean {
+ const { width, height } = useWindowDimensions();
+ return isTabletFromDimensions(width, height, readIsPad());
+}
diff --git a/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts b/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts
new file mode 100644
index 0000000000..9b874a6fd6
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/build-submit-review-input.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSubmitReviewInput } from '@/lib/pr-review/build-submit-review-input';
+import { type PendingReviewItem } from '@/lib/pr-review/pending-review-provider';
+
+function makeItem(overrides: Partial = {}): PendingReviewItem {
+ return {
+ id: 'id-1',
+ path: 'src/lib.ts',
+ side: 'RIGHT',
+ line: 7,
+ body: 'Looks good.',
+ commitSha: 'head-1',
+ ...overrides,
+ };
+}
+
+describe('buildSubmitReviewInput', () => {
+ it('maps a single-line comment without a summary', () => {
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'COMMENT',
+ commitSha: 'head-2',
+ items: [makeItem()],
+ });
+ expect(input).toEqual({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'COMMENT',
+ commitSha: 'head-2',
+ comments: [
+ {
+ path: 'src/lib.ts',
+ line: 7,
+ side: 'RIGHT',
+ body: 'Looks good.',
+ },
+ ],
+ });
+ });
+
+ it('includes startLine and startSide together for multi-line comments', () => {
+ const item = makeItem({ startLine: 5, line: 7, side: 'RIGHT' });
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'COMMENT',
+ commitSha: 'head-2',
+ items: [item],
+ });
+ expect(input.comments?.[0]).toEqual({
+ path: 'src/lib.ts',
+ line: 7,
+ side: 'RIGHT',
+ startLine: 5,
+ startSide: 'RIGHT',
+ body: 'Looks good.',
+ });
+ });
+
+ it('omits startLine/startSide for single-line comments', () => {
+ const item = makeItem({ side: 'LEFT' });
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'COMMENT',
+ commitSha: 'head-2',
+ items: [item],
+ });
+ const comment = input.comments?.[0];
+ expect(comment).not.toHaveProperty('startLine');
+ expect(comment).not.toHaveProperty('startSide');
+ expect(comment).toEqual({
+ path: 'src/lib.ts',
+ line: 7,
+ side: 'LEFT',
+ body: 'Looks good.',
+ });
+ });
+
+ it('includes a non-empty review body', () => {
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'APPROVE',
+ body: 'Nice work.',
+ commitSha: 'head-2',
+ items: [],
+ });
+ expect(input.body).toBe('Nice work.');
+ expect(input.comments).toBeUndefined();
+ });
+
+ it('omits an empty review body', () => {
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'APPROVE',
+ body: ' ',
+ commitSha: 'head-2',
+ items: [makeItem()],
+ });
+ expect(input).not.toHaveProperty('body');
+ });
+
+ it('omits comments when the queue is empty', () => {
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'APPROVE',
+ commitSha: 'head-2',
+ items: [],
+ });
+ expect(input.comments).toBeUndefined();
+ });
+
+ it('maps the full queue in order, retaining all items for the submit attempt', () => {
+ const items = [
+ makeItem({ id: 'a', path: 'a.ts', line: 1 }),
+ makeItem({ id: 'b', path: 'b.ts', line: 2, startLine: 1 }),
+ ];
+ const input = buildSubmitReviewInput({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ event: 'REQUEST_CHANGES',
+ commitSha: 'head-2',
+ items,
+ });
+ expect(input.comments).toHaveLength(2);
+ // On a successful submit, the caller clears the queue; on failure, the
+ // queue is retained because the input is built from the current items
+ // without mutating them.
+ expect(items).toHaveLength(2);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/build-submit-review-input.ts b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts
new file mode 100644
index 0000000000..34a6a2090a
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/build-submit-review-input.ts
@@ -0,0 +1,51 @@
+import {
+ type SubmitReviewComment,
+ type SubmitReviewInput,
+} from '@/lib/pr-review/use-pr-review-mutations';
+
+export type ReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
+
+type BuildSubmitReviewInputArgs = {
+ owner: string;
+ repo: string;
+ number: number;
+ event: ReviewEvent;
+ body?: string;
+ commitSha: string;
+ items: readonly {
+ path: string;
+ line: number;
+ side: 'LEFT' | 'RIGHT';
+ startLine?: number;
+ body: string;
+ }[];
+};
+
+/**
+ * Pure mapper from the pending-review queue + review event to the
+ * `submitReview` tRPC input. The submission always uses the latest head SHA;
+ * queued `commitSha` values are only used for the "may be outdated" hint.
+ *
+ * Per the S3 contract, `startLine` and `startSide` must be supplied together
+ * or omitted together.
+ */
+export function buildSubmitReviewInput(args: BuildSubmitReviewInputArgs): SubmitReviewInput {
+ const comments: SubmitReviewComment[] = args.items.map(item => ({
+ path: item.path,
+ line: item.line,
+ side: item.side,
+ ...(item.startLine !== undefined ? { startLine: item.startLine, startSide: item.side } : {}),
+ body: item.body,
+ }));
+
+ const trimmedBody = args.body?.trim() ?? '';
+ return {
+ owner: args.owner,
+ repo: args.repo,
+ number: args.number,
+ event: args.event,
+ ...(trimmedBody.length > 0 ? { body: trimmedBody } : {}),
+ commitSha: args.commitSha,
+ ...(comments.length > 0 ? { comments } : {}),
+ };
+}
diff --git a/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts b/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts
new file mode 100644
index 0000000000..af291d275a
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/build-suggestion-fence.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildSuggestionFence } from '@/lib/pr-review/build-suggestion-fence';
+
+describe('buildSuggestionFence', () => {
+ it('returns null for an empty selection', () => {
+ expect(buildSuggestionFence('')).toBeNull();
+ });
+
+ it('wraps a single line verbatim', () => {
+ expect(buildSuggestionFence(' const x = 1;')).toBe('```suggestion\n const x = 1;\n```');
+ });
+
+ it('preserves multi-line indentation and spacing exactly', () => {
+ const selectedText = ['function add(a, b) {', ' return a + b;', '}'].join('\n');
+ expect(buildSuggestionFence(selectedText)).toBe(
+ ['```suggestion', 'function add(a, b) {', ' return a + b;', '}', '```'].join('\n')
+ );
+ });
+
+ it('preserves leading and trailing blank lines', () => {
+ const selectedText = '\n indented\n';
+ expect(buildSuggestionFence(selectedText)).toBe('```suggestion\n\n indented\n\n```');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts b/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts
new file mode 100644
index 0000000000..6388b4904f
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/build-suggestion-fence.ts
@@ -0,0 +1,14 @@
+// Pure helper for the PR review comment composer: builds a
+// ```suggestion fenced block from the selected right-side text.
+//
+// The fence content must match the displayed lines EXACTLY — no
+// added or removed indentation — otherwise GitHub will apply the
+// wrong replacement.
+
+export function buildSuggestionFence(selectedText: string): string | null {
+ if (selectedText.length === 0) {
+ return null;
+ }
+ const lines = selectedText.split('\n');
+ return ['```suggestion', ...lines, '```'].join('\n');
+}
diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts
new file mode 100644
index 0000000000..305a7604fd
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/classify-pr-review-mutation-error.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from 'vitest';
+
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+
+function makeTrpcError(code: string, message?: string): unknown {
+ return { data: { code, ...(message ? { message } : {}) } };
+}
+
+function makeNestedTrpcError(code: string): unknown {
+ return { shape: { data: { code } } };
+}
+
+function makeTopLevelTrpcError(code: string): unknown {
+ return { code };
+}
+
+describe('classifyPrReviewMutationError', () => {
+ it('classifies BAD_REQUEST as non-retryable bad-request', () => {
+ const error = new Error('Cannot approve your own pull request');
+ Object.assign(error, { data: { code: 'BAD_REQUEST' } });
+ expect(classifyPrReviewMutationError(error)).toEqual({
+ kind: 'bad-request',
+ message: 'Cannot approve your own pull request',
+ });
+ });
+
+ it('classifies BAD_REQUEST from the nested shape too', () => {
+ expect(classifyPrReviewMutationError(makeNestedTrpcError('BAD_REQUEST'))).toEqual({
+ kind: 'bad-request',
+ message: 'Bad request',
+ });
+ });
+
+ it('classifies BAD_REQUEST from a top-level code field', () => {
+ expect(classifyPrReviewMutationError(makeTopLevelTrpcError('BAD_REQUEST'))).toEqual({
+ kind: 'bad-request',
+ message: 'Bad request',
+ });
+ });
+
+ it('classifies FORBIDDEN as terminal forbidden', () => {
+ const error = new Error('Resource not accessible by integration');
+ Object.assign(error, { data: { code: 'FORBIDDEN' } });
+ expect(classifyPrReviewMutationError(error)).toEqual({
+ kind: 'forbidden',
+ message: 'Resource not accessible by integration',
+ });
+ });
+
+ it('classifies FORBIDDEN from a top-level code field', () => {
+ expect(classifyPrReviewMutationError(makeTopLevelTrpcError('FORBIDDEN'))).toEqual({
+ kind: 'forbidden',
+ message: 'Forbidden',
+ });
+ });
+
+ it('classifies PRECONDITION_FAILED as reconnect', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('PRECONDITION_FAILED'))).toEqual({
+ kind: 'reconnect',
+ message: 'GitHub connection expired',
+ });
+ });
+
+ it('classifies UNAUTHORIZED as reconnect', () => {
+ const error = new Error('Bad credentials');
+ Object.assign(error, { data: { code: 'UNAUTHORIZED' } });
+ expect(classifyPrReviewMutationError(error)).toEqual({
+ kind: 'reconnect',
+ message: 'Bad credentials',
+ });
+ });
+
+ it('classifies TOO_MANY_REQUESTS as retryable', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('TOO_MANY_REQUESTS'))).toEqual({
+ kind: 'retryable',
+ });
+ });
+
+ it('classifies network errors as retryable', () => {
+ expect(classifyPrReviewMutationError(new Error('Network request failed'))).toEqual({
+ kind: 'retryable',
+ });
+ });
+
+ it('classifies 5xx tRPC errors as retryable', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('INTERNAL_SERVER_ERROR'))).toEqual({
+ kind: 'retryable',
+ });
+ expect(classifyPrReviewMutationError(makeTrpcError('TIMEOUT'))).toEqual({
+ kind: 'retryable',
+ });
+ });
+
+ it('classifies unknown / malformed errors as retryable', () => {
+ expect(classifyPrReviewMutationError('string error')).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewMutationError(null)).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewMutationError(undefined)).toEqual({ kind: 'retryable' });
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts
new file mode 100644
index 0000000000..061a1d3c11
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ classifyPrReviewMutationError,
+ classifyPrReviewQueryState,
+} from './classify-pr-review-query-state';
+
+function makeTrpcError(code: string): unknown {
+ // Mirrors the shape tRPC v11 surfaces on a thrown TRPCClientError from
+ // a query: `data.code` is the canonical TRPC_ERROR_CODE_KEY.
+ return { data: { code } };
+}
+
+function makeNestedTrpcError(code: string): unknown {
+ // Some helpers wrap the shape under `shape.data.code`. The classifier
+ // must accept both forms so a future tRPC upgrade can't silently
+ // route every error to the retryable branch.
+ return { shape: { data: { code } } };
+}
+
+function makeTopLevelTrpcError(code: string): unknown {
+ return { code };
+}
+
+describe('classifyPrReviewQueryState', () => {
+ it('classifies PRECONDITION_FAILED as a reconnect state (no retry, reconnect CTA)', () => {
+ expect(classifyPrReviewQueryState(makeTrpcError('PRECONDITION_FAILED'))).toEqual({
+ kind: 'reconnect',
+ });
+ });
+
+ it('classifies NOT_FOUND as a not-found empty state (no retry, install-CTA only)', () => {
+ expect(classifyPrReviewQueryState(makeTrpcError('NOT_FOUND'))).toEqual({
+ kind: 'not-found',
+ });
+ });
+
+ it('classifies FORBIDDEN as a permanent permission error (no CTA, no retry)', () => {
+ expect(classifyPrReviewQueryState(makeTrpcError('FORBIDDEN'))).toEqual({
+ kind: 'permission',
+ });
+ });
+
+ it('classifies UNAUTHORIZED as a reconnect state (revoked connection, reconnect CTA)', () => {
+ expect(classifyPrReviewQueryState(makeTrpcError('UNAUTHORIZED'))).toEqual({
+ kind: 'reconnect',
+ });
+ });
+
+ it('reads the code from the nested shape.data.code form too', () => {
+ expect(classifyPrReviewQueryState(makeNestedTrpcError('NOT_FOUND'))).toEqual({
+ kind: 'not-found',
+ });
+ expect(classifyPrReviewQueryState(makeNestedTrpcError('PRECONDITION_FAILED'))).toEqual({
+ kind: 'reconnect',
+ });
+ });
+
+ it('reads the code from a top-level `code` field', () => {
+ expect(classifyPrReviewQueryState(makeTopLevelTrpcError('FORBIDDEN'))).toEqual({
+ kind: 'permission',
+ });
+ });
+
+ it('falls back to retryable for unknown / non-tRPC errors', () => {
+ expect(classifyPrReviewQueryState(new Error('network down'))).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewQueryState('string error')).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewQueryState(null)).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewQueryState(undefined)).toEqual({ kind: 'retryable' });
+ });
+
+ it('falls back to retryable for tRPC 5xx-class codes', () => {
+ expect(classifyPrReviewQueryState(makeTrpcError('INTERNAL_SERVER_ERROR'))).toEqual({
+ kind: 'retryable',
+ });
+ expect(classifyPrReviewQueryState(makeTrpcError('TIMEOUT'))).toEqual({ kind: 'retryable' });
+ });
+});
+
+describe('classifyPrReviewMutationError', () => {
+ it('classifies FORBIDDEN as a terminal permission error', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('FORBIDDEN'))).toEqual({
+ kind: 'forbidden',
+ message: 'Forbidden',
+ });
+ });
+
+ it('classifies UNAUTHORIZED as a reconnect state', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('UNAUTHORIZED'))).toEqual({
+ kind: 'reconnect',
+ message: 'GitHub connection expired',
+ });
+ });
+
+ it('classifies PRECONDITION_FAILED as a reconnect state', () => {
+ expect(classifyPrReviewMutationError(makeTrpcError('PRECONDITION_FAILED'))).toEqual({
+ kind: 'reconnect',
+ message: 'GitHub connection expired',
+ });
+ });
+
+ it('falls back to retryable for unknown / non-tRPC errors', () => {
+ expect(classifyPrReviewMutationError(new Error('network down'))).toEqual({ kind: 'retryable' });
+ expect(classifyPrReviewMutationError('string error')).toEqual({ kind: 'retryable' });
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts
new file mode 100644
index 0000000000..52a6b49099
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/classify-pr-review-query-state.ts
@@ -0,0 +1,162 @@
+// Pure classification of a tRPC query error for the PR Review Overview /
+// Checks surfaces. The PR review surface has a slightly different error
+// matrix than the rest of the app: PRECONDITION_FAILED is treated as a
+// configuration/connect-state problem (the user must reopen the connect
+// flow rather than retry the request), and the App Store rules forbid
+// any retryable UI on permanent permission errors.
+//
+// Lives in `lib/pr-review/` so screens (and their unit tests) can import
+// the same decision tree. No React, no react-query, no expo modules —
+// that keeps it testable in plain Node.
+
+type PrReviewQueryState =
+ | {
+ /**
+ * The fetch failed with a transient error (network, 5xx, etc.). The
+ * caller should render `QueryError` with a Retry CTA.
+ */
+ kind: 'retryable';
+ }
+ | {
+ /**
+ * The fetch failed with a tRPC code the user can't recover from by
+ * retrying (e.g. they don't have access to this PR). The caller
+ * should render a terminal permission message with NO CTA.
+ */
+ kind: 'permission';
+ }
+ | {
+ /**
+ * The PR / checks were not found. The caller should render the
+ * "PR unavailable" empty state with a single CTA pointing at the
+ * Kilo GitHub App install flow, and NO retry.
+ */
+ kind: 'not-found';
+ }
+ | {
+ /**
+ * The connect gate's own precondition check failed. The reviewer
+ * is most likely disconnected or revoked, so the caller should
+ * surface a reconnect CTA rather than a generic retry — retrying
+ * the same query without fixing the underlying connection will
+ * keep failing in the same way.
+ */
+ kind: 'reconnect';
+ };
+
+/**
+ * Extracts the tRPC error code from an unknown thrown value. tRPC v11
+ * client errors expose `data.code`; server-shaped errors expose
+ * `shape.data.code`. Anything else is treated as an unknown transient
+ * error (retryable).
+ */
+function readTrpcErrorCode(error: unknown): string | undefined {
+ if (!error || typeof error !== 'object') {
+ return undefined;
+ }
+ const record = error as Record;
+ // Direct TRPCClientError — `data` is the shape's data.
+ const data = record.data;
+ if (data && typeof data === 'object') {
+ const code = (data as Record).code;
+ if (typeof code === 'string') {
+ return code;
+ }
+ }
+ // Nested `shape` form (some tRPC versions wrap the shape).
+ const shape = record.shape;
+ if (shape && typeof shape === 'object') {
+ const shapeData = (shape as Record).data;
+ if (shapeData && typeof shapeData === 'object') {
+ const code = (shapeData as Record).code;
+ if (typeof code === 'string') {
+ return code;
+ }
+ }
+ }
+ // Some helpers expose the code at the top level.
+ const top = record.code;
+ if (typeof top === 'string') {
+ return top;
+ }
+ return undefined;
+}
+
+export function classifyPrReviewQueryState(error: unknown): PrReviewQueryState {
+ const code = readTrpcErrorCode(error);
+ if (code === 'PRECONDITION_FAILED') {
+ return { kind: 'reconnect' };
+ }
+ if (code === 'NOT_FOUND') {
+ return { kind: 'not-found' };
+ }
+ if (code === 'FORBIDDEN') {
+ return { kind: 'permission' };
+ }
+ if (code === 'UNAUTHORIZED') {
+ return { kind: 'reconnect' };
+ }
+ return { kind: 'retryable' };
+}
+
+type PrReviewMutationErrorState =
+ | {
+ /**
+ * The mutation failed with a transient error (network, 5xx, rate
+ * limit, etc.). The caller should keep the retry affordance
+ * available.
+ */
+ kind: 'retryable';
+ }
+ | {
+ /**
+ * The mutation failed with a validation / 422-class error (e.g.
+ * approving your own PR or a stale comment line). The caller should
+ * show a specific inline message and remove the retry affordance;
+ * the user must change the input or event before submitting again.
+ */
+ kind: 'bad-request';
+ /** Original error message, suitable for logging but not the UI. */
+ message: string;
+ }
+ | {
+ /**
+ * The mutation failed with a permanent permission error. The
+ * caller should show a specific inline message and remove the
+ * retry affordance; no retry can succeed.
+ */
+ kind: 'forbidden';
+ message: string;
+ }
+ | {
+ /**
+ * The mutation failed because the GitHub authorization is no
+ * longer valid. The caller should remove the retry affordance and
+ * surface a reconnect CTA that invalidates the gate's auth query.
+ */
+ kind: 'reconnect';
+ message: string;
+ };
+
+export function classifyPrReviewMutationError(error: unknown): PrReviewMutationErrorState {
+ const code = readTrpcErrorCode(error);
+ if (code === 'BAD_REQUEST') {
+ return {
+ kind: 'bad-request',
+ message: error instanceof Error ? error.message : 'Bad request',
+ };
+ }
+ if (code === 'FORBIDDEN') {
+ return {
+ kind: 'forbidden',
+ message: error instanceof Error ? error.message : 'Forbidden',
+ };
+ }
+ if (code === 'PRECONDITION_FAILED' || code === 'UNAUTHORIZED') {
+ return {
+ kind: 'reconnect',
+ message: error instanceof Error ? error.message : 'GitHub connection expired',
+ };
+ }
+ return { kind: 'retryable' };
+}
diff --git a/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts b/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts
new file mode 100644
index 0000000000..7e45af4b0c
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/comment-composer-params.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from 'vitest';
+
+import { parseComposerParams } from '@/lib/pr-review/comment-composer-params';
+
+function valid(overrides: Record = {}) {
+ return {
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: '42',
+ path: 'src/lib.ts',
+ side: 'RIGHT',
+ line: '7',
+ ...overrides,
+ };
+}
+
+describe('parseComposerParams', () => {
+ it('returns parsed params for the happy path', () => {
+ expect(parseComposerParams(valid())).toEqual({
+ owner: 'kilocode',
+ repo: 'kilo',
+ number: 42,
+ path: 'src/lib.ts',
+ side: 'RIGHT',
+ line: 7,
+ });
+ });
+
+ it('parses optional startLine when present and <= line', () => {
+ expect(parseComposerParams(valid({ startLine: '5' }))).toEqual(
+ expect.objectContaining({ startLine: 5 })
+ );
+ });
+
+ it('rejects a missing owner', () => {
+ expect(parseComposerParams(valid({ owner: undefined }))).toBeNull();
+ });
+
+ it('rejects a missing repo', () => {
+ expect(parseComposerParams(valid({ repo: undefined }))).toBeNull();
+ });
+
+ it('rejects a non-positive number', () => {
+ expect(parseComposerParams(valid({ number: '0' }))).toBeNull();
+ expect(parseComposerParams(valid({ number: '-1' }))).toBeNull();
+ expect(parseComposerParams(valid({ number: 'abc' }))).toBeNull();
+ });
+
+ it('rejects an empty path', () => {
+ expect(parseComposerParams(valid({ path: '' }))).toBeNull();
+ });
+
+ it('rejects an invalid side', () => {
+ expect(parseComposerParams(valid({ side: 'MIDDLE' }))).toBeNull();
+ expect(parseComposerParams(valid({ side: undefined }))).toBeNull();
+ });
+
+ it('rejects a non-positive line', () => {
+ expect(parseComposerParams(valid({ line: '0' }))).toBeNull();
+ expect(parseComposerParams(valid({ line: '-3' }))).toBeNull();
+ expect(parseComposerParams(valid({ line: 'abc' }))).toBeNull();
+ });
+
+ it('rejects startLine greater than line', () => {
+ expect(parseComposerParams(valid({ line: '5', startLine: '6' }))).toBeNull();
+ });
+
+ it('rejects a non-positive startLine', () => {
+ expect(parseComposerParams(valid({ startLine: '0' }))).toBeNull();
+ expect(parseComposerParams(valid({ startLine: '-1' }))).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/comment-composer-params.ts b/apps/mobile/src/lib/pr-review/comment-composer-params.ts
new file mode 100644
index 0000000000..f195ba66eb
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/comment-composer-params.ts
@@ -0,0 +1,72 @@
+import { parseParam } from '@/lib/route-params';
+
+type RawComposerParams = {
+ owner?: string | string[] | undefined;
+ repo?: string | string[] | undefined;
+ number?: string | string[] | undefined;
+ path?: string | string[] | undefined;
+ side?: string | string[] | undefined;
+ line?: string | string[] | undefined;
+ startLine?: string | string[] | undefined;
+};
+
+type ParsedComposerParams = {
+ owner: string;
+ repo: string;
+ number: number;
+ path: string;
+ side: 'LEFT' | 'RIGHT';
+ line: number;
+ startLine?: number;
+};
+
+function parsePositiveInt(value: string | string[] | undefined): number | null {
+ const text = parseParam(value);
+ if (!text) {
+ return null;
+ }
+ const parsed = Number.parseInt(text, 10);
+ if (!Number.isInteger(parsed) || parsed <= 0) {
+ return null;
+ }
+ return parsed;
+}
+
+/**
+ * Runtime-validates the comment-composer route params before the screen
+ * queries or renders the composer. Returns `null` for any invalid
+ * combination (missing owner/repo/number, empty path, invalid side,
+ * non-positive line, or startLine greater than line).
+ */
+export function parseComposerParams(raw: RawComposerParams): ParsedComposerParams | null {
+ const owner = parseParam(raw.owner);
+ const repo = parseParam(raw.repo);
+ const number = parsePositiveInt(raw.number);
+ const path = parseParam(raw.path);
+ const side = parseParam(raw.side, ['LEFT', 'RIGHT'] as const);
+ const line = parsePositiveInt(raw.line);
+ const startLine = parsePositiveInt(raw.startLine);
+ const hasStartLine = parseParam(raw.startLine) !== null;
+
+ if (!owner || !repo || !number || !path || !side || !line) {
+ return null;
+ }
+
+ if (hasStartLine && startLine === null) {
+ return null;
+ }
+
+ if (startLine !== null && startLine > line) {
+ return null;
+ }
+
+ return {
+ owner,
+ repo,
+ number,
+ path,
+ side,
+ line,
+ ...(startLine !== null ? { startLine } : {}),
+ };
+}
diff --git a/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts b/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts
new file mode 100644
index 0000000000..0a007aae0a
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/connect-gate-platform.test.ts
@@ -0,0 +1,71 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ getConnectGatePlatformPlan,
+ openAuthorizationAndWaitForReturn,
+} from './connect-gate-platform';
+
+const webBrowserMocks = vi.hoisted(() => ({
+ openAuthSessionAsync: vi.fn<(url: string) => Promise>(),
+ openBrowserAsync: vi.fn<(url: string) => Promise>(),
+}));
+
+vi.mock('expo-web-browser', () => ({
+ openAuthSessionAsync: webBrowserMocks.openAuthSessionAsync,
+ openBrowserAsync: webBrowserMocks.openBrowserAsync,
+}));
+
+beforeEach(() => {
+ webBrowserMocks.openAuthSessionAsync.mockReset();
+ webBrowserMocks.openBrowserAsync.mockReset();
+});
+
+describe('getConnectGatePlatformPlan', () => {
+ it('uses the native auth session and refetches on sheet close for iOS', () => {
+ expect(getConnectGatePlatformPlan('ios')).toEqual({
+ launcher: 'openAuthSession',
+ refetchTrigger: 'sheet-close',
+ });
+ });
+
+ it('uses a plain custom-tab browser and refetches on app-foreground for Android', () => {
+ expect(getConnectGatePlatformPlan('android')).toEqual({
+ launcher: 'openBrowser',
+ refetchTrigger: 'app-foreground',
+ });
+ });
+
+ it('falls back to the Android plan for unknown platforms (web, etc.)', () => {
+ expect(getConnectGatePlatformPlan('web')).toEqual({
+ launcher: 'openBrowser',
+ refetchTrigger: 'app-foreground',
+ });
+ expect(getConnectGatePlatformPlan('')).toEqual({
+ launcher: 'openBrowser',
+ refetchTrigger: 'app-foreground',
+ });
+ });
+});
+
+describe('openAuthorizationAndWaitForReturn', () => {
+ it('uses openAuthSessionAsync on iOS and reports sheet-close', async () => {
+ webBrowserMocks.openAuthSessionAsync.mockResolvedValue('done');
+ const trigger = await openAuthorizationAndWaitForReturn('ios', 'https://example.com/connect');
+ expect(webBrowserMocks.openAuthSessionAsync).toHaveBeenCalledWith(
+ 'https://example.com/connect'
+ );
+ expect(webBrowserMocks.openBrowserAsync).not.toHaveBeenCalled();
+ expect(trigger).toBe('sheet-close');
+ });
+
+ it('uses openBrowserAsync on Android and reports app-foreground', async () => {
+ webBrowserMocks.openBrowserAsync.mockResolvedValue(undefined);
+ const trigger = await openAuthorizationAndWaitForReturn(
+ 'android',
+ 'https://example.com/connect'
+ );
+ expect(webBrowserMocks.openBrowserAsync).toHaveBeenCalledWith('https://example.com/connect');
+ expect(webBrowserMocks.openAuthSessionAsync).not.toHaveBeenCalled();
+ expect(trigger).toBe('app-foreground');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/connect-gate-platform.ts b/apps/mobile/src/lib/pr-review/connect-gate-platform.ts
new file mode 100644
index 0000000000..f4177c7c46
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/connect-gate-platform.ts
@@ -0,0 +1,48 @@
+// Pure/hook selection for the connect-gate's platform branch. Extracted so
+// the platform choice (which browser launcher + which refetch trigger) can
+// be unit-tested without pulling in the full React component tree.
+
+import * as WebBrowser from 'expo-web-browser';
+
+type AuthLauncher = 'openAuthSession' | 'openBrowser';
+
+type GateRefetchTrigger = 'sheet-close' | 'app-foreground';
+
+type ConnectGatePlatformPlan = {
+ launcher: AuthLauncher;
+ refetchTrigger: GateRefetchTrigger;
+};
+
+/**
+ * Maps a React Native platform to the browser launcher and refetch trigger
+ * the connect gate should use after the auth session ends.
+ *
+ * - iOS: `openAuthSessionAsync` returns when the sheet closes, so we
+ * refetch on `sheet-close`. No foreground listener needed.
+ * - Android: `openBrowserAsync` is fire-and-forget (no callback when the
+ * user finishes), so we wait for the app to return to foreground and
+ * refetch then. Same pattern as `use-device-auth.ts` ~:34-42.
+ */
+export function getConnectGatePlatformPlan(platform: string): ConnectGatePlatformPlan {
+ if (platform === 'ios') {
+ return { launcher: 'openAuthSession', refetchTrigger: 'sheet-close' };
+ }
+ return { launcher: 'openBrowser', refetchTrigger: 'app-foreground' };
+}
+
+/**
+ * Opens the authorization URL with the platform-appropriate launcher and
+ * resolves with the trigger the caller should use to refetch the
+ * authorization query. Kept as a single helper so the gate component
+ * doesn't have to know which platform maps to which API.
+ */
+export async function openAuthorizationAndWaitForReturn(
+ platform: string,
+ authorizationUrl: string
+): Promise {
+ const plan = getConnectGatePlatformPlan(platform);
+ await (plan.launcher === 'openAuthSession'
+ ? WebBrowser.openAuthSessionAsync(authorizationUrl)
+ : WebBrowser.openBrowserAsync(authorizationUrl));
+ return plan.refetchTrigger;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts
new file mode 100644
index 0000000000..dda58382dd
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff-selection-bridge.ts
@@ -0,0 +1,53 @@
+// Module-level bridge for the current diff selection (path + side + line
+// range + the actual selected line text). The diff side calls
+// `setDiffSelection` when the user taps a line range; the comment composer
+// route reads it via `getDiffSelection` on focus and clears it on blur so
+// a stale selection never leaks into the next visit. S7a implements the
+// producer side (the diff component) and the consumer side (the composer
+// sheet) in the same slice that adds the final pending-comment fields.
+//
+// The selection carries its owning PR identity so a selection made in one
+// PR can never be consumed by another PR's composer if both entries remain
+// mounted in the navigation stack: `getDiffSelection` returns null unless
+// the requested PR matches the stored selection.
+
+export type DiffSelectionSide = 'LEFT' | 'RIGHT';
+
+export type PrIdentity = {
+ owner: string;
+ repo: string;
+ number: number;
+};
+
+export type DiffSelection = PrIdentity & {
+ path: string;
+ side: DiffSelectionSide;
+ line: number;
+ startLine?: number;
+ selectedText: string;
+};
+
+let selection: DiffSelection | null = null;
+
+function samePr(a: PrIdentity, b: PrIdentity): boolean {
+ return (
+ a.owner.toLowerCase() === b.owner.toLowerCase() &&
+ a.repo.toLowerCase() === b.repo.toLowerCase() &&
+ a.number === b.number
+ );
+}
+
+export function setDiffSelection(next: DiffSelection) {
+ selection = next;
+}
+
+export function getDiffSelection(pr: PrIdentity): DiffSelection | null {
+ if (!selection || !samePr(selection, pr)) {
+ return null;
+ }
+ return selection;
+}
+
+export function clearDiffSelection() {
+ selection = null;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff-selection.test.ts b/apps/mobile/src/lib/pr-review/diff-selection.test.ts
new file mode 100644
index 0000000000..7b3bac5784
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff-selection.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ clearSelection,
+ isLineInSelection,
+ type SelectionTap,
+ selectLine,
+ sideForDiffLineType,
+} from '@/lib/pr-review/diff-selection';
+
+function tap(overrides: Partial = {}): SelectionTap {
+ return {
+ path: 'src/foo.ts',
+ side: 'RIGHT',
+ line: 5,
+ hunkKey: 'src/foo.ts:0',
+ text: 'line 5',
+ ...overrides,
+ };
+}
+
+function hunkLines(entries: [number, string][]): Map {
+ return new Map(entries);
+}
+
+describe('selectLine', () => {
+ it('starts a new single-line selection when state is null', () => {
+ const result = selectLine(null, tap({ line: 5, text: 'a' }), hunkLines([]));
+ expect(result).toEqual({
+ path: 'src/foo.ts',
+ side: 'RIGHT',
+ hunkKey: 'src/foo.ts:0',
+ startLine: 5,
+ line: 5,
+ selectedText: 'a',
+ });
+ });
+
+ it('extends the range when tapping the same path/side/hunk in ascending order', () => {
+ const first = selectLine(null, tap({ line: 5, text: 'a' }), hunkLines([]));
+ const second = selectLine(
+ first,
+ tap({ line: 7, text: 'c' }),
+ hunkLines([
+ [5, 'a'],
+ [6, 'b'],
+ [7, 'c'],
+ ])
+ );
+ expect(second.startLine).toBe(5);
+ expect(second.line).toBe(7);
+ expect(second.selectedText).toBe('a\nb\nc');
+ });
+
+ it('extends the range in descending order, taking min(startLine) and max(line)', () => {
+ const first = selectLine(null, tap({ line: 8, text: 'd' }), hunkLines([]));
+ const second = selectLine(
+ first,
+ tap({ line: 5, text: 'a' }),
+ hunkLines([
+ [5, 'a'],
+ [6, 'b'],
+ [7, 'c'],
+ [8, 'd'],
+ ])
+ );
+ expect(second.startLine).toBe(5);
+ expect(second.line).toBe(8);
+ expect(second.selectedText).toBe('a\nb\nc\nd');
+ });
+
+ it('replaces the selection when tapping the other side (LEFT ↔ RIGHT)', () => {
+ const first = selectLine(null, tap({ side: 'RIGHT', line: 5, text: 'a' }), hunkLines([]));
+ const second = selectLine(
+ first,
+ tap({ side: 'LEFT', line: 12, text: 'del-12' }),
+ hunkLines([])
+ );
+ expect(second).toEqual({
+ path: 'src/foo.ts',
+ side: 'LEFT',
+ hunkKey: 'src/foo.ts:0',
+ startLine: 12,
+ line: 12,
+ selectedText: 'del-12',
+ });
+ });
+
+ it('replaces the selection when crossing to a different hunk', () => {
+ const first = selectLine(
+ null,
+ tap({ line: 5, hunkKey: 'src/foo.ts:0', text: 'a' }),
+ hunkLines([])
+ );
+ const second = selectLine(
+ first,
+ tap({ line: 30, hunkKey: 'src/foo.ts:1', text: 'x' }),
+ hunkLines([])
+ );
+ expect(second).toEqual({
+ path: 'src/foo.ts',
+ side: 'RIGHT',
+ hunkKey: 'src/foo.ts:1',
+ startLine: 30,
+ line: 30,
+ selectedText: 'x',
+ });
+ });
+
+ it('replaces the selection when tapping a different file', () => {
+ const first = selectLine(null, tap({ path: 'src/a.ts', line: 1, text: 'a' }), hunkLines([]));
+ const second = selectLine(first, tap({ path: 'src/b.ts', line: 10, text: 'b' }), hunkLines([]));
+ expect(second.path).toBe('src/b.ts');
+ expect(second.startLine).toBe(10);
+ expect(second.line).toBe(10);
+ });
+
+ it('uses the hunk line map for selected text, not the tap text, when the map is complete', () => {
+ const first = selectLine(null, tap({ line: 5, text: 'tap-text' }), hunkLines([[5, 'a']]));
+ const second = selectLine(
+ first,
+ tap({ line: 7, text: 'tap-text' }),
+ hunkLines([
+ [5, 'a'],
+ [6, 'b'],
+ [7, 'c'],
+ ])
+ );
+ expect(second.selectedText).toBe('a\nb\nc');
+ });
+});
+
+describe('sideForDiffLineType', () => {
+ it('classifies add lines as RIGHT', () => {
+ expect(sideForDiffLineType('add')).toBe('RIGHT');
+ });
+
+ it('classifies context lines as RIGHT', () => {
+ expect(sideForDiffLineType('context')).toBe('RIGHT');
+ });
+
+ it('classifies del lines as LEFT', () => {
+ expect(sideForDiffLineType('del')).toBe('LEFT');
+ });
+});
+
+describe('clearSelection', () => {
+ it('returns null', () => {
+ expect(clearSelection()).toBeNull();
+ });
+});
+
+describe('isLineInSelection', () => {
+ it('returns false when no selection', () => {
+ expect(isLineInSelection(null, { line: 5 })).toBe(false);
+ });
+
+ it('returns true for a line inside the range', () => {
+ const state = {
+ path: 'src/foo.ts',
+ side: 'RIGHT' as const,
+ hunkKey: 'src/foo.ts:0',
+ startLine: 5,
+ line: 8,
+ selectedText: 'a\nb\nc\nd',
+ };
+ expect(isLineInSelection(state, { line: 5 })).toBe(true);
+ expect(isLineInSelection(state, { line: 6 })).toBe(true);
+ expect(isLineInSelection(state, { line: 8 })).toBe(true);
+ });
+
+ it('returns false for a line outside the range', () => {
+ const state = {
+ path: 'src/foo.ts',
+ side: 'RIGHT' as const,
+ hunkKey: 'src/foo.ts:0',
+ startLine: 5,
+ line: 8,
+ selectedText: 'a\nb\nc\nd',
+ };
+ expect(isLineInSelection(state, { line: 4 })).toBe(false);
+ expect(isLineInSelection(state, { line: 9 })).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff-selection.ts b/apps/mobile/src/lib/pr-review/diff-selection.ts
new file mode 100644
index 0000000000..84920f9bf4
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff-selection.ts
@@ -0,0 +1,102 @@
+// Pure diff-line selection reducer for the PR review composer.
+//
+// A selection is a contiguous range of commentable lines within a single
+// hunk. The producer (the diff) reports each tap as
+// `{path, side, line, hunkKey, text}` together with a `hunkLines` map
+// keyed by the line number visible on the selected side. The reducer
+// returns the next selection state, or `null` to mean "no selection".
+//
+// Rules (S7a):
+// - No selection, or different path / different side / different
+// hunk → START a new single-line selection.
+// - Same path + same side + same hunk → EXTEND the range so that
+// `startLine = min(state.startLine, tap.line)` and
+// `line = max(state.line, tap.line)`. This keeps the range
+// contiguous from the user's point of view even if they tap
+// out of order, and matches the spec's "min/max" rule.
+//
+// Selection side is derived by the producer from the line type:
+// `del` lines live on the LEFT, `add` and `context` lines live on
+// the RIGHT (GitHub's review-comment model). Mixed-side ranges are
+// rejected by the producer before they reach the reducer — a single
+// tap can never carry both sides.
+
+import { type DiffLineType } from '@/lib/pr-review/diff/parse-patch';
+
+export type SelectionSide = 'LEFT' | 'RIGHT';
+
+export type SelectionTap = {
+ path: string;
+ side: SelectionSide;
+ line: number;
+ hunkKey: string;
+ /** The text of the tapped line (the right-side text for context/add, the left-side text for del). */
+ text: string;
+};
+
+export type SelectionState = {
+ path: string;
+ side: SelectionSide;
+ hunkKey: string;
+ startLine: number;
+ line: number;
+ selectedText: string;
+};
+
+export function selectLine(
+ state: SelectionState | null,
+ tap: SelectionTap,
+ hunkLines: ReadonlyMap
+): SelectionState {
+ if (
+ !state ||
+ state.path !== tap.path ||
+ state.side !== tap.side ||
+ state.hunkKey !== tap.hunkKey
+ ) {
+ return {
+ path: tap.path,
+ side: tap.side,
+ hunkKey: tap.hunkKey,
+ startLine: tap.line,
+ line: tap.line,
+ selectedText: tap.text,
+ };
+ }
+
+ const startLine = Math.min(state.startLine, tap.line);
+ const endLine = Math.max(state.line, tap.line);
+ const lines: string[] = [];
+ for (let current = startLine; current <= endLine; current += 1) {
+ lines.push(hunkLines.get(current) ?? tap.text);
+ }
+ return {
+ path: tap.path,
+ side: tap.side,
+ hunkKey: tap.hunkKey,
+ startLine,
+ line: endLine,
+ selectedText: lines.join('\n'),
+ };
+}
+
+export function clearSelection(): null {
+ return null;
+}
+
+export function isLineInSelection(state: SelectionState | null, tap: { line: number }): boolean {
+ if (!state) {
+ return false;
+ }
+ return tap.line >= state.startLine && tap.line <= state.line;
+}
+
+/**
+ * Producer-side classification: which side of the diff a commentable line
+ * lives on. `del` lines are on the LEFT; `add` and `context` lines are on
+ * the RIGHT (GitHub's review-comment model). Mixed-side ranges are rejected
+ * by the producer before they reach the reducer.
+ */
+export function sideForDiffLineType(type: DiffLineType): SelectionSide {
+ return type === 'del' ? 'LEFT' : 'RIGHT';
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/highlight.test.ts b/apps/mobile/src/lib/pr-review/diff/highlight.test.ts
new file mode 100644
index 0000000000..4794d6a572
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/highlight.test.ts
@@ -0,0 +1,124 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ clearHighlightCacheForTests,
+ highlightLine,
+ type HighlightToken,
+ languageForPath,
+} from './highlight';
+
+function classes(tokens: HighlightToken[]): (string | null)[] {
+ return tokens.map(t => t.className);
+}
+
+function texts(tokens: HighlightToken[]): string[] {
+ return tokens.map(t => t.text);
+}
+
+describe('languageForPath', () => {
+ it('maps common file extensions to languages', () => {
+ expect(languageForPath('src/index.ts')).toBe('typescript');
+ expect(languageForPath('src/component.tsx')).toBe('typescript');
+ expect(languageForPath('script.js')).toBe('javascript');
+ expect(languageForPath('main.py')).toBe('python');
+ expect(languageForPath('main.go')).toBe('go');
+ expect(languageForPath('lib.rs')).toBe('rust');
+ expect(languageForPath('app.rb')).toBe('ruby');
+ expect(languageForPath('config.json')).toBe('json');
+ expect(languageForPath('README.md')).toBe('markdown');
+ expect(languageForPath('run.sh')).toBe('bash');
+ expect(languageForPath('style.css')).toBe('css');
+ expect(languageForPath('index.html')).toBe('xml');
+ expect(languageForPath('ci.yml')).toBe('yaml');
+ expect(languageForPath('Cargo.toml')).toBe('ini');
+ });
+
+ it('uses the last extension for compound names like foo.test.ts', () => {
+ expect(languageForPath('src/foo.test.ts')).toBe('typescript');
+ expect(languageForPath('src/component.test.tsx')).toBe('typescript');
+ });
+
+ it('is case-insensitive on the extension', () => {
+ expect(languageForPath('Script.PY')).toBe('python');
+ expect(languageForPath('App.TS')).toBe('typescript');
+ });
+
+ it('returns null for unknown extensions and dotfiles', () => {
+ expect(languageForPath('README')).toBeNull();
+ expect(languageForPath('.gitignore')).toBeNull();
+ expect(languageForPath('foo.unknown')).toBeNull();
+ });
+
+ it('returns null for null / undefined input', () => {
+ expect(languageForPath(null)).toBeNull();
+ expect(languageForPath(undefined)).toBeNull();
+ });
+
+ it('handles a path with a dot in a directory segment', () => {
+ expect(languageForPath('packages/foo.app/index.ts')).toBe('typescript');
+ });
+});
+
+describe('highlightLine', () => {
+ beforeEach(() => {
+ clearHighlightCacheForTests();
+ });
+
+ it('returns a single plain-text token for an unknown language', () => {
+ const tokens = highlightLine('hello world', null);
+ expect(tokens).toEqual([{ text: 'hello world', className: null }]);
+ });
+
+ it('returns plain text for an empty line', () => {
+ const tokens = highlightLine('', 'typescript');
+ expect(texts(tokens).join('')).toBe('');
+ });
+
+ it('tags a TypeScript const declaration with the keyword + identifier classes', () => {
+ const tokens = highlightLine('const x = 1;', 'typescript');
+ // We don't pin the exact class split (highlight.js may emit
+ // different sub-tokens across versions), but the keyword class
+ // must be present.
+ expect(classes(tokens)).toContain('keyword');
+ // And the entire line text must be preserved (no token dropped).
+ expect(texts(tokens).join('')).toBe('const x = 1;');
+ });
+
+ it('tags a string literal as string in TypeScript', () => {
+ const tokens = highlightLine('const greeting = "hello";', 'typescript');
+ expect(classes(tokens)).toContain('string');
+ expect(texts(tokens).join('')).toBe('const greeting = "hello";');
+ });
+
+ it('tags a single-line comment as comment in TypeScript', () => {
+ const tokens = highlightLine('// hello', 'typescript');
+ expect(classes(tokens)).toContain('comment');
+ expect(texts(tokens).join('')).toBe('// hello');
+ });
+
+ it('re-runs the same line through the cache and returns the same tokens', () => {
+ const a = highlightLine('const x = 1;', 'typescript');
+ const b = highlightLine('const x = 1;', 'typescript');
+ // Reference equality is not required, but the tokenized output
+ // should match exactly so the UI doesn't see visual diffs.
+ expect(b).toEqual(a);
+ });
+
+ it('falls back to plain text when the highlighter throws', () => {
+ // unknown language — lowlight throws and we recover.
+ const tokens = highlightLine('hello world', 'not-a-real-language');
+ expect(tokens).toEqual([{ text: 'hello world', className: null }]);
+ });
+
+ it('preserves the full line text across tokens (no characters dropped)', () => {
+ const line = 'function add(a: number, b: number): number { return a + b; }';
+ const tokens = highlightLine(line, 'typescript');
+ expect(texts(tokens).join('')).toBe(line);
+ });
+
+ it('handles JSON keys + string values', () => {
+ const tokens = highlightLine('"name": "kilo"', 'json');
+ expect(classes(tokens)).toContain('string');
+ expect(texts(tokens).join('')).toBe('"name": "kilo"');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/highlight.ts b/apps/mobile/src/lib/pr-review/diff/highlight.ts
new file mode 100644
index 0000000000..21beb47fd4
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/highlight.ts
@@ -0,0 +1,304 @@
+// Per-line syntax highlighting for diff lines, using `lowlight`
+// (highlight.js wrapped to return a hast tree instead of mutating the
+// DOM).
+//
+// The accepted v1 ceiling is per-line highlighting: each line of a diff
+// is highlighted independently. Multi-line tokens (a block comment
+// that opens on line 5 and closes on line 12) may mis-color on lines
+// 6–11 because the highlighter doesn't know the comment is still open.
+// This is the same trade-off GitHub's mobile web view made and the
+// PR-review surface is not the place to ship a multi-line tokenizer —
+// callers should still get a clean, readable result, and the cost of
+// wiring a per-hunk state machine is out of scope for S6b.
+//
+// The hast tree is then converted to a flat list of
+// `{text, className}` spans so a React Native `` can render it
+// with nested `` runs. The class names we produce are a small
+// fixed token palette (`tok-keyword`, `tok-string`, ...) derived from
+// the app's theme — the rendering side turns those into concrete
+// colors with dark variants. We don't use raw `hljs-*` classes because
+// the diff surface shouldn't depend on the full highlight.js CSS
+// theme being loaded.
+//
+// A small LRU cache sits on top of the per-line path because a large
+// diff can have thousands of identical lines (empty context rows) and
+// re-running the tokenizer for each one is wasteful.
+
+import { common, createLowlight } from 'lowlight';
+
+// Lazy singleton — the common grammar set is ~1 MB, instantiating it
+// once per module load (and not at all in tests that don't import
+// anything that calls it) keeps the bundle and cold-start cost
+// contained.
+type LowlightInstance = ReturnType;
+let lowlightInstance: LowlightInstance | null = null;
+
+function getLowlight(): LowlightInstance {
+ lowlightInstance ??= createLowlight(common);
+ return lowlightInstance;
+}
+
+// Extension → language name. Anything not in this map is highlighted
+// as plain text (the underlying `lowlight.highlight` will still return
+// a single text node, so callers get a valid result back without
+// throwing). Filenames are lower-cased and the last extension is
+// matched so `foo.test.ts` resolves to `typescript`.
+const EXTENSION_LANGUAGE_MAP: Record = {
+ ts: 'typescript',
+ tsx: 'typescript',
+ mts: 'typescript',
+ cts: 'typescript',
+ js: 'javascript',
+ jsx: 'javascript',
+ mjs: 'javascript',
+ cjs: 'javascript',
+ py: 'python',
+ go: 'go',
+ rs: 'rust',
+ java: 'java',
+ rb: 'ruby',
+ json: 'json',
+ jsonc: 'json',
+ md: 'markdown',
+ mdx: 'markdown',
+ sh: 'bash',
+ bash: 'bash',
+ zsh: 'bash',
+ css: 'css',
+ scss: 'css',
+ html: 'xml',
+ htm: 'xml',
+ xml: 'xml',
+ vue: 'xml',
+ svelte: 'xml',
+ yml: 'yaml',
+ yaml: 'yaml',
+ toml: 'ini',
+ ini: 'ini',
+ c: 'c',
+ h: 'c',
+ cpp: 'cpp',
+ cxx: 'cpp',
+ cc: 'cpp',
+ hpp: 'cpp',
+ cs: 'csharp',
+ php: 'php',
+ swift: 'swift',
+ kt: 'kotlin',
+ scala: 'scala',
+ sql: 'sql',
+ graphql: 'graphql',
+};
+
+export function languageForPath(path: string | null | undefined): string | null {
+ if (!path) {
+ return null;
+ }
+ const slash = path.lastIndexOf('/');
+ const basename = slash !== -1 ? path.slice(slash + 1) : path;
+ const dot = basename.lastIndexOf('.');
+ if (dot <= 0) {
+ return null;
+ }
+ const ext = basename.slice(dot + 1).toLowerCase();
+ return EXTENSION_LANGUAGE_MAP[ext] ?? null;
+}
+
+export type HighlightToken = {
+ text: string;
+ /**
+ * The token-color palette key (`keyword`, `string`, `comment`, ...).
+ * `null` for plain text runs the highlighter didn't tag.
+ */
+ className: string | null;
+};
+
+// Map a highlight.js class name to our smaller palette. We don't ship
+// every hljs sub-language — only the ones that show up in the
+// reviewer surface often enough to be worth coloring.
+const HLJS_CLASS_PALETTE: Record = {
+ // Keywords / control flow
+ keyword: 'keyword',
+ built_in: 'builtin',
+ 'builtin-name': 'builtin',
+ literal: 'literal',
+ symbol: 'literal',
+ boolean: 'literal',
+ number: 'number',
+ 'function-variable': 'function',
+ 'class-name': 'type',
+ type: 'type',
+ 'title.function': 'function',
+ 'title.class': 'type',
+ function: 'function',
+ attr: 'attribute',
+ attribute: 'attribute',
+ variable: 'variable',
+ template_variable: 'variable',
+ params: 'variable',
+ property: 'property',
+ tag: 'tag',
+ selector: 'selector',
+ selector_tag: 'selector',
+ selector_class: 'selector',
+ selector_id: 'selector',
+ selector_pseudo: 'selector',
+ // Literals
+ string: 'string',
+ regexp: 'string',
+ meta_string: 'string',
+ subst: 'string',
+ char: 'string',
+ // Comments / doc
+ comment: 'comment',
+ doctag: 'comment',
+ quote: 'string',
+ // Operators / punctuation
+ operator: 'operator',
+ punctuation: 'operator',
+ // Misc
+ meta: 'meta',
+ addition: 'add',
+ deletion: 'del',
+};
+
+// Cap the per-line cache at 5,000 entries — large diffs can have many
+// repeated short lines (empty context rows, import lines) but a hard
+// ceiling keeps memory bounded for an attacker-controlled patch.
+const HIGHLIGHT_CACHE_LIMIT = 5000;
+const highlightCache = new Map();
+
+function tokenFromHljsClassNames(classNames: readonly string[]): string | null {
+ for (const name of classNames) {
+ const palette = HLJS_CLASS_PALETTE[name];
+ if (palette) {
+ return palette;
+ }
+ // highlight.js also prefixes with `hljs-` for some grammars; strip
+ // the prefix and try again.
+ if (name.startsWith('hljs-')) {
+ const stripped = name.slice('hljs-'.length);
+ const palette2 = HLJS_CLASS_PALETTE[stripped];
+ if (palette2) {
+ return palette2;
+ }
+ }
+ }
+ return null;
+}
+
+type HastNode = {
+ type: string;
+ // hast element nodes carry `properties: { className?: string[] }`.
+ properties?: { className?: string[] };
+ // hast text nodes carry `value: string`.
+ value?: string;
+ children?: HastNode[];
+};
+
+function flattenHast(node: HastNode, out: HighlightToken[]): void {
+ if (node.type === 'text' || node.type === 'root') {
+ // The root node carries the per-line wrapper; we still want its
+ // text children to come out as plain runs.
+ if (node.value) {
+ out.push({ text: node.value, className: null });
+ }
+ if (node.children) {
+ for (const child of node.children) {
+ flattenHast(child, out);
+ }
+ }
+ return;
+ }
+ if (node.type === 'element') {
+ const classNames = node.properties?.className ?? [];
+ const token = tokenFromHljsClassNames(classNames);
+ // For an element, gather all descendant text into a single token
+ // run so React Native's nests cleanly. This is what GitHub
+ // does in its tree-sitter-backed view as well.
+ const text = collectText(node);
+ if (text.length > 0) {
+ out.push({ text, className: token });
+ }
+ return;
+ }
+ if (node.value) {
+ out.push({ text: node.value, className: null });
+ }
+}
+
+function collectText(node: HastNode): string {
+ if (node.type === 'text') {
+ return node.value ?? '';
+ }
+ if (!node.children) {
+ return '';
+ }
+ let result = '';
+ for (const child of node.children) {
+ result += collectText(child);
+ }
+ return result;
+}
+
+/**
+ * Highlight a single line of source code into a flat list of
+ * `{text, className}` tokens. The highlighter is run per-line so
+ * multi-line tokens (block comments, multi-line strings) may be
+ * mis-colored on continuation lines — this is the accepted v1
+ * ceiling and matches GitHub's mobile web view.
+ *
+ * Returns a single plain-text run if the language is unknown or the
+ * highlighter throws on malformed input.
+ */
+export function highlightLine(text: string, language: string | null): HighlightToken[] {
+ if (!language) {
+ return [{ text, className: null }];
+ }
+ const cacheKey = `${language}\u0000${text}`;
+ const cached = highlightCache.get(cacheKey);
+ if (cached) {
+ // LRU touch — re-insert to move to the end of insertion order.
+ highlightCache.delete(cacheKey);
+ highlightCache.set(cacheKey, cached);
+ return cached;
+ }
+ const tokens = runHighlight(text, language);
+ if (highlightCache.size >= HIGHLIGHT_CACHE_LIMIT) {
+ // Drop the oldest entry. Map iteration is insertion-ordered, so
+ // the first key is the least-recently-used.
+ const oldest = highlightCache.keys().next().value;
+ if (oldest !== undefined) {
+ highlightCache.delete(oldest);
+ }
+ }
+ highlightCache.set(cacheKey, tokens);
+ return tokens;
+}
+
+function runHighlight(text: string, language: string): HighlightToken[] {
+ try {
+ const tree = getLowlight().highlight(language, text);
+ const tokens: HighlightToken[] = [];
+ // The root node has a single span child whose children carry the
+ // real classes. We flatten through `flattenHast` to get one
+ // token per contiguous text/class run.
+ flattenHast(tree as unknown as HastNode, tokens);
+ if (tokens.length === 0 && text.length > 0) {
+ return [{ text, className: null }];
+ }
+ return tokens;
+ } catch {
+ // The grammar threw (e.g. unknown language) — fall back to plain.
+ return [{ text, className: null }];
+ }
+}
+
+/**
+ * For tests: clear the per-line cache so a test that swaps the
+ * singleton (e.g. to register a custom grammar) gets a fresh state.
+ */
+export function clearHighlightCacheForTests(): void {
+ highlightCache.clear();
+ lowlightInstance = null;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts b/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts
new file mode 100644
index 0000000000..459c240fc2
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/parse-patch.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it } from 'vitest';
+
+import { type ParsedHunk, parsePatch } from './parse-patch';
+
+function firstHunk(hunks: ParsedHunk[]): ParsedHunk {
+ const hunk = hunks[0];
+ if (!hunk) {
+ throw new Error('expected at least one hunk');
+ }
+ return hunk;
+}
+
+function nthHunk(hunks: ParsedHunk[], index: number): ParsedHunk {
+ const hunk = hunks[index];
+ if (!hunk) {
+ throw new Error(`expected hunk at index ${index}`);
+ }
+ return hunk;
+}
+
+describe('parsePatch', () => {
+ it('returns an empty result for a null patch', () => {
+ expect(parsePatch(null)).toEqual({ isRename: false, hunks: [] });
+ });
+
+ it('returns an empty result for an empty string', () => {
+ expect(parsePatch('')).toEqual({ isRename: false, hunks: [] });
+ });
+
+ it('parses a simple added line', () => {
+ const patch = [
+ 'diff --git a/hello.ts b/hello.ts',
+ 'index 0000001..1111111 100644',
+ '--- a/hello.ts',
+ '+++ b/hello.ts',
+ '@@ -0,0 +1,1 @@',
+ '+export const hello = "world";',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ expect(result.isRename).toBe(false);
+ expect(result.hunks).toHaveLength(1);
+ const hunk = firstHunk(result.hunks);
+ expect(hunk.oldStart).toBe(0);
+ expect(hunk.oldLines).toBe(0);
+ expect(hunk.newStart).toBe(1);
+ expect(hunk.newLines).toBe(1);
+ expect(hunk.lines).toEqual([
+ {
+ type: 'add',
+ newLine: 1,
+ text: 'export const hello = "world";',
+ noNewlineAtEndOfFile: false,
+ },
+ ]);
+ });
+
+ it('parses a pure deletion with context', () => {
+ const patch = [
+ 'diff --git a/hello.ts b/hello.ts',
+ '@@ -1,3 +1,1 @@',
+ ' import { foo } from "./foo";',
+ '-import { bar } from "./bar";',
+ '-import { baz } from "./baz";',
+ ' export const x = foo();',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ expect(result.hunks).toHaveLength(1);
+ const hunk = firstHunk(result.hunks);
+ expect(hunk.oldStart).toBe(1);
+ expect(hunk.oldLines).toBe(3);
+ expect(hunk.newStart).toBe(1);
+ expect(hunk.newLines).toBe(1);
+ expect(hunk.lines).toEqual([
+ {
+ type: 'context',
+ oldLine: 1,
+ newLine: 1,
+ text: 'import { foo } from "./foo";',
+ noNewlineAtEndOfFile: false,
+ },
+ {
+ type: 'del',
+ oldLine: 2,
+ text: 'import { bar } from "./bar";',
+ noNewlineAtEndOfFile: false,
+ },
+ {
+ type: 'del',
+ oldLine: 3,
+ text: 'import { baz } from "./baz";',
+ noNewlineAtEndOfFile: false,
+ },
+ {
+ type: 'context',
+ oldLine: 4,
+ newLine: 2,
+ text: 'export const x = foo();',
+ noNewlineAtEndOfFile: false,
+ },
+ ]);
+ });
+
+ it('parses a multi-hunk patch with correct line counters per hunk', () => {
+ const patch = [
+ 'diff --git a/file.ts b/file.ts',
+ '@@ -1,2 +1,2 @@',
+ ' line one',
+ '-old line two',
+ '+new line two',
+ ' line three',
+ '@@ -10,3 +10,4 @@',
+ ' line ten',
+ '+inserted after ten',
+ ' line eleven',
+ ' line twelve',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ expect(result.hunks).toHaveLength(2);
+
+ const first = firstHunk(result.hunks);
+ expect(first.oldStart).toBe(1);
+ expect(first.newStart).toBe(1);
+ expect(first.lines.map(l => l.type)).toEqual(['context', 'del', 'add', 'context']);
+ expect(first.lines.map(l => l.oldLine)).toEqual([1, 2, undefined, 3]);
+ expect(first.lines.map(l => l.newLine)).toEqual([1, undefined, 2, 3]);
+
+ const second = nthHunk(result.hunks, 1);
+ expect(second.oldStart).toBe(10);
+ expect(second.newStart).toBe(10);
+ expect(second.lines.map(l => l.type)).toEqual(['context', 'add', 'context', 'context']);
+ expect(second.lines[1]?.newLine).toBe(11);
+ });
+
+ it('attaches the no-newline marker to the immediately preceding add/del line', () => {
+ // GitHub emits the `\ No newline at end of file` marker as a
+ // separate line directly after the line it qualifies — it never
+ // appears between two add/del lines. So the marker is attached to
+ // the most recent add/del, not to both halves of a -/+ pair.
+ const patch = [
+ 'diff --git a/single.txt b/single.txt',
+ '@@ -1,1 +1,1 @@',
+ '-old content',
+ '+new content',
+ String.raw`\ No newline at end of file`,
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ const lines = firstHunk(result.hunks).lines;
+ expect(lines).toHaveLength(2);
+ expect(lines[0]).toMatchObject({ type: 'del', noNewlineAtEndOfFile: false });
+ expect(lines[1]).toMatchObject({ type: 'add', noNewlineAtEndOfFile: true });
+ });
+
+ it('attaches the no-newline marker to a del when the del is the last non-context line', () => {
+ const patch = [
+ 'diff --git a/single.txt b/single.txt',
+ '@@ -1,2 +0,0 @@',
+ '-removed line',
+ String.raw`\ No newline at end of file`,
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ const lines = firstHunk(result.hunks).lines;
+ expect(lines).toHaveLength(1);
+ expect(lines[0]).toMatchObject({ type: 'del', noNewlineAtEndOfFile: true });
+ });
+
+ it('does not emit the no-newline marker as its own line', () => {
+ const patch = [
+ 'diff --git a/single.txt b/single.txt',
+ '@@ -1,1 +1,1 @@',
+ '-old',
+ '+new',
+ String.raw`\ No newline at end of file`,
+ ' context',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ const lines = firstHunk(result.hunks).lines;
+ // 2 non-context lines + 1 context line = 3 total. The marker is
+ // attached to the preceding add, not counted as its own row.
+ expect(lines).toHaveLength(3);
+ expect(lines[0]?.type).toBe('del');
+ expect(lines[1]?.type).toBe('add');
+ expect(lines[1]?.noNewlineAtEndOfFile).toBe(true);
+ expect(lines[2]?.type).toBe('context');
+ });
+
+ it('parses a renamed file and exposes previousPath', () => {
+ const patch = [
+ 'diff --git a/old-name.ts b/new-name.ts',
+ 'similarity index 95%',
+ 'rename from old-name.ts',
+ 'rename to new-name.ts',
+ '@@ -1,1 +1,1 @@',
+ '-export const a = 1;',
+ '+export const a = 2;',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ expect(result.isRename).toBe(true);
+ expect(result.previousPath).toBe('old-name.ts');
+ expect(result.hunks).toHaveLength(1);
+ expect(firstHunk(result.hunks).lines).toHaveLength(2);
+ });
+
+ it('handles headers without explicit line counts (defaults to 1)', () => {
+ const patch = ['diff --git a/short.txt b/short.txt', '@@ -1 +1 @@', '-old', '+new'].join('\n');
+
+ const result = parsePatch(patch);
+ const hunk = firstHunk(result.hunks);
+ expect(hunk.oldLines).toBe(1);
+ expect(hunk.newLines).toBe(1);
+ });
+
+ it('preserves a hunk header section heading (e.g. function name) in the header text', () => {
+ const patch = [
+ 'diff --git a/file.ts b/file.ts',
+ '@@ -1,1 +1,1 @@ def greet():',
+ '-print("hi")',
+ '+print("hello")',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ expect(firstHunk(result.hunks).header).toBe('@@ -1,1 +1,1 @@ def greet():');
+ });
+
+ it('attaches the no-newline marker to the immediately-preceding context line', () => {
+ const patch = [
+ 'diff --git a/x.ts b/x.ts',
+ '@@ -1,2 +1,2 @@',
+ '-old last line',
+ '+new last line',
+ ' trailing context',
+ String.raw`\ No newline at end of file`,
+ ].join('\n');
+
+ const lines = firstHunk(parsePatch(patch).hunks).lines;
+ const contextLine = lines.find(l => l.text === 'trailing context');
+ const addLine = lines.find(l => l.text === 'new last line');
+ // The marker qualifies the context line it directly follows, NOT the
+ // earlier add/del line.
+ expect(contextLine?.noNewlineAtEndOfFile).toBe(true);
+ expect(addLine?.noNewlineAtEndOfFile).toBe(false);
+ });
+
+ it('normalizes CRLF line endings and still matches the no-newline marker', () => {
+ const patch = [
+ 'diff --git a/x.ts b/x.ts',
+ '@@ -1,1 +1,1 @@',
+ '-old',
+ '+new',
+ String.raw`\ No newline at end of file`,
+ ].join('\r\n');
+
+ const lines = firstHunk(parsePatch(patch).hunks).lines;
+ const addLine = lines.find(l => l.type === 'add');
+ // No stray \r leaked into the rendered content, and the marker matched.
+ expect(addLine?.text).toBe('new');
+ expect(addLine?.noNewlineAtEndOfFile).toBe(true);
+ });
+
+ it('returns an empty hunks array for a malformed hunk header', () => {
+ const patch = [
+ 'diff --git a/x.ts b/x.ts',
+ '@@ this is not a real header @@',
+ '-whatever',
+ '+whatever',
+ ].join('\n');
+
+ const result = parsePatch(patch);
+ // The parser bails out of the rest of the patch on a malformed
+ // header; the caller still has the file metadata from the DTO so
+ // it can render "Open on GitHub" as a fallback.
+ expect(result.hunks).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/parse-patch.ts b/apps/mobile/src/lib/pr-review/diff/parse-patch.ts
new file mode 100644
index 0000000000..d9e6721960
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/parse-patch.ts
@@ -0,0 +1,248 @@
+// Pure unified-diff parser for GitHub's `file.patch` strings.
+//
+// GitHub emits a single `diff --git a/ b/` block per file
+// followed by one or more `@@ -oldStart,oldLines +newStart,newLines @@`
+// hunks. Each hunk body is a sequence of lines starting with ' '
+// (context), '+' (add), or '-' (del). A trailing `\ No newline at end of
+// file` marker belongs to the previous add/del line and must not become
+// its own diff line — that marker means "the previous line had no
+// terminating newline" and is purely metadata, not diff content.
+//
+// Renamed files appear with a `rename from ` / `rename to `
+// header in addition to the `diff --git` line. We expose those via
+// `isRename` + `previousPath` on the parsed file result so the UI can
+// render an "old → new" header. The actual diff body for a rename is
+// still a normal unified diff against the new path, so `hunks` does not
+// need any special treatment.
+//
+// Outputs are plain data — no React, no React Native, no lowlight —
+// so this module is testable in plain Node and reusable by any future
+// non-mobile surface (web, CLI, etc.).
+
+export type DiffLineType = 'context' | 'add' | 'del';
+
+export type ParsedDiffLine = {
+ type: DiffLineType;
+ /** 1-indexed line number in the old file. Undefined for `add` lines. */
+ oldLine?: number;
+ /** 1-indexed line number in the new file. Undefined for `del` lines. */
+ newLine?: number;
+ /** The line content, without the leading +/-/space marker. */
+ text: string;
+ /**
+ * The previous line had no terminating newline. Carried as a flag
+ * rather than its own line so the caller can render it once and the
+ * total line count matches the row count the user sees.
+ */
+ noNewlineAtEndOfFile: boolean;
+};
+
+export type ParsedHunk = {
+ /** The raw `@@ -a,b +c,d @@` header line, minus the trailing section heading. */
+ header: string;
+ oldStart: number;
+ oldLines: number;
+ newStart: number;
+ newLines: number;
+ lines: ParsedDiffLine[];
+};
+
+export type ParsedPatch = {
+ isRename: boolean;
+ previousPath?: string;
+ hunks: ParsedHunk[];
+};
+
+const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/;
+const NO_NEWLINE_MARKER = String.raw`\ No newline at end of file`;
+// `git apply` accepts " " / "+" / "-" / "\" as line-type markers.
+const LINE_MARKERS = new Set([' ', '+', '-', '\\']);
+
+type ParseState = {
+ isRename: boolean;
+ previousPath: string | undefined;
+ hunks: ParsedHunk[];
+ currentHunk: ParsedHunk | null;
+ oldLineNo: number;
+ newLineNo: number;
+ /** Index of the immediately-preceding parsed body line (any type). */
+ lastLineIndex: number;
+ abort: boolean;
+};
+
+function processDiffGitLine(state: ParseState): void {
+ // Starting a new file — flush any open hunk defensively even
+ // though GitHub's output is well-formed and never has two diff
+ // blocks in a single patch string.
+ if (state.currentHunk) {
+ state.hunks.push(state.currentHunk);
+ state.currentHunk = null;
+ }
+}
+
+function processRenameFrom(state: ParseState, line: string): void {
+ state.isRename = true;
+ state.previousPath = line.slice('rename from '.length);
+}
+
+function processHunkHeader(state: ParseState, line: string): void {
+ if (state.currentHunk) {
+ state.hunks.push(state.currentHunk);
+ }
+ const match = HUNK_HEADER_RE.exec(line);
+ if (!match) {
+ // Unparseable hunk header — bail out of the rest of this
+ // patch. The file DTO still has its path / status / counts
+ // so the user can read the file via "Open on GitHub".
+ state.currentHunk = null;
+ state.abort = true;
+ return;
+ }
+ const oldStart = Number(match[1]);
+ const oldLines = match[2] === undefined ? 1 : Number(match[2]);
+ const newStart = Number(match[3]);
+ const newLines = match[4] === undefined ? 1 : Number(match[4]);
+ state.currentHunk = {
+ header: `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@${match[5] ?? ''}`,
+ oldStart,
+ oldLines,
+ newStart,
+ newLines,
+ lines: [],
+ };
+ state.oldLineNo = oldStart;
+ state.newLineNo = newStart;
+ state.lastLineIndex = -1;
+}
+
+function attachNoNewlineMarker(state: ParseState): void {
+ if (!state.currentHunk || state.lastLineIndex < 0) {
+ return;
+ }
+ const previous = state.currentHunk.lines[state.lastLineIndex];
+ if (!previous) {
+ return;
+ }
+ state.currentHunk.lines[state.lastLineIndex] = {
+ type: previous.type,
+ ...(previous.oldLine !== undefined ? { oldLine: previous.oldLine } : {}),
+ ...(previous.newLine !== undefined ? { newLine: previous.newLine } : {}),
+ text: previous.text,
+ noNewlineAtEndOfFile: true,
+ };
+}
+
+function processBodyLine(state: ParseState, line: string): void {
+ if (!state.currentHunk) {
+ // Diff metadata lines (index, ---, +++, similarity, etc.) are
+ // skipped — we only emit actual hunk bodies.
+ return;
+ }
+ if (line === NO_NEWLINE_MARKER) {
+ attachNoNewlineMarker(state);
+ return;
+ }
+ const marker = line[0];
+ if (!marker || !LINE_MARKERS.has(marker) || marker === '\\') {
+ // Empty / unrecognized line inside a hunk body — skip rather
+ // than treat as a malformed context line. The leading '\' case
+ // for non-marker no-newline lines is already handled above.
+ return;
+ }
+ const text = line.slice(1);
+ if (marker === ' ') {
+ state.currentHunk.lines.push({
+ type: 'context',
+ oldLine: state.oldLineNo,
+ newLine: state.newLineNo,
+ text,
+ noNewlineAtEndOfFile: false,
+ });
+ state.oldLineNo += 1;
+ state.newLineNo += 1;
+ state.lastLineIndex = state.currentHunk.lines.length - 1;
+ return;
+ }
+ if (marker === '+') {
+ state.currentHunk.lines.push({
+ type: 'add',
+ newLine: state.newLineNo,
+ text,
+ noNewlineAtEndOfFile: false,
+ });
+ state.newLineNo += 1;
+ state.lastLineIndex = state.currentHunk.lines.length - 1;
+ return;
+ }
+ // marker === '-'
+ state.currentHunk.lines.push({
+ type: 'del',
+ oldLine: state.oldLineNo,
+ text,
+ noNewlineAtEndOfFile: false,
+ });
+ state.oldLineNo += 1;
+ state.lastLineIndex = state.currentHunk.lines.length - 1;
+}
+
+function processLine(state: ParseState, line: string): void {
+ if (line.startsWith('diff --git ')) {
+ processDiffGitLine(state);
+ return;
+ }
+ if (line.startsWith('rename from ')) {
+ processRenameFrom(state, line);
+ return;
+ }
+ if (line.startsWith('rename to ')) {
+ // The `rename to` path is the same as the new file path (already
+ // available in the file DTO), so we don't re-parse it here.
+ return;
+ }
+ if (line.startsWith('@@')) {
+ processHunkHeader(state, line);
+ return;
+ }
+ processBodyLine(state, line);
+}
+
+export function parsePatch(patch: string | null | undefined): ParsedPatch {
+ if (!patch) {
+ return { isRename: false, hunks: [] };
+ }
+
+ // Normalize line endings — GitHub's API returns \n, but splitting on
+ // \r?\n keeps the parser robust against a proxy that returns CRLF and
+ // ensures the `\ No newline at end of file` marker matches exactly
+ // (a stray \r would otherwise leak into rendered content and break the
+ // marker match).
+ const rawLines = patch.split(/\r?\n/);
+
+ const state: ParseState = {
+ isRename: false,
+ previousPath: undefined,
+ hunks: [],
+ currentHunk: null,
+ oldLineNo: 0,
+ newLineNo: 0,
+ lastLineIndex: -1,
+ abort: false,
+ };
+
+ for (const line of rawLines) {
+ if (state.abort) {
+ break;
+ }
+ processLine(state, line);
+ }
+
+ if (state.currentHunk) {
+ state.hunks.push(state.currentHunk);
+ }
+
+ return {
+ isRename: state.isRename,
+ ...(state.previousPath ? { previousPath: state.previousPath } : {}),
+ hunks: state.hunks,
+ };
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts
new file mode 100644
index 0000000000..52522c247d
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-gap-builder.ts
@@ -0,0 +1,121 @@
+// Gap builder helpers for the PR diff FlashList. Kept separate so the
+// main list builder stays under the max-lines limit.
+
+import { type ParsedDiffLine, type ParsedPatch } from '@/lib/pr-review/diff/parse-patch';
+import {
+ type BuildItemsArgs,
+ type DiffViewMode,
+ type ExpandSeparatorItem,
+ type ExpandSeparatorState,
+ getCumulativeLines,
+ getTotalLines,
+ type ListItem,
+} from '@/lib/pr-review/diff/pr-diff-list-items';
+import { type SideBySideRow } from '@/lib/pr-review/diff/side-by-side';
+
+function deriveSeparatorState(
+ state: ExpandSeparatorState,
+ loadedCount: number
+): ExpandSeparatorItem['state'] {
+ if (state.status === 'loading') {
+ return 'loading';
+ }
+ if (state.status === 'error') {
+ return 'error';
+ }
+ if (state.status === 'unavailable') {
+ return 'unavailable';
+ }
+ return loadedCount > 0 ? 'partial' : 'idle';
+}
+
+export function pushGapItems(args: {
+ items: ListItem[];
+ file: BuildItemsArgs['files'][number];
+ startLine: number;
+ endLine: number;
+ gapIndex: number;
+ hunkIndex: number;
+ fileContext: Record;
+ parsed: ParsedPatch;
+ language: string | null;
+ headSha: string;
+ viewMode?: DiffViewMode;
+}): void {
+ const state = args.fileContext[args.gapIndex] ?? { status: 'idle' as const };
+ const cumulativeLines = getCumulativeLines(state);
+ const loadedCount = cumulativeLines.length;
+ const effectiveEndLine = getTotalLines(state) ?? args.endLine;
+ const gapSize = effectiveEndLine - args.startLine + 1;
+ const isComplete = loadedCount >= gapSize;
+ const viewMode: DiffViewMode = args.viewMode ?? 'unified';
+
+ for (let lineIdx = 0; lineIdx < loadedCount; lineIdx += 1) {
+ const lineText = cumulativeLines[lineIdx] ?? '';
+ const newLineNo = args.startLine + lineIdx;
+ if (viewMode === 'side-by-side') {
+ const line: ParsedDiffLine = {
+ type: 'context',
+ oldLine: newLineNo,
+ newLine: newLineNo,
+ text: lineText,
+ noNewlineAtEndOfFile: false,
+ };
+ const row: SideBySideRow = { left: { line }, right: { line } };
+ const rowKeyId = `gap-sbs:${args.file.path}:${args.gapIndex}:${lineIdx}`;
+ args.items.push({
+ kind: 'side-by-side-row',
+ key: rowKeyId,
+ rowKey: rowKeyId,
+ filePath: args.file.path,
+ hunkIndex: args.hunkIndex,
+ rowIndex: lineIdx,
+ row,
+ language: args.language,
+ rowKeyId,
+ });
+ } else {
+ args.items.push({
+ kind: 'diff-line',
+ key: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`,
+ lineKey: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`,
+ filePath: args.file.path,
+ hunkIndex: args.hunkIndex,
+ lineIndex: lineIdx,
+ parsed: args.parsed,
+ line: {
+ type: 'context',
+ newLine: newLineNo,
+ text: lineText,
+ noNewlineAtEndOfFile: false,
+ },
+ language: args.language,
+ lineKeyId: `gap-line:${args.file.path}:${args.gapIndex}:${lineIdx}`,
+ selectable: false,
+ });
+ }
+ }
+
+ if (isComplete || state.status === 'unavailable') {
+ return;
+ }
+
+ const remainingStartLine = args.startLine + loadedCount;
+ args.items.push({
+ kind: 'expand-separator',
+ key: `gap:${args.file.path}:${args.gapIndex}`,
+ filePath: args.file.path,
+ ref: {
+ owner: '',
+ repo: '',
+ number: 0,
+ ref: args.headSha,
+ },
+ context: {
+ gapIndex: args.gapIndex,
+ startLine: remainingStartLine,
+ endLine: effectiveEndLine,
+ },
+ state: deriveSeparatorState(state, loadedCount),
+ });
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts
new file mode 100644
index 0000000000..82b5c9b63e
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder-side-by-side.test.ts
@@ -0,0 +1,102 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder';
+import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+
+type DiffLineListItem = Extract;
+type SeparatorItem = Extract;
+type SideBySideRowItem = Extract;
+
+function diffLines(items: ListItem[]): DiffLineListItem[] {
+ return items.filter((i): i is DiffLineListItem => i.kind === 'diff-line');
+}
+function separators(items: ListItem[]): SeparatorItem[] {
+ return items.filter((i): i is SeparatorItem => i.kind === 'expand-separator');
+}
+function sideBySideRows(items: ListItem[]): SideBySideRowItem[] {
+ return items.filter((i): i is SideBySideRowItem => i.kind === 'side-by-side-row');
+}
+function separatorFor(items: ListItem[], gapIndex: number): SeparatorItem | undefined {
+ return separators(items).find(s => s.context.gapIndex === gapIndex);
+}
+
+function makeFile(patch: string, path = 'a.ts'): PrReviewFile {
+ return {
+ path,
+ previousPath: null,
+ status: 'modified',
+ additions: 1,
+ deletions: 1,
+ patch,
+ patchMissing: false,
+ };
+}
+
+function baseArgs(overrides: Partial = {}): BuildItemsArgs {
+ return {
+ files: [],
+ expanded: {},
+ expandedContext: {},
+ viewed: () => false,
+ headSha: 'abc',
+ owner: 'owner',
+ repo: 'repo',
+ number: 1,
+ changedFiles: 0,
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ laterPageError: false,
+ fetchToCompletionRunning: false,
+ fetchToCompletionLoaded: 0,
+ totalFiles: null,
+ viewMode: 'side-by-side',
+ ...overrides,
+ };
+}
+
+const singleHunkPatch = [
+ 'diff --git a/a.ts b/a.ts',
+ '@@ -5,3 +5,3 @@',
+ ' context line 5',
+ '-old line 6',
+ '+new line 6',
+ ' context line 7',
+].join('\n');
+
+describe('buildItems side-by-side gaps', () => {
+ it('renders loaded leading-gap context as side-by-side rows, not unified diff lines', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(singleHunkPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': { [-1]: { status: 'partial', lines: ['leading 1', 'leading 2'], totalLines: 4 } },
+ },
+ })
+ );
+
+ const gapUnified = diffLines(items).filter(l => l.lineKey.startsWith('gap-line:'));
+ expect(gapUnified).toHaveLength(0);
+
+ const gapSbs = sideBySideRows(items).filter(r => r.rowKey.startsWith('gap-sbs:a.ts:-1:'));
+ expect(gapSbs).toHaveLength(2);
+ expect(gapSbs[0]?.row.left?.line.text).toBe('leading 1');
+ expect(gapSbs[0]?.row.right?.line.text).toBe('leading 1');
+ expect(gapSbs[0]?.row.left?.line.oldLine).toBe(1);
+ expect(gapSbs[0]?.row.left?.line.newLine).toBe(1);
+ expect(gapSbs[1]?.row.left?.line.text).toBe('leading 2');
+ expect(gapSbs[1]?.row.left?.line.newLine).toBe(2);
+ });
+
+ it('keeps the leading gap expand-separator full-width in side-by-side mode', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(singleHunkPatch)],
+ expanded: { 'a.ts': true },
+ })
+ );
+ expect(separatorFor(items, -1)).toMatchObject({ kind: 'expand-separator' });
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts
new file mode 100644
index 0000000000..1ff1898e52
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.test.ts
@@ -0,0 +1,293 @@
+import { describe, expect, it } from 'vitest';
+
+import { buildItems } from '@/lib/pr-review/diff/pr-diff-list-builder';
+import { type BuildItemsArgs, type ListItem } from '@/lib/pr-review/diff/pr-diff-list-items';
+import { type PrReviewFile } from '@/lib/pr-review/diff/pr-review-file-types';
+
+type FilePatchMissingItem = Extract;
+
+type SeparatorItem = Extract;
+type DiffLineListItem = Extract;
+type PaginationItem = Extract;
+
+function patchMissingItems(items: ListItem[]): FilePatchMissingItem[] {
+ return items.filter((i): i is FilePatchMissingItem => i.kind === 'file-patch-missing');
+}
+
+function separators(items: ListItem[]): SeparatorItem[] {
+ return items.filter((i): i is SeparatorItem => i.kind === 'expand-separator');
+}
+function diffLines(items: ListItem[]): DiffLineListItem[] {
+ return items.filter((i): i is DiffLineListItem => i.kind === 'diff-line');
+}
+function paginationRow(items: ListItem[]): PaginationItem | undefined {
+ return items.find((i): i is PaginationItem => i.kind === 'pagination-row');
+}
+
+function makeFile(patch: string, path = 'a.ts'): PrReviewFile {
+ return {
+ path,
+ previousPath: null,
+ status: 'modified',
+ additions: 1,
+ deletions: 1,
+ patch,
+ patchMissing: false,
+ };
+}
+
+function baseArgs(overrides: Partial = {}): BuildItemsArgs {
+ return {
+ files: [],
+ expanded: {},
+ expandedContext: {},
+ viewed: () => false,
+ headSha: 'abc',
+ owner: 'owner',
+ repo: 'repo',
+ number: 1,
+ changedFiles: 0,
+ isLoading: false,
+ isFetchingNextPage: false,
+ hasNextPage: false,
+ laterPageError: false,
+ fetchToCompletionRunning: false,
+ fetchToCompletionLoaded: 0,
+ totalFiles: null,
+ ...overrides,
+ };
+}
+
+const singleHunkPatch = [
+ 'diff --git a/a.ts b/a.ts',
+ '@@ -5,3 +5,3 @@',
+ ' context line 5',
+ '-old line 6',
+ '+new line 6',
+ ' context line 7',
+].join('\n');
+
+const twoHunkPatch = [
+ 'diff --git a/a.ts b/a.ts',
+ '@@ -5,3 +5,3 @@',
+ ' context line 5',
+ '-old line 6',
+ '+new line 6',
+ ' context line 7',
+ '@@ -15,3 +15,3 @@',
+ ' context line 15',
+ '-old line 16',
+ '+new line 16',
+ ' context line 17',
+].join('\n');
+
+const largeGapPatch = [
+ 'diff --git a/a.ts b/a.ts',
+ '@@ -5,3 +5,3 @@',
+ ' context line 5',
+ '-old line 6',
+ '+new line 6',
+ ' context line 7',
+ '@@ -45,3 +45,3 @@',
+ ' context line 45',
+ '-old line 46',
+ '+new line 46',
+ ' context line 47',
+].join('\n');
+
+describe('buildItems later-page error', () => {
+ it('emits an error pagination row when laterPageError + hasNextPage', () => {
+ const items = buildItems(baseArgs({ hasNextPage: true, laterPageError: true }));
+ expect(paginationRow(items)).toMatchObject({ kind: 'pagination-row', state: 'error' });
+ });
+
+ it('does not emit an error pagination row when laterPageError is false', () => {
+ const items = buildItems(baseArgs({ hasNextPage: true, laterPageError: false }));
+ expect(paginationRow(items)?.state).not.toBe('error');
+ });
+
+ it('does not emit an error pagination row when there is no next page', () => {
+ const items = buildItems(baseArgs({ hasNextPage: false, laterPageError: true }));
+ expect(paginationRow(items)?.state).not.toBe('error');
+ });
+});
+
+function gapLinesFor(items: ListItem[], path: string, gapIndex: number): DiffLineListItem[] {
+ const prefix = `gap-line:${path}:${gapIndex}:`;
+ return diffLines(items).filter(l => l.lineKey.startsWith(prefix));
+}
+
+function separatorFor(items: ListItem[], gapIndex: number): SeparatorItem | undefined {
+ return separators(items).find(s => s.context.gapIndex === gapIndex);
+}
+
+describe('buildItems gap separators', () => {
+ it('renders a leading separator when the first hunk starts after line 1', () => {
+ const items = buildItems(
+ baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } })
+ );
+ expect(separatorFor(items, -1)).toMatchObject({
+ context: { gapIndex: -1, startLine: 1, endLine: 4 },
+ });
+ });
+
+ it('renders a trailing separator after the last hunk', () => {
+ const items = buildItems(
+ baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } })
+ );
+ const trailing = separatorFor(items, 1);
+ expect(trailing).toMatchObject({ context: { gapIndex: 1, startLine: 8 }, state: 'idle' });
+ expect(Number.isFinite(trailing?.context.endLine ?? 0)).toBe(false);
+ });
+
+ it('renders between-hunk separators for gaps larger than 3 lines', () => {
+ const items = buildItems(
+ baseArgs({ files: [makeFile(twoHunkPatch)], expanded: { 'a.ts': true } })
+ );
+ expect(separatorFor(items, 0)).toMatchObject({
+ context: { gapIndex: 0, startLine: 8, endLine: 14 },
+ });
+ });
+});
+
+describe('buildItems progressive context windowing', () => {
+ it('first tap loads window 1 and keeps a partial separator for the remainder', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(largeGapPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': {
+ 0: { status: 'partial', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) },
+ },
+ },
+ })
+ );
+ const gapLines = gapLinesFor(items, 'a.ts', 0);
+ expect(gapLines).toHaveLength(20);
+ expect(gapLines[0]?.line.newLine).toBe(8);
+ expect(gapLines[19]?.line.newLine).toBe(27);
+ expect(separatorFor(items, 0)).toMatchObject({
+ state: 'partial',
+ context: { gapIndex: 0, startLine: 28, endLine: 44 },
+ });
+ });
+
+ it('second tap advances to window 2 without duplicating earlier lines', () => {
+ const lines = Array.from({ length: 37 }, (_, i) => `gap-line-${i}`);
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(largeGapPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: { 'a.ts': { 0: { status: 'partial', lines } } },
+ })
+ );
+ const gapLines = gapLinesFor(items, 'a.ts', 0);
+ expect(gapLines).toHaveLength(37);
+ expect(gapLines.map(l => l.line.text)).toEqual(lines);
+ expect(gapLines[0]?.line.newLine).toBe(8);
+ expect(gapLines[36]?.line.newLine).toBe(44);
+ expect(separatorFor(items, 0)).toBeUndefined();
+ });
+
+ it('expand all for a small gap removes the separator', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(twoHunkPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': {
+ 0: { status: 'partial', lines: Array.from({ length: 7 }, (_, i) => `gap-line-${i}`) },
+ },
+ },
+ })
+ );
+ expect(gapLinesFor(items, 'a.ts', 0)).toHaveLength(7);
+ expect(separatorFor(items, 0)).toBeUndefined();
+ });
+
+ it('a failed later window keeps earlier lines and surfaces an error separator', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(largeGapPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': {
+ 0: { status: 'error', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) },
+ },
+ },
+ })
+ );
+ expect(gapLinesFor(items, 'a.ts', 0)).toHaveLength(20);
+ expect(separatorFor(items, 0)).toMatchObject({
+ state: 'error',
+ context: { startLine: 28, endLine: 44 },
+ });
+ });
+
+ it('marks expanded gap lines as non-selectable', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(largeGapPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': {
+ 0: { status: 'partial', lines: Array.from({ length: 20 }, (_, i) => `gap-line-${i}`) },
+ },
+ },
+ })
+ );
+ const gapLines = gapLinesFor(items, 'a.ts', 0);
+ expect(gapLines.length).toBeGreaterThan(0);
+ for (const gapLine of gapLines) {
+ expect(gapLine.selectable).toBe(false);
+ }
+ });
+
+ it('leaves real parsed-hunk lines selectable by default', () => {
+ const items = buildItems(
+ baseArgs({ files: [makeFile(singleHunkPatch)], expanded: { 'a.ts': true } })
+ );
+ const lines = diffLines(items).filter(l => l.lineKey.startsWith('line:'));
+ expect(lines.length).toBeGreaterThan(0);
+ for (const line of lines) {
+ expect(line.selectable).not.toBe(false);
+ }
+ });
+});
+
+describe('buildItems malformed patch', () => {
+ it('routes a non-empty patch that parses to zero hunks through the patch-missing fallback', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile('this is not a valid patch', 'bad.ts')],
+ expanded: { 'bad.ts': true },
+ })
+ );
+ const missing = patchMissingItems(items);
+ expect(missing).toHaveLength(1);
+ expect(missing[0]).toMatchObject({
+ kind: 'file-patch-missing',
+ file: { path: 'bad.ts' },
+ });
+ });
+});
+
+describe('buildItems trailing gap totalLines', () => {
+ it('uses totalLines to bound the trailing gap once known', () => {
+ const items = buildItems(
+ baseArgs({
+ files: [makeFile(singleHunkPatch)],
+ expanded: { 'a.ts': true },
+ expandedContext: {
+ 'a.ts': { 1: { status: 'partial', lines: ['trailing-1'], totalLines: 9 } },
+ },
+ })
+ );
+ expect(gapLinesFor(items, 'a.ts', 1)).toHaveLength(1);
+ expect(separatorFor(items, 1)).toMatchObject({
+ state: 'partial',
+ context: { startLine: 9, endLine: 9 },
+ });
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts
new file mode 100644
index 0000000000..97cbf15357
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-builder.ts
@@ -0,0 +1,274 @@
+// List builder for the PR diff FlashList. Split from
+// `pr-diff-list-items.ts` so each file stays under the max-lines limit.
+
+import { languageForPath } from '@/lib/pr-review/diff/highlight';
+import { parsePatch } from '@/lib/pr-review/diff/parse-patch';
+import { pushGapItems } from '@/lib/pr-review/diff/pr-diff-gap-builder';
+import {
+ buildGithubFileUrl,
+ type BuildItemsArgs,
+ type DiffViewMode,
+ type ListItem,
+ type PaginationRowItem,
+ PR_REVIEW_MAX_LISTED_FILES,
+ shouldShowTruncationBanner,
+ truncationBannerCopy,
+} from '@/lib/pr-review/diff/pr-diff-list-items';
+import { buildSideBySideRows } from '@/lib/pr-review/diff/side-by-side';
+
+type ParsedFile = ReturnType;
+type ParsedHunk = ParsedFile['hunks'][number];
+
+function pushSideBySideHunk(args: {
+ items: ListItem[];
+ file: BuildItemsArgs['files'][number];
+ hunk: ParsedHunk;
+ hunkIndex: number;
+ language: string | null;
+}): void {
+ const { items, file, hunk, hunkIndex, language } = args;
+ items.push({
+ kind: 'hunk-side-by-side',
+ key: `hunk-sbs:${file.path}:${hunkIndex}`,
+ hunk,
+ hunkIndex,
+ language,
+ });
+ const rows = buildSideBySideRows(hunk);
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
+ const row = rows[rowIndex];
+ if (!row) {
+ break;
+ }
+ const rowKeyId = `sbs:${file.path}:${hunkIndex}:${rowIndex}`;
+ items.push({
+ kind: 'side-by-side-row',
+ key: rowKeyId,
+ rowKey: rowKeyId,
+ filePath: file.path,
+ hunkIndex,
+ rowIndex,
+ row,
+ language,
+ rowKeyId,
+ });
+ }
+}
+
+function pushUnifiedHunk(args: {
+ items: ListItem[];
+ file: BuildItemsArgs['files'][number];
+ hunk: ParsedHunk;
+ hunkIndex: number;
+ parsed: ParsedFile;
+ language: string | null;
+}): void {
+ const { items, file, hunk, hunkIndex, parsed, language } = args;
+ items.push({
+ kind: 'hunk-header',
+ key: `hunk:${file.path}:${hunkIndex}`,
+ header: hunk.header,
+ });
+ for (let lineIndex = 0; lineIndex < hunk.lines.length; lineIndex += 1) {
+ const line = hunk.lines[lineIndex];
+ if (!line) {
+ break;
+ }
+ items.push({
+ kind: 'diff-line',
+ key: `line:${file.path}:${hunkIndex}:${lineIndex}`,
+ lineKey: `line:${file.path}:${hunkIndex}:${lineIndex}`,
+ filePath: file.path,
+ hunkIndex,
+ lineIndex,
+ parsed,
+ line,
+ language,
+ lineKeyId: `line:${file.path}:${hunkIndex}:${lineIndex}`,
+ });
+ }
+}
+
+function pushPatchMissingItems(args: {
+ items: ListItem[];
+ file: BuildItemsArgs['files'][number];
+ viewed: boolean;
+ githubUrl: string;
+}): void {
+ args.items.push({
+ kind: 'file-patch-missing',
+ key: `file-pm:${args.file.path}`,
+ file: args.file,
+ viewed: args.viewed,
+ githubUrl: args.githubUrl,
+ });
+}
+
+function pushExpandedFileItems(
+ items: ListItem[],
+ file: BuildItemsArgs['files'][number],
+ args: BuildItemsArgs
+): void {
+ const githubUrl = buildGithubFileUrl({
+ owner: args.owner,
+ repo: args.repo,
+ number: args.number,
+ path: file.path,
+ });
+ const parsed: ParsedFile = file.patch ? parsePatch(file.patch) : { isRename: false, hunks: [] };
+ const hunks = parsed.hunks;
+
+ if (file.patchMissing || hunks.length === 0) {
+ pushPatchMissingItems({ items, file, viewed: args.viewed(file.path), githubUrl });
+ return;
+ }
+
+ const language = languageForPath(file.path);
+ const fileContext = args.expandedContext[file.path] ?? {};
+ const viewMode: DiffViewMode = args.viewMode ?? 'unified';
+
+ for (let hunkIndex = 0; hunkIndex < hunks.length; hunkIndex += 1) {
+ const hunk = hunks[hunkIndex];
+ if (!hunk) {
+ break;
+ }
+
+ if (hunkIndex === 0 && hunk.newStart > 1) {
+ pushGapItems({
+ items,
+ file,
+ startLine: 1,
+ endLine: hunk.newStart - 1,
+ gapIndex: -1,
+ hunkIndex: 0,
+ fileContext,
+ parsed,
+ language,
+ headSha: args.headSha,
+ viewMode,
+ });
+ }
+
+ if (hunkIndex > 0) {
+ const prevHunk = hunks[hunkIndex - 1];
+ if (prevHunk) {
+ const prevNewEnd = prevHunk.newStart + prevHunk.newLines - 1;
+ const gap = hunk.newStart - prevNewEnd - 1;
+ if (gap > 3) {
+ pushGapItems({
+ items,
+ file,
+ startLine: prevNewEnd + 1,
+ endLine: hunk.newStart - 1,
+ gapIndex: hunkIndex - 1,
+ hunkIndex,
+ fileContext,
+ parsed,
+ language,
+ headSha: args.headSha,
+ viewMode,
+ });
+ }
+ }
+ }
+
+ if (viewMode === 'side-by-side') {
+ pushSideBySideHunk({ items, file, hunk, hunkIndex, language });
+ } else {
+ pushUnifiedHunk({ items, file, hunk, hunkIndex, parsed, language });
+ }
+ }
+
+ const lastHunk = hunks.at(-1);
+ if (lastHunk) {
+ pushGapItems({
+ items,
+ file,
+ startLine: lastHunk.newStart + lastHunk.newLines,
+ endLine: Infinity,
+ gapIndex: hunks.length,
+ hunkIndex: hunks.length - 1,
+ fileContext,
+ parsed,
+ language,
+ headSha: args.headSha,
+ viewMode,
+ });
+ }
+}
+
+function pushPaginationState(
+ items: ListItem[],
+ state: PaginationRowItem['state'],
+ args: BuildItemsArgs
+): void {
+ items.push({
+ kind: 'pagination-row',
+ key: 'pagination-row',
+ state,
+ loadedFiles: args.isLoading ? 0 : args.fetchToCompletionLoaded,
+ totalFiles: args.totalFiles,
+ });
+}
+
+function pushPaginationItem(items: ListItem[], args: BuildItemsArgs): void {
+ if (args.isLoading) {
+ pushPaginationState(items, 'loading', args);
+ return;
+ }
+ if (args.hasNextPage) {
+ if (args.laterPageError && !args.isFetchingNextPage && !args.fetchToCompletionRunning) {
+ pushPaginationState(items, 'error', args);
+ return;
+ }
+ if (args.fetchToCompletionLoaded >= PR_REVIEW_MAX_LISTED_FILES) {
+ pushPaginationState(items, 'all-loaded', args);
+ return;
+ }
+ if (args.fetchToCompletionRunning) {
+ pushPaginationState(items, 'fetch-to-completion', args);
+ return;
+ }
+ if (args.isFetchingNextPage) {
+ pushPaginationState(items, 'loading', args);
+ return;
+ }
+ pushPaginationState(items, 'no-pages', args);
+ return;
+ }
+ if (args.isFetchingNextPage) {
+ pushPaginationState(items, 'loading', args);
+ return;
+ }
+ pushPaginationState(items, 'all-loaded', args);
+}
+
+export function buildItems(args: BuildItemsArgs): ListItem[] {
+ const items: ListItem[] = [];
+
+ if (shouldShowTruncationBanner(args.changedFiles)) {
+ items.push({
+ kind: 'truncation-banner',
+ key: 'truncation-banner',
+ text: truncationBannerCopy(args.changedFiles),
+ });
+ }
+
+ for (const file of args.files) {
+ const isExpanded = args.expanded[file.path] ?? false;
+ items.push({
+ kind: 'file-header',
+ key: `file-header:${file.path}`,
+ file,
+ expanded: isExpanded,
+ hasDiff: !file.patchMissing,
+ viewed: args.viewed(file.path),
+ });
+ if (isExpanded) {
+ pushExpandedFileItems(items, file, args);
+ }
+ }
+
+ pushPaginationItem(items, args);
+ return items;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts
new file mode 100644
index 0000000000..48794e3ed8
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ addContextLoadState,
+ getCumulativeLines,
+ setContextLines,
+} from '@/lib/pr-review/diff/pr-diff-list-items';
+
+describe('addContextLoadState', () => {
+ it('transitions idle to loading with empty lines', () => {
+ const next = addContextLoadState({
+ state: {},
+ filePath: 'a.ts',
+ gapIndex: 0,
+ status: 'loading',
+ });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'loading', lines: [] });
+ });
+
+ it('preserves existing lines when transitioning partial to loading', () => {
+ const state = {
+ 'a.ts': {
+ 0: { status: 'partial' as const, lines: ['line1'] },
+ },
+ };
+ const next = addContextLoadState({ state, filePath: 'a.ts', gapIndex: 0, status: 'loading' });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'loading', lines: ['line1'] });
+ });
+
+ it('preserves existing lines when transitioning partial to error', () => {
+ const state = {
+ 'a.ts': {
+ 0: { status: 'partial' as const, lines: ['line1'] },
+ },
+ };
+ const next = addContextLoadState({ state, filePath: 'a.ts', gapIndex: 0, status: 'error' });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'error', lines: ['line1'] });
+ });
+
+ it('clears state when marking unavailable', () => {
+ const state = {
+ 'a.ts': {
+ 0: { status: 'partial' as const, lines: ['line1'] },
+ },
+ };
+ const next = addContextLoadState({
+ state,
+ filePath: 'a.ts',
+ gapIndex: 0,
+ status: 'unavailable',
+ });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'unavailable' });
+ });
+});
+
+describe('setContextLines', () => {
+ it('sets partial state with lines from idle', () => {
+ const next = setContextLines({
+ state: {},
+ filePath: 'a.ts',
+ gapIndex: 0,
+ lines: ['line1', 'line2'],
+ });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1', 'line2'] });
+ });
+
+ it('appends new lines to existing partial state', () => {
+ const state = {
+ 'a.ts': {
+ 0: { status: 'partial' as const, lines: ['line1'] },
+ },
+ };
+ const next = setContextLines({ state, filePath: 'a.ts', gapIndex: 0, lines: ['line2'] });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1', 'line2'] });
+ });
+
+ it('stores totalLines when provided', () => {
+ const next = setContextLines({
+ state: {},
+ filePath: 'a.ts',
+ gapIndex: 0,
+ lines: ['line1'],
+ totalLines: 100,
+ });
+ expect(next['a.ts']?.[0]).toEqual({ status: 'partial', lines: ['line1'], totalLines: 100 });
+ });
+});
+
+describe('getCumulativeLines', () => {
+ it('returns lines for loading, partial, and error states', () => {
+ expect(getCumulativeLines({ status: 'loading', lines: ['a'] })).toEqual(['a']);
+ expect(getCumulativeLines({ status: 'partial', lines: ['b'] })).toEqual(['b']);
+ expect(getCumulativeLines({ status: 'error', lines: ['c'] })).toEqual(['c']);
+ });
+
+ it('returns empty array for idle and unavailable states', () => {
+ expect(getCumulativeLines({ status: 'idle' })).toEqual([]);
+ expect(getCumulativeLines({ status: 'unavailable' })).toEqual([]);
+ expect(getCumulativeLines(undefined)).toEqual([]);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts
new file mode 100644
index 0000000000..31b94e6044
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-diff-list-items.ts
@@ -0,0 +1,319 @@
+// Item types + small pure helpers for the PR diff FlashList.
+// The list builder lives in `pr-diff-list-builder.ts` so each file
+// stays under the max-lines limit.
+
+import {
+ PR_REVIEW_MAX_LISTED_FILES,
+ type PrReviewFile,
+} from '@/lib/pr-review/diff/pr-review-file-types';
+import {
+ shouldShowTruncationBanner,
+ truncationBannerCopy,
+} from '@/lib/pr-review/diff/pr-review-truncation';
+import {
+ type ParsedDiffLine,
+ type ParsedHunk,
+ type ParsedPatch,
+} from '@/lib/pr-review/diff/parse-patch';
+import { type SideBySideRow } from '@/lib/pr-review/diff/side-by-side';
+
+export type TruncationBannerItem = {
+ kind: 'truncation-banner';
+ key: string;
+ text: string;
+};
+
+export type FileHeaderItem = {
+ kind: 'file-header';
+ key: string;
+ file: PrReviewFile;
+ expanded: boolean;
+ hasDiff: boolean;
+ viewed: boolean;
+};
+
+export type FilePatchMissingItem = {
+ kind: 'file-patch-missing';
+ key: string;
+ file: PrReviewFile;
+ viewed: boolean;
+ githubUrl: string;
+};
+
+export type HunkHeaderItem = {
+ kind: 'hunk-header';
+ key: string;
+ header: string;
+};
+
+export type DiffLineItem = {
+ kind: 'diff-line';
+ key: string;
+ lineKey: string;
+ /** The owning file path — needed by the S7a tap producer to build a `DiffSelection`. */
+ filePath: string;
+ hunkIndex: number;
+ lineIndex: number;
+ parsed: ParsedPatch;
+ line: ParsedDiffLine;
+ language: string | null;
+ lineKeyId: string;
+ /**
+ * Gap/expanded-context lines are read-only and must not participate in
+ * diff-line selection. Real parsed-hunk lines are selectable by default.
+ */
+ selectable?: boolean;
+};
+
+export type SideBySideRowItem = {
+ kind: 'side-by-side-row';
+ key: string;
+ rowKey: string;
+ filePath: string;
+ hunkIndex: number;
+ rowIndex: number;
+ row: SideBySideRow;
+ language: string | null;
+ rowKeyId: string;
+};
+
+export type HunkSideBySideHeaderItem = {
+ kind: 'hunk-side-by-side';
+ key: string;
+ hunk: ParsedHunk;
+ hunkIndex: number;
+ language: string | null;
+};
+
+export type ExpandSeparatorItem = {
+ kind: 'expand-separator';
+ key: string;
+ filePath: string;
+ ref: { owner: string; repo: string; number: number; ref: string };
+ context: {
+ gapIndex: number;
+ startLine: number;
+ endLine: number;
+ };
+ state: 'idle' | 'loading' | 'error' | 'unavailable' | 'partial';
+};
+
+export type PaginationRowItem = {
+ kind: 'pagination-row';
+ key: string;
+ state: 'loading' | 'error' | 'fetch-to-completion' | 'all-loaded' | 'no-pages';
+ loadedFiles: number;
+ totalFiles: number | null;
+};
+
+export type ListItem =
+ | TruncationBannerItem
+ | FileHeaderItem
+ | FilePatchMissingItem
+ | HunkHeaderItem
+ | DiffLineItem
+ | SideBySideRowItem
+ | HunkSideBySideHeaderItem
+ | ExpandSeparatorItem
+ | PaginationRowItem;
+
+export type DiffViewMode = 'unified' | 'side-by-side';
+
+const ITEM_TYPE = {
+ Truncation: 'truncation',
+ FileHeader: 'file-header',
+ FilePatchMissing: 'file-patch-missing',
+ HunkHeader: 'hunk-header',
+ DiffLine: 'diff-line',
+ SideBySideRow: 'side-by-side-row',
+ HunkSideBySide: 'hunk-side-by-side',
+ ExpandSeparator: 'expand-separator',
+ Pagination: 'pagination',
+} as const;
+
+export type ExpandSeparatorState =
+ | { status: 'idle' }
+ | { status: 'loading'; lines: string[]; totalLines?: number }
+ | { status: 'partial'; lines: string[]; totalLines?: number }
+ | { status: 'error'; lines: string[]; totalLines?: number }
+ | { status: 'unavailable' };
+
+export function fileHeaderKey(path: string): string {
+ return `file-header:${path}`;
+}
+
+export function itemTypeFor(item: ListItem): string {
+ switch (item.kind) {
+ case 'truncation-banner': {
+ return ITEM_TYPE.Truncation;
+ }
+ case 'file-header': {
+ return ITEM_TYPE.FileHeader;
+ }
+ case 'file-patch-missing': {
+ return ITEM_TYPE.FilePatchMissing;
+ }
+ case 'hunk-header': {
+ return ITEM_TYPE.HunkHeader;
+ }
+ case 'diff-line': {
+ return ITEM_TYPE.DiffLine;
+ }
+ case 'side-by-side-row': {
+ return ITEM_TYPE.SideBySideRow;
+ }
+ case 'hunk-side-by-side': {
+ return ITEM_TYPE.HunkSideBySide;
+ }
+ case 'expand-separator': {
+ return ITEM_TYPE.ExpandSeparator;
+ }
+ case 'pagination-row': {
+ return ITEM_TYPE.Pagination;
+ }
+ default: {
+ return ITEM_TYPE.FileHeader;
+ }
+ }
+}
+
+export function buildGithubFileUrl(args: {
+ owner: string;
+ repo: string;
+ number: number;
+ path: string;
+}): string {
+ return `https://github.com/${args.owner}/${args.repo}/pull/${args.number}/files#diff-${encodeURIComponent(args.path)}`;
+}
+
+export function addContextLoadState(args: {
+ state: Record>;
+ filePath: string;
+ gapIndex: number;
+ status: 'loading' | 'error' | 'unavailable';
+}): Record> {
+ const previous = args.state[args.filePath];
+ const previousState = previous?.[args.gapIndex];
+ if (args.status === 'unavailable') {
+ return {
+ ...args.state,
+ [args.filePath]: {
+ ...previous,
+ [args.gapIndex]: { status: 'unavailable' },
+ },
+ };
+ }
+ const existingLines =
+ previousState?.status === 'loading' ||
+ previousState?.status === 'partial' ||
+ previousState?.status === 'error'
+ ? previousState.lines
+ : [];
+ const nextStatus: ExpandSeparatorState =
+ args.status === 'loading'
+ ? { status: 'loading', lines: existingLines }
+ : { status: 'error', lines: existingLines };
+ return {
+ ...args.state,
+ [args.filePath]: {
+ ...previous,
+ [args.gapIndex]: nextStatus,
+ },
+ };
+}
+
+export function getCumulativeLines(state: ExpandSeparatorState | undefined): string[] {
+ if (state?.status === 'loading' || state?.status === 'partial' || state?.status === 'error') {
+ return state.lines;
+ }
+ return [];
+}
+
+export function getTotalLines(state: ExpandSeparatorState | undefined): number | undefined {
+ if (state?.status === 'loading' || state?.status === 'partial' || state?.status === 'error') {
+ return state.totalLines;
+ }
+ return undefined;
+}
+
+export function setContextLines(args: {
+ state: Record>;
+ filePath: string;
+ gapIndex: number;
+ lines: string[];
+ totalLines?: number;
+}): Record> {
+ const previous = args.state[args.filePath];
+ const previousState = previous?.[args.gapIndex];
+ const existingLines =
+ previousState?.status === 'loading' ||
+ previousState?.status === 'partial' ||
+ previousState?.status === 'error'
+ ? previousState.lines
+ : [];
+ const nextLines = [...existingLines, ...args.lines];
+ const nextStatus: ExpandSeparatorState = {
+ status: 'partial',
+ lines: nextLines,
+ totalLines: args.totalLines,
+ };
+ return {
+ ...args.state,
+ [args.filePath]: {
+ ...previous,
+ [args.gapIndex]: nextStatus,
+ },
+ };
+}
+
+export function readTrpcErrorCode(error: unknown): string | undefined {
+ if (!error || typeof error !== 'object') {
+ return undefined;
+ }
+ const record = error as Record;
+ const data = record.data;
+ if (data && typeof data === 'object') {
+ const code = (data as Record).code;
+ if (typeof code === 'string') {
+ return code;
+ }
+ }
+ const shape = record.shape;
+ if (shape && typeof shape === 'object') {
+ const shapeData = (shape as Record).data;
+ if (shapeData && typeof shapeData === 'object') {
+ const code = (shapeData as Record).code;
+ if (typeof code === 'string') {
+ return code;
+ }
+ }
+ }
+ const top = record.code;
+ if (typeof top === 'string') {
+ return top;
+ }
+ return undefined;
+}
+
+export type BuildItemsArgs = {
+ files: PrReviewFile[];
+ expanded: Record;
+ expandedContext: Record>;
+ viewed: (path: string) => boolean;
+ headSha: string;
+ owner: string;
+ repo: string;
+ number: number;
+ changedFiles: number;
+ isLoading: boolean;
+ isFetchingNextPage: boolean;
+ hasNextPage: boolean;
+ laterPageError: boolean;
+ fetchToCompletionRunning: boolean;
+ fetchToCompletionLoaded: number;
+ totalFiles: number | null;
+ /** Unified (default) or side-by-side (tablet only). */
+ viewMode?: DiffViewMode;
+};
+
+export { PR_REVIEW_MAX_LISTED_FILES, shouldShowTruncationBanner, truncationBannerCopy };
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts
new file mode 100644
index 0000000000..c018def9f8
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-list-state.ts
@@ -0,0 +1,209 @@
+// File-list query + viewed-files sync + fetch-to-completion control for
+// the PR review Files tab. Centralizes the tRPC `listFiles` infinite
+// query and the viewed-files store so the list component can stay
+// focused on rendering.
+//
+// The list tab has two distinct loading dimensions:
+// 1. Page-level: a single `listFiles` page can fail mid-stream. The
+// caller renders a retry row (handled inside the FlashList) and
+// the query's `refetch` re-fetches just the failed page.
+// 2. Tab-level terminal: a `NOT_FOUND` / `FORBIDDEN` /
+// `PRECONDITION_FAILED` on the first page means the whole tab
+// is dead — there's no point rendering a list skeleton with a
+// retry. The Files tab treats this as a terminal state (no CTA
+// for `permission`, an install CTA for `not-found`).
+//
+// `usePrReviewFileListQuery` is the only hook the list component
+// needs; it returns a `useInfiniteQuery` result plus a `status` field
+// that already classifies the error so the list can short-circuit
+// to the right terminal state.
+
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { useCallback, useEffect, useState } from 'react';
+
+import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state';
+import { PR_REVIEW_MAX_PAGES } from '@/lib/pr-review/diff/pr-review-file-types';
+import { getViewedFiles, toggleViewedFile } from '@/lib/pr-review/viewed-files';
+import { useTRPC } from '@/lib/trpc';
+
+export function usePrReviewFileListQuery(args: {
+ owner: string;
+ repo: string;
+ number: number;
+ enabled: boolean;
+}) {
+ const { owner, repo, number, enabled } = args;
+ const trpc = useTRPC();
+ const query = useInfiniteQuery(
+ trpc.githubPrReview.listFiles.infiniteQueryOptions(
+ { owner, repo, number },
+ {
+ staleTime: 30_000,
+ enabled,
+ getNextPageParam: lastPage => lastPage.nextCursor ?? undefined,
+ // Cap at the server's page ceiling so we never request page 61.
+ // 60 pages × 100/page = 6,000 files, which is well above the
+ // 3,000 truncation banner so fetch-to-completion still has
+ // headroom to actually finish.
+ maxPages: PR_REVIEW_MAX_PAGES,
+ }
+ )
+ );
+
+ const errorState = query.error ? classifyPrReviewQueryState(query.error) : null;
+ // A first-page error is one where NO page has loaded yet. A failure while
+ // fetching a LATER page (already-loaded files present) is a later-page error
+ // and must not blank the screen — consumers keep the loaded files and offer a
+ // resume/retry affordance instead.
+ const hasLoadedPages = (query.data?.pages.length ?? 0) > 0;
+ const firstPageErrorState = hasLoadedPages ? null : errorState;
+ const laterPageError = Boolean(query.error) && hasLoadedPages;
+
+ return {
+ query,
+ errorState,
+ firstPageErrorState,
+ laterPageError,
+ };
+}
+
+export type UsePrReviewFileListQueryResult = ReturnType;
+
+/**
+ * Subscribes the viewed-files store for a specific PR (keyed by
+ * `owner/repo#number` + `headSha`). Returns the current viewed path
+ * set plus a `toggle` callback that flips a single path. The
+ * underlying store is a single SecureStore key shared across all
+ * PRs, so the hook re-reads on toggle rather than maintaining a
+ * long-lived in-memory cache.
+ */
+// Module-level notifier so every mounted viewed-files hook (e.g. the diff list
+// AND the file navigator sheet mounted over it) re-reads after any toggle,
+// keeping their viewed indicators in sync without prop drilling.
+const viewedChangeListeners = new Set<() => void>();
+
+function notifyViewedChange(): void {
+ for (const listener of viewedChangeListeners) {
+ listener();
+ }
+}
+
+export function usePrReviewViewedFiles(
+ ref: {
+ owner: string;
+ repo: string;
+ number: number;
+ },
+ headSha: string
+) {
+ const { owner, repo, number } = ref;
+ const [paths, setPaths] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ setIsLoading(true);
+
+ async function load() {
+ try {
+ const next = await getViewedFiles({ owner, repo, number }, headSha);
+ if (!cancelled) {
+ setPaths(next);
+ setIsLoading(false);
+ }
+ } catch {
+ if (!cancelled) {
+ setPaths([]);
+ setIsLoading(false);
+ }
+ }
+ }
+
+ void load();
+ // Re-read whenever any instance toggles a file so this instance stays in
+ // sync (the navigator sheet and the underlying diff list share the store).
+ const onChange = () => {
+ void load();
+ };
+ viewedChangeListeners.add(onChange);
+ return () => {
+ cancelled = true;
+ viewedChangeListeners.delete(onChange);
+ };
+ }, [owner, repo, number, headSha]);
+
+ const toggle = useCallback(
+ async (path: string) => {
+ // Optimistic toggle: flip the local set first so the UI updates
+ // instantly. The store write is durable (SecureStore).
+ setPaths(previous => {
+ if (previous.includes(path)) {
+ return previous.filter(p => p !== path);
+ }
+ return [...previous, path];
+ });
+ await toggleViewedFile({ owner, repo, number, headSha, path });
+ // Notify other mounted instances (they re-read the durable store).
+ notifyViewedChange();
+ },
+ [owner, repo, number, headSha]
+ );
+
+ const set = new Set(paths);
+ return { isViewed: (path: string) => set.has(path), toggle, isLoading };
+}
+
+/**
+ * Drives an infinite list query to completion. Used by the file
+ * navigator (S6c) which needs the full listed set to offer a working
+ * search/scrubber. Returns an imperative `run()` so the consumer can
+ * start it from a button or an effect and observe progress via
+ * `isRunning` / `loadedFiles` / `error`.
+ */
+export type FetchToCompletionResult = {
+ run: () => Promise;
+ isRunning: boolean;
+ loadedFiles: number;
+ totalFiles: number | null;
+ error: unknown;
+};
+
+export function useFetchToCompletion(
+ query: ReturnType['query'],
+ totalFiles: number | null
+): FetchToCompletionResult {
+ const [isRunning, setIsRunning] = useState(false);
+ const [error, setError] = useState(null);
+
+ const loadedFiles = (query.data?.pages ?? []).reduce((sum, page) => sum + page.files.length, 0);
+
+ const run = useCallback(async () => {
+ if (query.isFetching || !query.hasNextPage) {
+ return;
+ }
+ setError(null);
+ setIsRunning(true);
+ try {
+ // Loop instead of recursing to keep the call stack flat. Pages must be
+ // fetched sequentially because each request needs the previous page's
+ // cursor, so `await` inside the loop is intentional here.
+ // The `hasNextPage` value is re-checked on each iteration via the
+ // result of `fetchNextPage` (not a stale closure), so the loop
+ // condition is intentionally the live query flag.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hasNextPage is re-evaluated after each page
+ while (query.hasNextPage) {
+ // eslint-disable-next-line no-await-in-loop -- sequential cursor pagination
+ const result = await query.fetchNextPage();
+ if (!result.data || !result.hasNextPage) {
+ break;
+ }
+ }
+ } catch (caughtError) {
+ setError(caughtError);
+ } finally {
+ setIsRunning(false);
+ }
+ }, [query]);
+
+ return { run, isRunning, loadedFiles, totalFiles, error };
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts
new file mode 100644
index 0000000000..72623ce0c5
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-review-file-types.ts
@@ -0,0 +1,20 @@
+// Pure file-level types and constants for the PR review diff viewer. Lives
+// in a module with no React / react-query imports so it can be tested in
+// plain Node.
+
+export type PrReviewFile = {
+ path: string;
+ previousPath: string | null;
+ status: string;
+ additions: number;
+ deletions: number;
+ patch: string | null;
+ patchMissing: boolean;
+};
+
+export const PR_REVIEW_MAX_LISTED_FILES = 3000;
+
+// Server caps cursor at 60 (per the S2 read DTO contract). Pages after
+// 60 are silently dropped; this is the same boundary the server uses
+// for "no more pages" detection, so the client never asks for page 61.
+export const PR_REVIEW_MAX_PAGES = 60;
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts
new file mode 100644
index 0000000000..810418dc60
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ PR_REVIEW_TRUNCATION_BANNER_THRESHOLD,
+ shouldShowTruncationBanner,
+ truncationBannerCopy,
+} from './pr-review-truncation';
+
+describe('pr-review-truncation', () => {
+ it('hides the banner when changedFiles is at the threshold', () => {
+ expect(shouldShowTruncationBanner(PR_REVIEW_TRUNCATION_BANNER_THRESHOLD)).toBe(false);
+ });
+
+ it('hides the banner when changedFiles is below the threshold', () => {
+ expect(shouldShowTruncationBanner(0)).toBe(false);
+ expect(shouldShowTruncationBanner(2999)).toBe(false);
+ });
+
+ it('shows the banner when changedFiles is above the threshold', () => {
+ expect(shouldShowTruncationBanner(3001)).toBe(true);
+ expect(shouldShowTruncationBanner(10_000)).toBe(true);
+ });
+
+ it('renders the banner copy with the threshold + the actual file count', () => {
+ expect(truncationBannerCopy(3001)).toBe(
+ 'Showing the first 3,000 of 3,001 changed files — GitHub API limit'
+ );
+ expect(truncationBannerCopy(12_345)).toBe(
+ 'Showing the first 3,000 of 12,345 changed files — GitHub API limit'
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts
new file mode 100644
index 0000000000..52de6dd6da
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/pr-review-truncation.ts
@@ -0,0 +1,18 @@
+// Pure selectors for the Files tab. Kept here so the file-list
+// component and the E2E verifier can share the same logic without
+// importing the React component tree.
+//
+// These are intentionally simple — a 3,000-file PR is the boundary
+// at which GitHub truncates its listFiles response, and we mirror
+// that exactly so the user never sees a "Showing 2,500 of 3,000"
+// banner when GitHub would have returned 2,500 anyway.
+
+export const PR_REVIEW_TRUNCATION_BANNER_THRESHOLD = 3000;
+
+export function shouldShowTruncationBanner(changedFiles: number): boolean {
+ return changedFiles > PR_REVIEW_TRUNCATION_BANNER_THRESHOLD;
+}
+
+export function truncationBannerCopy(changedFiles: number): string {
+ return `Showing the first ${PR_REVIEW_TRUNCATION_BANNER_THRESHOLD.toLocaleString()} of ${changedFiles.toLocaleString()} changed files — GitHub API limit`;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts b/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts
new file mode 100644
index 0000000000..89f6aeade3
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/side-by-side.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest';
+
+import { type ParsedHunk, parsePatch } from '@/lib/pr-review/diff/parse-patch';
+import { buildSideBySideRows, type SideBySideRow } from '@/lib/pr-review/diff/side-by-side';
+
+function firstHunk(patch: string): ParsedHunk {
+ const result = parsePatch(patch);
+ const hunk = result.hunks[0];
+ if (!hunk) {
+ throw new Error('expected at least one hunk');
+ }
+ return hunk;
+}
+
+function typesOf(rows: SideBySideRow[]): string[] {
+ return rows.map(row => {
+ if (row.left && row.right) {
+ if (row.left.line === row.right.line) {
+ return 'context';
+ }
+ return 'replace';
+ }
+ if (row.left) {
+ return 'del';
+ }
+ if (row.right) {
+ return 'add';
+ }
+ return 'empty';
+ });
+}
+
+describe('buildSideBySideRows', () => {
+ it('pairs a one-line replacement: -a / +b', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,1 @@', '-old', '+new'].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['replace']);
+ expect(rows[0]?.left?.line.text).toBe('old');
+ expect(rows[0]?.left?.line.type).toBe('del');
+ expect(rows[0]?.right?.line.text).toBe('new');
+ expect(rows[0]?.right?.line.type).toBe('add');
+ });
+
+ it('pairs a multi-line replacement: -a -b / +c +d', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,2 +1,2 @@', '-a', '-b', '+c', '+d'].join(
+ '\n'
+ );
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['replace', 'replace']);
+ expect(rows.map(r => r.left?.line.text)).toEqual(['a', 'b']);
+ expect(rows.map(r => r.right?.line.text)).toEqual(['c', 'd']);
+ });
+
+ it('pairs an uneven replacement: -a -b -c / +x, leaving b and c as left-only', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,3 +1,1 @@', '-a', '-b', '-c', '+x'].join(
+ '\n'
+ );
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['replace', 'del', 'del']);
+ expect(rows[0]?.left?.line.text).toBe('a');
+ expect(rows[0]?.right?.line.text).toBe('x');
+ expect(rows[1]?.left?.line.text).toBe('b');
+ expect(rows[1]?.right).toBeNull();
+ expect(rows[2]?.left?.line.text).toBe('c');
+ expect(rows[2]?.right).toBeNull();
+ });
+
+ it('pairs an uneven replacement: -a / +x +y +z, leaving y and z as right-only', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,3 @@', '-a', '+x', '+y', '+z'].join(
+ '\n'
+ );
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['replace', 'add', 'add']);
+ expect(rows[0]?.left?.line.text).toBe('a');
+ expect(rows[0]?.right?.line.text).toBe('x');
+ expect(rows[1]?.left).toBeNull();
+ expect(rows[1]?.right?.line.text).toBe('y');
+ expect(rows[2]?.left).toBeNull();
+ expect(rows[2]?.right?.line.text).toBe('z');
+ });
+
+ it('renders a pure-add line as right-only (left null)', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -0,0 +1,1 @@', '+new'].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['add']);
+ expect(rows[0]?.left).toBeNull();
+ expect(rows[0]?.right?.line.text).toBe('new');
+ });
+
+ it('renders a pure-del line as left-only (right null)', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +0,0 @@', '-gone'].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['del']);
+ expect(rows[0]?.left?.line.text).toBe('gone');
+ expect(rows[0]?.right).toBeNull();
+ });
+
+ it('renders context on both sides and does not pair it with del/add', () => {
+ const patch = [
+ 'diff --git a/foo.ts b/foo.ts',
+ '@@ -1,3 +1,3 @@',
+ ' ctx',
+ '-a',
+ '+b',
+ ' end',
+ ].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['context', 'replace', 'context']);
+ expect(rows[0]?.left?.line.text).toBe('ctx');
+ expect(rows[0]?.right?.line.text).toBe('ctx');
+ expect(rows[2]?.left?.line.text).toBe('end');
+ expect(rows[2]?.right?.line.text).toBe('end');
+ });
+
+ it('keeps context as a single row shared by both columns (same reference)', () => {
+ const patch = ['diff --git a/foo.ts b/foo.ts', '@@ -1,1 +1,1 @@', ' same'].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['context']);
+ expect(rows[0]?.left?.line).toBe(rows[0]?.right?.line);
+ });
+
+ it('keeps leftover lines after a del/add run as separate rows (no fusion with later context)', () => {
+ const patch = [
+ 'diff --git a/foo.ts b/foo.ts',
+ '@@ -1,3 +1,3 @@',
+ '-a',
+ '+b',
+ '-c',
+ ' end',
+ ].join('\n');
+ const rows = buildSideBySideRows(firstHunk(patch));
+ expect(typesOf(rows)).toEqual(['replace', 'del', 'context']);
+ expect(rows[1]?.left?.line.text).toBe('c');
+ expect(rows[1]?.right).toBeNull();
+ expect(rows[2]?.left?.line.text).toBe('end');
+ expect(rows[2]?.right?.line.text).toBe('end');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/side-by-side.ts b/apps/mobile/src/lib/pr-review/diff/side-by-side.ts
new file mode 100644
index 0000000000..9ba229fa57
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/side-by-side.ts
@@ -0,0 +1,104 @@
+// Side-by-side rendering helper for the tablet PR diff viewer.
+//
+// A side-by-side view splits each parsed hunk into two columns:
+// - LEFT: old (context) + deleted lines, with their old line numbers
+// - RIGHT: new (context) + added lines, with their new line numbers
+//
+// A "replacement" hunk is a contiguous run of `-…` lines followed by
+// `+…` lines (in any combination of counts). Each pair of one del + one
+// add becomes a single row with both columns populated. Leftover dels
+// become left-only rows; leftover adds become right-only rows. Context
+// lines (which appear with both old and new line numbers) become rows
+// with both columns set to the same text. The result is a fixed grid
+// of equal-height rows the FlashList can virtualize without measuring.
+//
+// This module is pure data — no React, no React Native — so it is
+// testable in plain Node and reusable by any future non-mobile surface
+// (web, CLI). The visual rendering of these rows lives in
+// `pr-diff-side-by-side-row.tsx` so this file stays under the max-lines
+// limit.
+
+import { type ParsedDiffLine, type ParsedHunk } from '@/lib/pr-review/diff/parse-patch';
+
+export type SideBySideCell = {
+ line: ParsedDiffLine;
+};
+
+export type SideBySideRow = {
+ left: SideBySideCell | null;
+ right: SideBySideCell | null;
+};
+
+function pushReplacementPair(
+ rows: SideBySideRow[],
+ del: ParsedDiffLine,
+ add: ParsedDiffLine
+): void {
+ rows.push({ left: { line: del }, right: { line: add } });
+}
+
+function pushLeftOnly(rows: SideBySideRow[], del: ParsedDiffLine): void {
+ rows.push({ left: { line: del }, right: null });
+}
+
+function pushRightOnly(rows: SideBySideRow[], add: ParsedDiffLine): void {
+ rows.push({ left: null, right: { line: add } });
+}
+
+function pushContextPair(rows: SideBySideRow[], context: ParsedDiffLine): void {
+ rows.push({ left: { line: context }, right: { line: context } });
+}
+
+function flushRun(rows: SideBySideRow[], dels: ParsedDiffLine[], adds: ParsedDiffLine[]): void {
+ const pairs = Math.min(dels.length, adds.length);
+ for (let i = 0; i < pairs; i += 1) {
+ const del = dels[i];
+ const add = adds[i];
+ if (!del || !add) {
+ break;
+ }
+ pushReplacementPair(rows, del, add);
+ }
+ for (let i = pairs; i < dels.length; i += 1) {
+ const del = dels[i];
+ if (!del) {
+ break;
+ }
+ pushLeftOnly(rows, del);
+ }
+ for (let i = pairs; i < adds.length; i += 1) {
+ const add = adds[i];
+ if (!add) {
+ break;
+ }
+ pushRightOnly(rows, add);
+ }
+}
+
+export function buildSideBySideRows(hunk: ParsedHunk): SideBySideRow[] {
+ const rows: SideBySideRow[] = [];
+ let dels: ParsedDiffLine[] = [];
+ let adds: ParsedDiffLine[] = [];
+
+ const flush = () => {
+ if (dels.length > 0 || adds.length > 0) {
+ flushRun(rows, dels, adds);
+ dels = [];
+ adds = [];
+ }
+ };
+
+ for (const line of hunk.lines) {
+ if (line.type === 'context') {
+ flush();
+ pushContextPair(rows, line);
+ } else if (line.type === 'del') {
+ dels.push(line);
+ } else {
+ adds.push(line);
+ }
+ }
+ flush();
+
+ return rows;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts b/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts
new file mode 100644
index 0000000000..8215015fdf
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/syntax-colors.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { DEFAULT_TOKEN_COLOR, tokenColorFor } from './syntax-colors';
+
+describe('tokenColorFor', () => {
+ it('returns the light color for a known class in light mode', () => {
+ expect(tokenColorFor('keyword', false)).toBe('#7B2CBF');
+ });
+
+ it('returns the dark color for a known class in dark mode', () => {
+ expect(tokenColorFor('keyword', true)).toBe('#D8B4FE');
+ });
+
+ it('falls back to the default color for an unknown class', () => {
+ expect(tokenColorFor('unknown-token', false)).toBe(DEFAULT_TOKEN_COLOR.light);
+ expect(tokenColorFor('unknown-token', true)).toBe(DEFAULT_TOKEN_COLOR.dark);
+ });
+
+ it('falls back to the default color when className is null', () => {
+ expect(tokenColorFor(null, false)).toBe(DEFAULT_TOKEN_COLOR.light);
+ expect(tokenColorFor(null, true)).toBe(DEFAULT_TOKEN_COLOR.dark);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts
new file mode 100644
index 0000000000..6a4cab5029
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/syntax-colors.ts
@@ -0,0 +1,40 @@
+// Shared runtime palette for diff syntax highlighting. These colors are
+// applied as inline `style={{ color }}` values because the token class
+// (e.g. 'keyword', 'string') is only known at runtime from the highlighter;
+// NativeWind cannot map arbitrary token classes to theme variables at
+// build time. Centralizing the palette keeps the two diff renderers
+// (unified `DiffLine` and tablet `SideBySideRow`) consistent.
+
+const TOKEN_DARK_LIGHT: Record = {
+ keyword: { light: '#7B2CBF', dark: '#D8B4FE' },
+ builtin: { light: '#1F6FEB', dark: '#79B8FF' },
+ literal: { light: '#7B2CBF', dark: '#D8B4FE' },
+ number: { light: '#B27214', dark: '#F2B05F' },
+ string: { light: '#278150', dark: '#5FCB8E' },
+ comment: { light: '#6F6A61', dark: '#8A8680' },
+ type: { light: '#1F6FEB', dark: '#79B8FF' },
+ function: { light: '#1F6FEB', dark: '#79B8FF' },
+ variable: { light: '#14130F', dark: '#F2F0EB' },
+ property: { light: '#1F6FEB', dark: '#79B8FF' },
+ tag: { light: '#BE4E3F', dark: '#F28B7A' },
+ selector: { light: '#7B2CBF', dark: '#D8B4FE' },
+ attribute: { light: '#1F6FEB', dark: '#79B8FF' },
+ operator: { light: '#6F6A61', dark: '#8A8680' },
+ meta: { light: '#6F6A61', dark: '#8A8680' },
+ add: { light: '#278150', dark: '#5FCB8E' },
+ del: { light: '#BE4E3F', dark: '#F28B7A' },
+};
+
+export const DEFAULT_TOKEN_COLOR = { light: '#14130F', dark: '#F2F0EB' };
+export const MUTED_COLOR = { light: '#6F6A61', dark: '#8A8680' };
+
+export function tokenColorFor(className: string | null, isDark: boolean): string {
+ if (!className) {
+ return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light;
+ }
+ const palette = TOKEN_DARK_LIGHT[className];
+ if (!palette) {
+ return isDark ? DEFAULT_TOKEN_COLOR.dark : DEFAULT_TOKEN_COLOR.light;
+ }
+ return isDark ? palette.dark : palette.light;
+}
diff --git a/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts
new file mode 100644
index 0000000000..c7eb58a857
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/diff/use-pr-diff-context-loader.ts
@@ -0,0 +1,107 @@
+// Context-expansion loader for the PR diff viewer. Encapsulates the
+// expandedContext state and the progressive window fetch so the list
+// component stays under the max-lines limit.
+
+import { useCallback, useRef, useState } from 'react';
+
+import {
+ addContextLoadState,
+ type ExpandSeparatorState,
+ type ListItem,
+ readTrpcErrorCode,
+ setContextLines,
+} from '@/lib/pr-review/diff/pr-diff-list-items';
+import { trpcClient } from '@/lib/trpc';
+
+type UsePrDiffContextLoaderResult = {
+ expandedContext: Record>;
+ setExpandedContext: React.Dispatch<
+ React.SetStateAction>>
+ >;
+ handleLoadContext: (
+ item: Extract,
+ windowSize: number
+ ) => void;
+};
+
+export function usePrDiffContextLoader(args: {
+ owner: string;
+ repo: string;
+ headSha: string;
+}): UsePrDiffContextLoaderResult {
+ const { owner, repo, headSha } = args;
+ const [expandedContext, setExpandedContext] = useState<
+ Record>
+ >({});
+ const expandedContextRef = useRef(expandedContext);
+ expandedContextRef.current = expandedContext;
+
+ const handleLoadContext = useCallback(
+ (item: Extract, windowSize: number) => {
+ const existingState = expandedContextRef.current[item.filePath]?.[item.context.gapIndex];
+ const alreadyLoaded =
+ existingState?.status === 'loading' ||
+ existingState?.status === 'partial' ||
+ existingState?.status === 'error'
+ ? existingState.lines.length
+ : 0;
+ const startLine = item.context.startLine + alreadyLoaded;
+ const endLine = Math.min(item.context.endLine, startLine + windowSize - 1);
+
+ setExpandedContext(prev =>
+ addContextLoadState({
+ state: prev,
+ filePath: item.filePath,
+ gapIndex: item.context.gapIndex,
+ status: 'loading',
+ })
+ );
+ void (async () => {
+ try {
+ const result = await trpcClient.githubPrReview.getFileLines.query({
+ owner: item.ref.owner || owner,
+ repo: item.ref.repo || repo,
+ ref: item.ref.ref || headSha,
+ path: item.filePath,
+ startLine,
+ endLine,
+ });
+ if (result.lines.length === 0) {
+ setExpandedContext(prev =>
+ addContextLoadState({
+ state: prev,
+ filePath: item.filePath,
+ gapIndex: item.context.gapIndex,
+ status: 'unavailable',
+ })
+ );
+ return;
+ }
+ setExpandedContext(prev =>
+ setContextLines({
+ state: prev,
+ filePath: item.filePath,
+ gapIndex: item.context.gapIndex,
+ lines: result.lines,
+ totalLines: result.totalLines,
+ })
+ );
+ } catch (error: unknown) {
+ const code = readTrpcErrorCode(error);
+ const status = code === 'NOT_FOUND' ? 'unavailable' : 'error';
+ setExpandedContext(prev =>
+ addContextLoadState({
+ state: prev,
+ filePath: item.filePath,
+ gapIndex: item.context.gapIndex,
+ status,
+ })
+ );
+ }
+ })();
+ },
+ [owner, repo, headSha]
+ );
+
+ return { expandedContext, setExpandedContext, handleLoadContext };
+}
diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts
new file mode 100644
index 0000000000..820312bc72
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-reducers.test.ts
@@ -0,0 +1,184 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ applyReactionToggle,
+ applyResolveToggle,
+ findReviewComment,
+ type ReviewThread,
+ type ReviewThreadsInfiniteData,
+} from './review-discussion-types';
+
+function makeThread(overrides: Partial = {}): ReviewThread {
+ return {
+ threadId: 'T1',
+ isResolved: false,
+ isOutdated: false,
+ subjectType: 'LINE',
+ path: 'src/index.ts',
+ line: 10,
+ startLine: null,
+ originalLine: null,
+ originalStartLine: null,
+ diffSide: 'RIGHT',
+ comments: [
+ {
+ commentId: 1,
+ nodeId: 'C1',
+ author: { login: 'alice', avatarUrl: 'https://example.com/a.png' },
+ bodyMarkdown: 'hello',
+ createdAt: '2024-01-01T00:00:00Z',
+ reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }],
+ },
+ ],
+ ...overrides,
+ };
+}
+
+function makeData(threads: ReviewThread[]): ReviewThreadsInfiniteData {
+ return {
+ pages: [{ threads, nextCursor: null }],
+ pageParams: [null],
+ };
+}
+
+describe('applyResolveToggle', () => {
+ it('flips the matching threadId to the next value', () => {
+ const data = makeData([makeThread({ threadId: 'A' }), makeThread({ threadId: 'B' })]);
+ const next = applyResolveToggle(data, 'A', true);
+ expect(next?.pages[0]?.threads.find(t => t.threadId === 'A')?.isResolved).toBe(true);
+ expect(next?.pages[0]?.threads.find(t => t.threadId === 'B')?.isResolved).toBe(false);
+ });
+
+ it('returns the same reference when no thread matched', () => {
+ const data = makeData([makeThread({ threadId: 'A' })]);
+ const next = applyResolveToggle(data, 'ZZZ', true);
+ expect(next).toBe(data);
+ });
+
+ it('returns the same reference when the threadId matched but the value is already next', () => {
+ const data = makeData([makeThread({ threadId: 'A', isResolved: true })]);
+ const next = applyResolveToggle(data, 'A', true);
+ expect(next).toBe(data);
+ });
+
+ it('returns undefined for undefined input', () => {
+ expect(applyResolveToggle(undefined, 'A', true)).toBeUndefined();
+ });
+
+ it('walks all pages, not just the first', () => {
+ const data: ReviewThreadsInfiniteData = {
+ pages: [
+ { threads: [makeThread({ threadId: 'A' })], nextCursor: 'p2' },
+ { threads: [makeThread({ threadId: 'B' })], nextCursor: null },
+ ],
+ pageParams: [null, 'p2'],
+ };
+ const next = applyResolveToggle(data, 'B', true);
+ expect(next?.pages[1]?.threads[0]?.isResolved).toBe(true);
+ expect(next?.pages[0]?.threads[0]?.isResolved).toBe(false);
+ });
+});
+
+describe('applyReactionToggle', () => {
+ it('adds a reaction when the viewer is not yet reacted', () => {
+ const data = makeData([
+ makeThread({
+ comments: [
+ {
+ commentId: 1,
+ nodeId: 'C1',
+ author: { login: 'alice', avatarUrl: null },
+ bodyMarkdown: 'hi',
+ createdAt: '2024-01-01T00:00:00Z',
+ reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }],
+ },
+ ],
+ }),
+ ]);
+ const next = applyReactionToggle({
+ data,
+ threadId: 'T1',
+ commentNodeId: 'C1',
+ content: 'HEART',
+ });
+ const comment = findReviewComment(next, 'T1', 'C1');
+ expect(comment?.reactions).toEqual([
+ { content: 'THUMBS_UP', count: 2, viewerHasReacted: false },
+ { content: 'HEART', count: 1, viewerHasReacted: true },
+ ]);
+ });
+
+ it('removes a reaction when the viewer is already reacted', () => {
+ const data = makeData([
+ makeThread({
+ comments: [
+ {
+ commentId: 1,
+ nodeId: 'C1',
+ author: { login: 'alice', avatarUrl: null },
+ bodyMarkdown: 'hi',
+ createdAt: '2024-01-01T00:00:00Z',
+ reactions: [{ content: 'THUMBS_UP', count: 3, viewerHasReacted: true }],
+ },
+ ],
+ }),
+ ]);
+ const next = applyReactionToggle({
+ data,
+ threadId: 'T1',
+ commentNodeId: 'C1',
+ content: 'THUMBS_UP',
+ });
+ const comment = findReviewComment(next, 'T1', 'C1');
+ expect(comment?.reactions).toEqual([
+ { content: 'THUMBS_UP', count: 2, viewerHasReacted: false },
+ ]);
+ });
+
+ it('clamps the count at 0 on a remove-from-zero race', () => {
+ const data = makeData([
+ makeThread({
+ comments: [
+ {
+ commentId: 1,
+ nodeId: 'C1',
+ author: { login: 'alice', avatarUrl: null },
+ bodyMarkdown: 'hi',
+ createdAt: '2024-01-01T00:00:00Z',
+ reactions: [{ content: 'THUMBS_UP', count: 0, viewerHasReacted: true }],
+ },
+ ],
+ }),
+ ]);
+ const next = applyReactionToggle({
+ data,
+ threadId: 'T1',
+ commentNodeId: 'C1',
+ content: 'THUMBS_UP',
+ });
+ const comment = findReviewComment(next, 'T1', 'C1');
+ expect(comment?.reactions[0]?.count).toBe(0);
+ });
+
+ it('returns the same reference when no matching comment exists', () => {
+ const data = makeData([makeThread()]);
+ const next = applyReactionToggle({
+ data,
+ threadId: 'T1',
+ commentNodeId: 'NOT-THERE',
+ content: 'HEART',
+ });
+ expect(next).toBe(data);
+ });
+
+ it('returns the same reference when no matching thread exists', () => {
+ const data = makeData([makeThread({ threadId: 'A' })]);
+ const next = applyReactionToggle({
+ data,
+ threadId: 'ZZZ',
+ commentNodeId: 'C1',
+ content: 'HEART',
+ });
+ expect(next).toBe(data);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts
new file mode 100644
index 0000000000..fd4cbf390d
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ groupThreadsByPath,
+ type ReviewThread,
+ selectCommentAuthorName,
+ selectThreadAnchorLabel,
+ selectThreadBadges,
+} from './review-discussion-types';
+
+function makeThread(overrides: Partial = {}): ReviewThread {
+ return {
+ threadId: 'T1',
+ isResolved: false,
+ isOutdated: false,
+ subjectType: 'LINE',
+ path: 'src/index.ts',
+ line: 10,
+ startLine: null,
+ originalLine: null,
+ originalStartLine: null,
+ diffSide: 'RIGHT',
+ comments: [
+ {
+ commentId: 1,
+ nodeId: 'C1',
+ author: { login: 'alice', avatarUrl: 'https://example.com/a.png' },
+ bodyMarkdown: 'hello',
+ createdAt: '2024-01-01T00:00:00Z',
+ reactions: [{ content: 'THUMBS_UP', count: 2, viewerHasReacted: false }],
+ },
+ ],
+ ...overrides,
+ };
+}
+
+describe('selectThreadAnchorLabel', () => {
+ it('renders an active line anchor with side', () => {
+ expect(selectThreadAnchorLabel(makeThread({ line: 10, diffSide: 'RIGHT' }))).toBe(
+ 'src/index.ts L10 (RIGHT)'
+ );
+ });
+
+ it('renders an active range anchor with start/end', () => {
+ expect(selectThreadAnchorLabel(makeThread({ line: 12, startLine: 10, diffSide: 'LEFT' }))).toBe(
+ 'src/index.ts L10–L12 (LEFT)'
+ );
+ });
+
+ it('renders a file-level thread label when subjectType is FILE', () => {
+ expect(selectThreadAnchorLabel(makeThread({ subjectType: 'FILE' }))).toBe(
+ 'File comment on src/index.ts'
+ );
+ });
+
+ it('renders a file-level thread label when line is null', () => {
+ expect(selectThreadAnchorLabel(makeThread({ line: null }))).toBe(
+ 'File comment on src/index.ts'
+ );
+ });
+
+ it('renders an outdated thread label using originalLine', () => {
+ expect(
+ selectThreadAnchorLabel(
+ makeThread({
+ isOutdated: true,
+ line: null,
+ startLine: null,
+ originalLine: 8,
+ originalStartLine: 5,
+ diffSide: 'LEFT',
+ })
+ )
+ ).toBe('Outdated on src/index.ts L5–L8 (LEFT)');
+ });
+
+ it('renders an outdated label with a single line', () => {
+ expect(
+ selectThreadAnchorLabel(
+ makeThread({
+ isOutdated: true,
+ line: null,
+ startLine: null,
+ originalLine: 8,
+ originalStartLine: null,
+ diffSide: null,
+ })
+ )
+ ).toBe('Outdated on src/index.ts L8');
+ });
+
+ it('renders a path-only outdated label when originalLine is null (no dangling L)', () => {
+ expect(
+ selectThreadAnchorLabel(
+ makeThread({
+ isOutdated: true,
+ line: null,
+ startLine: null,
+ originalLine: null,
+ originalStartLine: null,
+ diffSide: 'RIGHT',
+ })
+ )
+ ).toBe('Outdated on src/index.ts (RIGHT)');
+ });
+
+ it('renders a bare Outdated label when both path and originalLine are null', () => {
+ expect(
+ selectThreadAnchorLabel(
+ makeThread({
+ isOutdated: true,
+ path: null,
+ line: null,
+ startLine: null,
+ originalLine: null,
+ originalStartLine: null,
+ diffSide: null,
+ })
+ )
+ ).toBe('Outdated');
+ });
+
+ it('ignores a null originalStartLine and shows only the original line', () => {
+ expect(
+ selectThreadAnchorLabel(
+ makeThread({
+ isOutdated: true,
+ line: null,
+ originalLine: 8,
+ originalStartLine: null,
+ })
+ )
+ ).toBe('Outdated on src/index.ts L8 (RIGHT)');
+ });
+
+ it('omits side when diffSide is null', () => {
+ expect(selectThreadAnchorLabel(makeThread({ diffSide: null, line: 10 }))).toBe(
+ 'src/index.ts L10'
+ );
+ });
+});
+
+describe('selectThreadBadges', () => {
+ it('flags resolved + outdated + file-level independently', () => {
+ expect(selectThreadBadges(makeThread({ isResolved: true }))).toEqual({
+ resolved: true,
+ outdated: false,
+ fileLevel: false,
+ });
+ expect(selectThreadBadges(makeThread({ isOutdated: true }))).toEqual({
+ resolved: false,
+ outdated: true,
+ fileLevel: false,
+ });
+ expect(selectThreadBadges(makeThread({ subjectType: 'FILE' }))).toEqual({
+ resolved: false,
+ outdated: false,
+ fileLevel: true,
+ });
+ expect(selectThreadBadges(makeThread({ line: null }))).toEqual({
+ resolved: false,
+ outdated: false,
+ fileLevel: true,
+ });
+ });
+});
+
+describe('selectCommentAuthorName', () => {
+ it('returns the login when present', () => {
+ expect(selectCommentAuthorName({ login: 'alice', avatarUrl: null })).toBe('alice');
+ });
+
+ it('returns the "deleted user" fallback when author is null', () => {
+ expect(selectCommentAuthorName(null)).toBe('deleted user');
+ });
+});
+
+describe('groupThreadsByPath', () => {
+ it('groups threads by path, preserving insertion order', () => {
+ const a = makeThread({ threadId: 'A', path: 'src/a.ts' });
+ const b = makeThread({ threadId: 'B', path: 'src/b.ts' });
+ const a2 = makeThread({ threadId: 'A2', path: 'src/a.ts' });
+ const groups = groupThreadsByPath([a, b, a2]);
+ expect(groups.map(g => g.path)).toEqual(['src/a.ts', 'src/b.ts']);
+ expect(groups[0]?.threads.map(t => t.threadId)).toEqual(['A', 'A2']);
+ expect(groups[1]?.threads.map(t => t.threadId)).toEqual(['B']);
+ });
+
+ it('buckets null-path threads under "(no file)"', () => {
+ const orphan = makeThread({ threadId: 'X', path: null });
+ const a = makeThread({ threadId: 'A', path: 'src/a.ts' });
+ const groups = groupThreadsByPath([a, orphan]);
+ expect(groups.map(g => g.path)).toEqual(['src/a.ts', '(no file)']);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts
new file mode 100644
index 0000000000..b11cabba48
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/review-discussion-types.ts
@@ -0,0 +1,285 @@
+// Pure types, helpers, and reducers for the PR review Discussion tab.
+//
+// The discussion tab is built from three pure contracts:
+// 1. The trpc DTO shapes (thread / comment / reaction) — these
+// mirror the committed S2 read DTO and S3 mutation DTOs.
+// We re-declare the narrowest types here (rather than depending
+// on the trpc root type) so the helpers stay testable in plain
+// Node and so the type contracts are explicit in this slice.
+// 2. `selectThreadAnchorLabel` — turns a thread's nullable anchor
+// (file-level vs line-anchored vs outdated) into the label shown
+// in the thread header. Pure, no React.
+// 3. `applyResolveToggle` and `applyReactionToggle` — pure cache
+// reducers that flip a single thread / comment in the
+// `InfiniteData<{threads, nextCursor}>` cache used by the
+// `listReviewThreads` infinite query. Used by the optimistic
+// `onMutate` of the resolve / unresolve / reaction mutations and
+// by the rollback in `onError`.
+//
+// Keeping these pure (no React, no trpc, no expo) means they can be
+// unit-tested in plain Node and reused by the diff viewer if it later
+// wants to compute its own indicators from the same cache.
+
+import { type inferRouterOutputs, type RootRouter } from '@kilocode/trpc';
+
+// GitHub's 8 review-comment reaction content values. The trpc DTO
+// exposes this as a plain `string`; we narrow to the union here so
+// the reducer and the pill row get exhaustiveness checking.
+export type ReviewReactionContent =
+ | 'THUMBS_UP'
+ | 'THUMBS_DOWN'
+ | 'LAUGH'
+ | 'HOORAY'
+ | 'CONFUSED'
+ | 'HEART'
+ | 'ROCKET'
+ | 'EYES';
+
+export const REVIEW_REACTION_CONTENTS: readonly ReviewReactionContent[] = [
+ 'THUMBS_UP',
+ 'THUMBS_DOWN',
+ 'LAUGH',
+ 'HOORAY',
+ 'CONFUSED',
+ 'HEART',
+ 'ROCKET',
+ 'EYES',
+] as const;
+
+// Derive the wire (DTO) shapes from the tRPC `listReviewThreads` output so a
+// backend contract change (fields, nullability) is type-checked here rather
+// than silently drifting from a hand-copied shape (apps/mobile/AGENTS.md).
+// `reactions[].content` is typed as a plain `string` by tRPC; we narrow it to
+type RouterOutputs = inferRouterOutputs;
+export type ReviewThreadsPage = RouterOutputs['githubPrReview']['listReviewThreads'];
+export type ReviewThread = ReviewThreadsPage['threads'][number];
+export type ReviewComment = ReviewThread['comments'][number];
+
+// The shape of a single page in the cached `InfiniteData`
+// produced by `useInfiniteQuery(trpc.githubPrReview.listReviewThreads…)`.
+export type ReviewThreadsInfiniteData = {
+ readonly pages: readonly ReviewThreadsPage[];
+ readonly pageParams: readonly unknown[];
+};
+
+/**
+ * Display label for a thread's anchor. The label reflects THREE
+ * different nullable shapes:
+ * - file-level (`subjectType==='FILE'` or `line===null`):
+ * "File comment on "
+ * - outdated (`isOutdated` true, with `originalLine`):
+ * "Outdated on L ()"
+ * - active line:
+ * " L ()" (or just L if no path)
+ * - active range:
+ * " L–L ()"
+ */
+export function selectThreadAnchorLabel(thread: ReviewThread): string {
+ const path = thread.path ?? '';
+ const side = thread.diffSide ? ` (${thread.diffSide})` : '';
+ // Outdated threads anchor via `original*` (their current-line `line`
+ // field is null because the diff has moved past the comment).
+ if (thread.isOutdated) {
+ const { originalLine } = thread;
+ // No complete original anchor — fall back to a path-only outdated label
+ // (never render a dangling "L").
+ if (originalLine === null) {
+ return path ? `Outdated on ${path}${side}` : `Outdated${side}`;
+ }
+ const original =
+ thread.originalStartLine && thread.originalStartLine !== originalLine
+ ? `L${thread.originalStartLine}–L${originalLine}`
+ : `L${originalLine}`;
+ return path ? `Outdated on ${path} ${original}${side}` : `Outdated on ${original}${side}`;
+ }
+ // File-level (subjectType FILE or no line) — only show path.
+ if (thread.subjectType === 'FILE' || thread.line === null) {
+ return path ? `File comment on ${path}` : 'File comment';
+ }
+ // Active line / range.
+ const line =
+ thread.startLine && thread.startLine !== thread.line
+ ? `L${thread.startLine}–L${thread.line}`
+ : `L${thread.line}`;
+ return path ? `${path} ${line}${side}` : `${line}${side}`;
+}
+
+/**
+ * Returns the same `isResolved` value with the `isOutdated` label
+ * surfaced for the badges in the thread header. Purely presentational.
+ */
+export function selectThreadBadges(thread: ReviewThread): {
+ readonly resolved: boolean;
+ readonly outdated: boolean;
+ readonly fileLevel: boolean;
+} {
+ return {
+ resolved: thread.isResolved,
+ outdated: thread.isOutdated,
+ fileLevel: thread.subjectType === 'FILE' || thread.line === null,
+ };
+}
+
+/**
+ * Display name for a comment author. Returns "deleted user" when the
+ * author is null (deleted / banned GitHub accounts surface as
+ * `author: null` in the GraphQL DTO).
+ */
+export function selectCommentAuthorName(author: ReviewComment['author']): string {
+ return author?.login ?? 'deleted user';
+}
+
+/**
+ * Group threads by `path` for the sectioned list. Unanchored / null-path
+ * threads (rare but possible) are bucketed under "(no file)". The
+ * grouping is deterministic (Map insertion order = API order) so
+ * snapshots are stable across renders.
+ */
+type ReviewThreadGroup = {
+ readonly path: string;
+ readonly threads: readonly ReviewThread[];
+};
+
+export function groupThreadsByPath(threads: readonly ReviewThread[]): readonly ReviewThreadGroup[] {
+ const byPath = new Map();
+ for (const thread of threads) {
+ const path = thread.path ?? '(no file)';
+ const bucket = byPath.get(path);
+ if (bucket) {
+ bucket.push(thread);
+ } else {
+ byPath.set(path, [thread]);
+ }
+ }
+ return Array.from(byPath, ([path, group]) => ({ path, threads: group }));
+}
+
+/**
+ * Toggle a single thread's `isResolved` flag in the cached
+ * `InfiniteData`. The reducer walks every page
+ * and flips the matching threadId. Returns a new object so the
+ * cache update is detected by react-query. Returns the same
+ * reference when no thread matched or when the value is already
+ * at the target state.
+ */
+export function applyResolveToggle(
+ data: ReviewThreadsInfiniteData | undefined,
+ threadId: string,
+ nextIsResolved: boolean
+): ReviewThreadsInfiniteData | undefined {
+ if (!data) {
+ return data;
+ }
+ const nextPages = data.pages.map(page => {
+ const nextThreads = page.threads.map(thread =>
+ thread.threadId === threadId && thread.isResolved !== nextIsResolved
+ ? { ...thread, isResolved: nextIsResolved }
+ : thread
+ );
+ const pageChanged = nextThreads.some((thread, index) => thread !== page.threads[index]);
+ return pageChanged ? { ...page, threads: nextThreads } : page;
+ });
+ const changed = nextPages.some((page, index) => page !== data.pages[index]);
+ return changed ? { ...data, pages: nextPages } : data;
+}
+
+/**
+ * Toggle a single reaction in a single comment. When the viewer is
+ * already reacted, the reaction is removed (count -1, viewerHasReacted
+ * false). When the viewer is not yet reacted, it is added (count +1,
+ * viewerHasReacted true). If the reaction's content is not in the
+ * known set (unknown future GitHub value), it is treated as an
+ * upsert by the existing-bucket path. If the bucket is not present
+ * yet, it is appended. Returns a new InfiniteData so react-query
+ * sees the change.
+ */
+export function applyReactionToggle(args: {
+ data: ReviewThreadsInfiniteData | undefined;
+ threadId: string;
+ commentNodeId: string;
+ content: string;
+}): ReviewThreadsInfiniteData | undefined {
+ const { data, threadId, commentNodeId, content } = args;
+ if (!data) {
+ return data;
+ }
+ const nextPages = data.pages.map(page => {
+ const nextThreads = page.threads.map(thread => {
+ if (thread.threadId !== threadId) {
+ return thread;
+ }
+ const nextComments = thread.comments.map(comment => {
+ if (comment.nodeId !== commentNodeId) {
+ return comment;
+ }
+ const existing = comment.reactions.find(r => r.content === content);
+ if (existing) {
+ if (existing.viewerHasReacted) {
+ // Remove: count -1, clear viewer flag. Guard against
+ // underflow if the server count was already 0.
+ return {
+ ...comment,
+ reactions: comment.reactions.map(r =>
+ r.content === content
+ ? {
+ ...r,
+ count: Math.max(0, r.count - 1),
+ viewerHasReacted: false,
+ }
+ : r
+ ),
+ };
+ }
+ // Add to existing bucket.
+ return {
+ ...comment,
+ reactions: comment.reactions.map(r =>
+ r.content === content ? { ...r, count: r.count + 1, viewerHasReacted: true } : r
+ ),
+ };
+ }
+ // Reaction bucket not present yet — append a new one.
+ return {
+ ...comment,
+ reactions: [...comment.reactions, { content, count: 1, viewerHasReacted: true }],
+ };
+ });
+ const commentsChanged = nextComments.some(
+ (comment, index) => comment !== thread.comments[index]
+ );
+ return commentsChanged ? { ...thread, comments: nextComments } : thread;
+ });
+ const pageChanged = nextThreads.some((thread, index) => thread !== page.threads[index]);
+ return pageChanged ? { ...page, threads: nextThreads } : page;
+ });
+ const changed = nextPages.some((page, index) => page !== data.pages[index]);
+ if (!changed) {
+ return data;
+ }
+ return { ...data, pages: nextPages };
+}
+
+/**
+ * Locate the thread + comment in the cached data. Returns `null` when
+ * either is missing so the optimistic reducer can early-out.
+ */
+export function findReviewComment(
+ data: ReviewThreadsInfiniteData | undefined,
+ threadId: string,
+ commentNodeId: string
+): ReviewComment | null {
+ if (!data) {
+ return null;
+ }
+ for (const page of data.pages) {
+ for (const thread of page.threads) {
+ if (thread.threadId === threadId) {
+ const match = thread.comments.find(comment => comment.nodeId === commentNodeId);
+ if (match) {
+ return match;
+ }
+ }
+ }
+ }
+ return null;
+}
diff --git a/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts b/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts
new file mode 100644
index 0000000000..de76fa951a
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/use-pr-review-discussion-threads.ts
@@ -0,0 +1,57 @@
+// Discussion-tab list query hook.
+//
+// - `usePrReviewDiscussionThreads` — wraps the tRPC
+// `listReviewThreads` infinite query and returns the tab-level
+// error classification (via `classifyPrReviewQueryState`) so
+// the tab can short-circuit to a terminal state when the FIRST
+// page fails (a later-page error is rare here but should be
+// surfaced as a "Retry" affordance, not a tab-level blank).
+//
+// `useInfiniteQuery` returns `error` for both first-page and later-
+// page errors. A first-page error is one where `pages.length === 0`
+// AND the query has finished (no longer `isPending`). The
+// `firstPageErrorState` helper below encodes that distinction so
+// the tab UI doesn't have to.
+
+import { useInfiniteQuery } from '@tanstack/react-query';
+
+import { classifyPrReviewQueryState } from '@/lib/pr-review/classify-pr-review-query-state';
+import { useTRPC } from '@/lib/trpc';
+
+export function usePrReviewDiscussionThreads(args: {
+ owner: string;
+ repo: string;
+ number: number;
+}) {
+ const { owner, repo, number } = args;
+ const trpc = useTRPC();
+ const query = useInfiniteQuery(
+ trpc.githubPrReview.listReviewThreads.infiniteQueryOptions(
+ { owner, repo, number },
+ {
+ staleTime: 15_000,
+ getNextPageParam: lastPage => lastPage.nextCursor ?? undefined,
+ }
+ )
+ );
+
+ const hasLoadedPages = (query.data?.pages.length ?? 0) > 0;
+ const firstPagePending = query.isPending;
+ const firstPageErrorState =
+ !firstPagePending && !hasLoadedPages && query.error
+ ? classifyPrReviewQueryState(query.error)
+ : null;
+ const laterPageError = Boolean(query.error) && hasLoadedPages;
+
+ // Flat list of all threads across all loaded pages, in page order.
+ // We return a fresh array on every render so the consumer can
+ // `.map` without memoizing; the rows themselves are stable.
+ const threads = (query.data?.pages ?? []).flatMap(page => page.threads);
+
+ return {
+ query,
+ threads,
+ firstPageErrorState,
+ laterPageError,
+ };
+}
diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts
new file mode 100644
index 0000000000..832987de2b
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.ts
@@ -0,0 +1,233 @@
+// Discussion-tab mutations for the PR review surface.
+//
+// - `replyToComment` — NOT optimistic (per S7b contract):
+// the comment is appended only after the
+// server confirms, and the list is
+// invalidated on settle so the next
+// render includes the new comment.
+// The mutation hook toasts `onError` and
+// the inline reply input keeps its own
+// error state so the user can retry.
+//
+// - `resolveThread` /
+// `unresolveThread` — OPTIMISTIC. The reducer flips the
+// thread's `isResolved` in the cached
+// `listReviewThreads` infinite query,
+// snapshots the previous data in
+// `onMutate`, and rolls it back in
+// `onError`. `onSettled` invalidates the
+// path so a re-fetch reconciles with
+// the server's eventual state.
+//
+// - `addReaction` /
+// `removeReaction` — OPTIMISTIC. Same pattern as resolve,
+// but the reducer walks into a specific
+// comment inside a specific thread to
+// flip `count` + `viewerHasReacted`.
+// Invalidates on settle.
+//
+// Why we do NOT coalesce these into the existing
+// `useCreateReviewCommentMutation` / `useSubmitReviewMutation` hooks:
+// those are the inline / pending-review path; discussion replies and
+// reactions are independent mutations on already-posted comments, so
+// they belong in their own hook (and their own file) to keep the
+// queryKey surface area narrow.
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner-native';
+
+import { useTRPC } from '@/lib/trpc';
+
+import {
+ applyReactionToggle,
+ applyResolveToggle,
+ type ReviewReactionContent,
+ type ReviewThreadsInfiniteData,
+} from './review-discussion-types';
+
+function useDiscussionKeys() {
+ const trpc = useTRPC();
+ return {
+ listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(),
+ };
+}
+
+async function invalidateDiscussionCaches(
+ queryClient: ReturnType,
+ keys: ReturnType
+): Promise {
+ await queryClient.invalidateQueries(keys.listReviewThreadsPath);
+}
+
+// ── Reply (not optimistic) ────────────────────────────────────────────
+
+export function useReplyToCommentMutation() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+
+ return useMutation(
+ trpc.githubPrReview.replyToComment.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateDiscussionCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+// ── Resolve / unresolve (optimistic) ──────────────────────────────────
+
+export function useResolveThreadMutation() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+
+ return useMutation(
+ trpc.githubPrReview.resolveThread.mutationOptions({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: async ({ threadId }) => {
+ await queryClient.cancelQueries(keys.listReviewThreadsPath);
+ const previous = queryClient.getQueriesData(
+ keys.listReviewThreadsPath
+ );
+ queryClient.setQueriesData(keys.listReviewThreadsPath, old =>
+ applyResolveToggle(old, threadId, true)
+ );
+ return { previous };
+ },
+ onError: (error, _input, context) => {
+ const previous = context?.previous;
+ if (previous) {
+ for (const [key, data] of previous) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateDiscussionCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export function useUnresolveThreadMutation() {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+
+ return useMutation(
+ trpc.githubPrReview.unresolveThread.mutationOptions({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: async ({ threadId }) => {
+ await queryClient.cancelQueries(keys.listReviewThreadsPath);
+ const previous = queryClient.getQueriesData(
+ keys.listReviewThreadsPath
+ );
+ queryClient.setQueriesData(keys.listReviewThreadsPath, old =>
+ applyResolveToggle(old, threadId, false)
+ );
+ return { previous };
+ },
+ onError: (error, _input, context) => {
+ const previous = context?.previous;
+ if (previous) {
+ for (const [key, data] of previous) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateDiscussionCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+// ── Reactions (optimistic) ────────────────────────────────────────────
+
+// The reaction mutation DTO only carries `{commentNodeId, content}`. The
+// optimistic cache walk also needs the owning `threadId`, which is NOT a DTO
+// field — so the hook is constructed PER THREAD and closes over `threadId`
+// (the caller passes only the DTO fields to `.mutate`).
+export function useAddReactionMutation(threadId: string) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+
+ return useMutation(
+ trpc.githubPrReview.addReaction.mutationOptions({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: async ({ commentNodeId, content }) => {
+ await queryClient.cancelQueries(keys.listReviewThreadsPath);
+ const previous = queryClient.getQueriesData(
+ keys.listReviewThreadsPath
+ );
+ queryClient.setQueriesData(keys.listReviewThreadsPath, old =>
+ applyReactionToggle({
+ data: old,
+ threadId,
+ commentNodeId,
+ content: content as ReviewReactionContent,
+ })
+ );
+ return { previous };
+ },
+ onError: (error, _input, context) => {
+ const previous = context?.previous;
+ if (previous) {
+ for (const [key, data] of previous) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateDiscussionCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export function useRemoveReactionMutation(threadId: string) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+
+ return useMutation(
+ trpc.githubPrReview.removeReaction.mutationOptions({
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ onMutate: async ({ commentNodeId, content }) => {
+ await queryClient.cancelQueries(keys.listReviewThreadsPath);
+ const previous = queryClient.getQueriesData(
+ keys.listReviewThreadsPath
+ );
+ queryClient.setQueriesData(keys.listReviewThreadsPath, old =>
+ applyReactionToggle({
+ data: old,
+ threadId,
+ commentNodeId,
+ content: content as ReviewReactionContent,
+ })
+ );
+ return { previous };
+ },
+ onError: (error, _input, context) => {
+ const previous = context?.previous;
+ if (previous) {
+ for (const [key, data] of previous) {
+ queryClient.setQueryData(key, data);
+ }
+ }
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateDiscussionCaches(queryClient, keys);
+ },
+ })
+ );
+}
diff --git a/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts
new file mode 100644
index 0000000000..a4df62aa68
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/file-navigator-bridge.ts
@@ -0,0 +1,43 @@
+// Module-level bridge for the file-navigator's "scroll to file" request.
+// The file navigator subscribes via `subscribeFileNavigatorRequest` and
+// navigates the diff list when the user picks a file from the navigator
+// sheet. `requestScrollToFile` is the producer side, called by the
+// navigator sheet on selection. S6c implements both ends.
+//
+// Every request carries its owning PR identity, and subscribers register
+// for a specific PR, so a navigation request emitted for one PR is never
+// delivered to another PR's diff list if both remain mounted.
+
+import { type PrIdentity } from './diff-selection-bridge';
+
+export type FileNavigatorRequest = PrIdentity & {
+ path: string;
+};
+
+type Listener = (request: FileNavigatorRequest) => void;
+
+const listeners = new Set<{ pr: PrIdentity; listener: Listener }>();
+
+function samePr(a: PrIdentity, b: PrIdentity): boolean {
+ return (
+ a.owner.toLowerCase() === b.owner.toLowerCase() &&
+ a.repo.toLowerCase() === b.repo.toLowerCase() &&
+ a.number === b.number
+ );
+}
+
+export function requestScrollToFile(request: FileNavigatorRequest) {
+ for (const entry of listeners) {
+ if (samePr(entry.pr, request)) {
+ entry.listener(request);
+ }
+ }
+}
+
+export function subscribeFileNavigatorRequest(pr: PrIdentity, listener: Listener): () => void {
+ const entry = { pr, listener };
+ listeners.add(entry);
+ return () => {
+ listeners.delete(entry);
+ };
+}
diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts
new file mode 100644
index 0000000000..71380f5e34
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.test.ts
@@ -0,0 +1,278 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ defaultMergeMethodFor,
+ getAllowedMergeMethods,
+ getMergeabilityStatus,
+ getMergeBlockedReasons,
+ type MergeBlockedReasonsArgs,
+} from './merge-blocked-reasons';
+
+function repo(overrides: Partial = {}): MergeBlockedReasonsArgs {
+ return {
+ state: 'open',
+ draft: false,
+ mergeable: false,
+ mergeableState: 'blocked',
+ reviewDecision: 'REVIEW_REQUIRED',
+ allowUpdateBranch: true,
+ ...overrides,
+ };
+}
+
+describe('getMergeabilityStatus', () => {
+ it('returns terminal for merged PRs', () => {
+ expect(
+ getMergeabilityStatus({ state: 'merged', mergeable: true, mergeableState: 'clean' })
+ ).toBe('terminal');
+ });
+
+ it('returns terminal for closed PRs', () => {
+ expect(
+ getMergeabilityStatus({ state: 'closed', mergeable: true, mergeableState: 'clean' })
+ ).toBe('terminal');
+ });
+
+ it('returns unknown when mergeable is null', () => {
+ expect(getMergeabilityStatus({ state: 'open', mergeable: null, mergeableState: 'clean' })).toBe(
+ 'unknown'
+ );
+ });
+
+ it('returns unknown when mergeableState is null', () => {
+ expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: null })).toBe(
+ 'unknown'
+ );
+ });
+
+ it('returns unknown when mergeableState is "unknown"', () => {
+ expect(
+ getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'unknown' })
+ ).toBe('unknown');
+ });
+
+ it('returns mergeable only when mergeable=true AND mergeableState=clean', () => {
+ expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'clean' })).toBe(
+ 'mergeable'
+ );
+ });
+
+ it('returns blocked when mergeable=true but state is not clean', () => {
+ expect(getMergeabilityStatus({ state: 'open', mergeable: true, mergeableState: 'dirty' })).toBe(
+ 'blocked'
+ );
+ });
+
+ it('returns blocked when mergeable=false even with a clean state (race)', () => {
+ expect(
+ getMergeabilityStatus({ state: 'open', mergeable: false, mergeableState: 'clean' })
+ ).toBe('blocked');
+ });
+});
+
+describe('getMergeBlockedReasons', () => {
+ it('returns an empty list for merged/closed PRs', () => {
+ expect(
+ getMergeBlockedReasons(repo({ state: 'merged', mergeable: false, mergeableState: 'clean' }))
+ ).toEqual([]);
+ expect(
+ getMergeBlockedReasons(repo({ state: 'closed', mergeable: false, mergeableState: 'clean' }))
+ ).toEqual([]);
+ });
+
+ it('reports dirty as a destructive conflicts reason', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'dirty', reviewDecision: 'APPROVED' })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.id).toBe('conflicts');
+ expect(reasons[0]?.iconKind).toBe('conflicts');
+ expect(reasons[0]?.severity).toBe('destructive');
+ });
+
+ it('reports blocked with required-reviews + failing-checks', () => {
+ const reasons = getMergeBlockedReasons(repo({ mergeable: false, mergeableState: 'blocked' }));
+ expect(reasons.map(r => r.id)).toEqual(['required-reviews', 'failing-checks']);
+ expect(reasons[0]?.iconKind).toBe('required-reviews');
+ expect(reasons[0]?.severity).toBe('warn');
+ expect(reasons[1]?.iconKind).toBe('failing-checks');
+ expect(reasons[1]?.severity).toBe('destructive');
+ });
+
+ it('reports behind as a branch-out-of-date reason with a rebase/update CTA', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'behind', reviewDecision: 'APPROVED' })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.id).toBe('behind');
+ expect(reasons[0]?.iconKind).toBe('behind');
+ expect(reasons[0]?.detail).toContain('Update the branch from the base');
+ });
+
+ it('shows a rebase-only detail when the repo disallows Update branch', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({
+ mergeable: false,
+ mergeableState: 'behind',
+ reviewDecision: 'APPROVED',
+ allowUpdateBranch: false,
+ })
+ );
+ expect(reasons[0]?.detail).toContain('Rebase or update the branch');
+ });
+
+ it('reports unstable as a non-required-checks-failing info reason', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: true, mergeableState: 'unstable', reviewDecision: 'APPROVED' })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.id).toBe('unstable-checks');
+ expect(reasons[0]?.iconKind).toBe('unstable-checks');
+ expect(reasons[0]?.severity).toBe('info');
+ });
+
+ it('reports draft as a draft info reason', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'draft', reviewDecision: null })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.id).toBe('draft');
+ expect(reasons[0]?.iconKind).toBe('draft');
+ expect(reasons[0]?.severity).toBe('info');
+ });
+
+ it('adds a required-reviews reason when reviewDecision is REVIEW_REQUIRED and mergeableState did not include it', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'dirty', reviewDecision: 'REVIEW_REQUIRED' })
+ );
+ expect(reasons.map(r => r.id)).toEqual(['conflicts', 'required-reviews']);
+ });
+
+ it('does not double-list required-reviews when mergeableState=blocked already produced it', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'blocked', reviewDecision: 'REVIEW_REQUIRED' })
+ );
+ expect(reasons.filter(r => r.id === 'required-reviews')).toHaveLength(1);
+ });
+
+ it('adds a draft reason when the PR is marked draft and mergeableState did not already report it', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'dirty', draft: true, reviewDecision: 'APPROVED' })
+ );
+ expect(reasons.map(r => r.id)).toEqual(['conflicts', 'draft']);
+ });
+
+ it('does not double-list draft when mergeableState=draft already produced it', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'draft', draft: true, reviewDecision: 'APPROVED' })
+ );
+ expect(reasons.filter(r => r.id === 'draft')).toHaveLength(1);
+ });
+
+ it('returns an unknown-state reason when mergeableState is unknown', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: true, mergeableState: 'unknown', reviewDecision: 'APPROVED' })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.id).toBe('unknown-state');
+ expect(reasons[0]?.iconKind).toBe('unknown-state');
+ });
+
+ it('returns an unknown-state reason when mergeableState is null', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: true, mergeableState: null, reviewDecision: 'APPROVED' })
+ );
+ expect(reasons[0]?.id).toBe('unknown-state');
+ });
+
+ it('returns an empty list for a fully mergeable PR', () => {
+ expect(
+ getMergeBlockedReasons(
+ repo({ mergeable: true, mergeableState: 'clean', reviewDecision: 'APPROVED' })
+ )
+ ).toEqual([]);
+ });
+
+ it('surfaces unknown future mergeableState values rather than hiding the block', () => {
+ const reasons = getMergeBlockedReasons(
+ repo({ mergeable: false, mergeableState: 'some_future_value', reviewDecision: 'APPROVED' })
+ );
+ expect(reasons).toHaveLength(1);
+ expect(reasons[0]?.detail).toContain('some_future_value');
+ });
+});
+
+describe('getAllowedMergeMethods', () => {
+ it('returns methods in a stable order, filtering out disabled ones', () => {
+ expect(
+ getAllowedMergeMethods({
+ allowMergeCommit: true,
+ allowSquashMerge: true,
+ allowRebaseMerge: true,
+ allowAutoMerge: true,
+ deleteBranchOnMerge: true,
+ allowUpdateBranch: true,
+ viewerCanPush: true,
+ viewerCanAdmin: true,
+ })
+ ).toEqual(['merge', 'squash', 'rebase']);
+
+ expect(
+ getAllowedMergeMethods({
+ allowMergeCommit: false,
+ allowSquashMerge: true,
+ allowRebaseMerge: false,
+ allowAutoMerge: true,
+ deleteBranchOnMerge: true,
+ allowUpdateBranch: true,
+ viewerCanPush: true,
+ viewerCanAdmin: true,
+ })
+ ).toEqual(['squash']);
+
+ expect(
+ getAllowedMergeMethods({
+ allowMergeCommit: false,
+ allowSquashMerge: false,
+ allowRebaseMerge: false,
+ allowAutoMerge: true,
+ deleteBranchOnMerge: true,
+ allowUpdateBranch: true,
+ viewerCanPush: true,
+ viewerCanAdmin: true,
+ })
+ ).toEqual([]);
+ });
+});
+
+describe('defaultMergeMethodFor', () => {
+ it('prefers squash when the repo only allows squash', () => {
+ expect(
+ defaultMergeMethodFor({
+ allowMergeCommit: false,
+ allowSquashMerge: true,
+ allowRebaseMerge: false,
+ allowAutoMerge: true,
+ deleteBranchOnMerge: true,
+ allowUpdateBranch: true,
+ viewerCanPush: true,
+ viewerCanAdmin: true,
+ })
+ ).toBe('squash');
+ });
+
+ it('falls back to merge when the repo has no allowed methods', () => {
+ expect(
+ defaultMergeMethodFor({
+ allowMergeCommit: false,
+ allowSquashMerge: false,
+ allowRebaseMerge: false,
+ allowAutoMerge: true,
+ deleteBranchOnMerge: true,
+ allowUpdateBranch: true,
+ viewerCanPush: true,
+ viewerCanAdmin: true,
+ })
+ ).toBe('merge');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts
new file mode 100644
index 0000000000..fdafb0c809
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/merge/merge-blocked-reasons.ts
@@ -0,0 +1,281 @@
+// Pure selector: derives the merge-state decision tree that drives the
+// S8 merge section (and its unit tests). No React, no react-query, no
+// expo modules, and NO icon library — the section/sheet components
+// translate the `iconKind` strings into Lucide icons. Keeping the icon
+// mapping out of this module lets the tests load in plain Node without
+// pulling in lucide-react-native (whose ESM build uses `import.meta` in
+// ways the repo's vitest setup doesn't transform).
+
+export type PrMergeMethod = 'merge' | 'squash' | 'rebase';
+export type PrReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null;
+
+export type PrOverviewRepoSettings = {
+ allowMergeCommit: boolean;
+ allowSquashMerge: boolean;
+ allowRebaseMerge: boolean;
+ allowAutoMerge: boolean;
+ deleteBranchOnMerge: boolean;
+ allowUpdateBranch: boolean;
+ viewerCanPush: boolean;
+ viewerCanAdmin: boolean;
+};
+
+export type PrOverviewDto = {
+ state: 'open' | 'closed' | 'merged';
+ draft: boolean;
+ baseRef: string;
+ headRef: string;
+ isCrossRepo: boolean;
+ headSha: string;
+ prNodeId: string;
+ title: string;
+ bodyMarkdown: string | null;
+ number: number;
+ mergeable: boolean | null;
+ mergeableState: string | null;
+ autoMerge: { method: string } | null;
+ reviewDecision: PrReviewDecision;
+ repo: PrOverviewRepoSettings;
+};
+
+type MergeabilityStatus = 'unknown' | 'blocked' | 'mergeable' | 'terminal';
+
+export type MergeBlockedReasonId =
+ | 'conflicts'
+ | 'required-reviews'
+ | 'failing-checks'
+ | 'behind'
+ | 'unstable-checks'
+ | 'draft'
+ | 'unknown-state';
+
+export type MergeBlockedReasonSeverity = 'info' | 'warn' | 'destructive';
+
+export type MergeBlockedReason = {
+ id: MergeBlockedReasonId;
+ /** Stable identifier the section uses to look up an icon (see pr-merge-icons.ts). */
+ iconKind: MergeBlockedReasonId;
+ severity: MergeBlockedReasonSeverity;
+ title: string;
+ detail: string;
+};
+
+export type MergeBlockedReasonsArgs = {
+ state: PrOverviewDto['state'];
+ draft: PrOverviewDto['draft'];
+ mergeable: PrOverviewDto['mergeable'];
+ mergeableState: PrOverviewDto['mergeableState'];
+ reviewDecision: PrReviewDecision;
+ allowUpdateBranch: boolean;
+};
+
+/**
+ * High-level mergeability status the section uses to pick which UI to
+ * render. `unknown` covers the brief window after GitHub queues a
+ * mergeability re-check; the section polls the overview in that case.
+ * `terminal` means the PR is closed/merged and no further action exists.
+ */
+export function getMergeabilityStatus(args: {
+ state: PrOverviewDto['state'];
+ mergeable: PrOverviewDto['mergeable'];
+ mergeableState: PrOverviewDto['mergeableState'];
+}): MergeabilityStatus {
+ if (args.state !== 'open') {
+ return 'terminal';
+ }
+ if (
+ args.mergeable === null ||
+ args.mergeableState === null ||
+ args.mergeableState === 'unknown'
+ ) {
+ return 'unknown';
+ }
+ if (args.mergeable && args.mergeableState === 'clean') {
+ return 'mergeable';
+ }
+ return 'blocked';
+}
+
+const UNKNOWN_REASON: MergeBlockedReason = {
+ id: 'unknown-state',
+ iconKind: 'unknown-state',
+ severity: 'warn',
+ title: "GitHub hasn't reported a mergeable state",
+ detail: 'Try refreshing the overview in a moment.',
+};
+
+const CONFLICTS_REASON: MergeBlockedReason = {
+ id: 'conflicts',
+ iconKind: 'conflicts',
+ severity: 'destructive',
+ title: 'Merge conflicts',
+ detail: 'Resolve the merge conflicts on this branch before merging.',
+};
+
+const REQUIRED_REVIEWS_REASON: MergeBlockedReason = {
+ id: 'required-reviews',
+ iconKind: 'required-reviews',
+ severity: 'warn',
+ title: 'Required reviews missing',
+ detail: 'Approvals from the required reviewers are missing or pending.',
+};
+
+const FAILING_CHECKS_REASON: MergeBlockedReason = {
+ id: 'failing-checks',
+ iconKind: 'failing-checks',
+ severity: 'destructive',
+ title: 'Failing required checks',
+ detail: 'Required status checks are failing.',
+};
+
+const UNSTABLE_REASON: MergeBlockedReason = {
+ id: 'unstable-checks',
+ iconKind: 'unstable-checks',
+ severity: 'info',
+ title: 'Some checks are failing',
+ detail: 'Non-required checks are failing. They will not block the merge.',
+};
+
+const DRAFT_REASON: MergeBlockedReason = {
+ id: 'draft',
+ iconKind: 'draft',
+ severity: 'info',
+ title: 'Draft pull request',
+ detail: 'Mark the pull request as ready for review before merging.',
+};
+
+function behindReason(allowUpdateBranch: boolean): MergeBlockedReason {
+ return {
+ id: 'behind',
+ iconKind: 'behind',
+ severity: 'warn',
+ title: 'Branch is out of date',
+ detail: allowUpdateBranch
+ ? 'Update the branch from the base, or rebase, before merging.'
+ : 'The head branch is behind the base. Rebase or update the branch before merging.',
+ };
+}
+
+/**
+ * Ordered, deduplicated list of why-this-PR-can't-be-merged-yet reasons.
+ * Empty when the PR is mergeable. Order: most specific / most actionable
+ * first — GitHub's `mergeable_state` wins as the top reason when it
+ * fires, then reviews, then draft.
+ */
+export function getMergeBlockedReasons(args: MergeBlockedReasonsArgs): MergeBlockedReason[] {
+ if (args.state !== 'open') {
+ return [];
+ }
+ const reasons: MergeBlockedReason[] = [];
+ const seen = new Set();
+
+ const push = (reason: MergeBlockedReason) => {
+ if (seen.has(reason.id)) {
+ return;
+ }
+ seen.add(reason.id);
+ reasons.push(reason);
+ };
+
+ switch (args.mergeableState) {
+ case 'dirty': {
+ push(CONFLICTS_REASON);
+ break;
+ }
+ case 'blocked': {
+ push(REQUIRED_REVIEWS_REASON);
+ push(FAILING_CHECKS_REASON);
+ break;
+ }
+ case 'behind': {
+ push(behindReason(args.allowUpdateBranch));
+ break;
+ }
+ case 'unstable': {
+ push(UNSTABLE_REASON);
+ break;
+ }
+ case 'draft': {
+ push(DRAFT_REASON);
+ break;
+ }
+ case 'clean': {
+ if (args.mergeable === false) {
+ push(UNKNOWN_REASON);
+ }
+ break;
+ }
+ case 'unknown':
+ case null: {
+ push(UNKNOWN_REASON);
+ break;
+ }
+ default: {
+ // GitHub may add new mergeable_state values over time — surface the
+ // raw value as a generic blocked reason rather than silently
+ // showing nothing.
+ push({
+ id: 'unknown-state',
+ iconKind: 'unknown-state',
+ severity: 'warn',
+ title: 'This pull request is not mergeable yet',
+ detail: `GitHub reported a "${args.mergeableState}" mergeable state.`,
+ });
+ }
+ }
+
+ if (args.reviewDecision === 'REVIEW_REQUIRED' && args.mergeableState !== 'blocked') {
+ push(REQUIRED_REVIEWS_REASON);
+ }
+
+ if (args.draft && args.mergeableState !== 'draft') {
+ push(DRAFT_REASON);
+ }
+
+ return reasons;
+}
+
+export type AllowedMergeMethod = PrMergeMethod;
+
+/**
+ * Repo-allowed merge methods, in the order the picker should show them.
+ * Squashes and merges are the two defaults; rebase is rare but still
+ * honored when enabled.
+ */
+export function getAllowedMergeMethods(repo: PrOverviewRepoSettings): AllowedMergeMethod[] {
+ const methods: AllowedMergeMethod[] = [];
+ if (repo.allowMergeCommit) {
+ methods.push('merge');
+ }
+ if (repo.allowSquashMerge) {
+ methods.push('squash');
+ }
+ if (repo.allowRebaseMerge) {
+ methods.push('rebase');
+ }
+ return methods;
+}
+
+export const PR_MERGE_LABELS: Record = {
+ merge: 'Create a merge commit',
+ squash: 'Squash and merge',
+ rebase: 'Rebase and merge',
+};
+
+export const PR_MERGE_DESCRIPTIONS: Record = {
+ merge: 'Combine all commits from this branch into the base branch with a merge commit.',
+ squash: 'Combine all commits from this branch into a single commit on the base branch.',
+ rebase: 'Replay all commits from this branch onto the base branch without a merge commit.',
+};
+
+/** The default method the picker selects on first open. */
+export function defaultMergeMethodFor(repo: PrOverviewRepoSettings): AllowedMergeMethod {
+ const allowed = getAllowedMergeMethods(repo);
+ // The server should never return a PR with no allowed methods, but if
+ // it does we still need a stable default for the form state.
+ if (allowed.length === 0) {
+ return 'merge';
+ }
+ const first = allowed[0];
+ return first ?? 'merge';
+}
diff --git a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts
new file mode 100644
index 0000000000..0baabb8dd5
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ defaultCommitMessage,
+ defaultCommitTitle,
+} from '@/lib/pr-review/merge/merge-commit-defaults';
+
+describe('defaultCommitTitle', () => {
+ it('returns the PR title with the number appended', () => {
+ expect(defaultCommitTitle('Add feature', 42)).toBe('Add feature (#42)');
+ });
+});
+
+describe('defaultCommitMessage', () => {
+ it('returns the PR body when it is non-empty', () => {
+ expect(defaultCommitMessage('Description line')).toBe('Description line');
+ });
+
+ it('returns an empty string when the body is null', () => {
+ expect(defaultCommitMessage(null)).toBe('');
+ });
+
+ it('returns an empty string when the body is whitespace only', () => {
+ expect(defaultCommitMessage(' ')).toBe('');
+ });
+
+ it('returns the body after trimming surrounding whitespace', () => {
+ expect(defaultCommitMessage(' meaningful body ')).toBe(' meaningful body ');
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts
new file mode 100644
index 0000000000..de1641078d
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/merge/merge-commit-defaults.ts
@@ -0,0 +1,14 @@
+// Pure defaults for the merge sheet's commit title/message fields. Kept
+// out of the component so they stay unit-testable and the sheet stays
+// under the max-lines limit.
+
+export function defaultCommitTitle(title: string, number: number): string {
+ return `${title} (#${number})`;
+}
+
+export function defaultCommitMessage(body: string | null): string {
+ if (body && body.trim().length > 0) {
+ return body;
+ }
+ return '';
+}
diff --git a/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts
new file mode 100644
index 0000000000..3f29bcaa24
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/merge/use-pr-merge-mutations.ts
@@ -0,0 +1,108 @@
+// S8 merge-side mutation hooks. Pattern mirrors the repo's existing
+// mutation hooks (useSessionMutations, useSecurityAgentMutations):
+// - `onError` toasts the message
+// - `onSettled` invalidates the overview + the per-PR listChecks /
+// listFiles caches so the new head SHA refetches
+// - keeps the mutation hook thin and lets the sheet / section handle
+// inline errors (toasts paint behind formSheets)
+//
+// listChecks is keyed by `(owner, repo, ref)`. The head ref will change
+// after a successful merge / update-branch, so we invalidate the
+// procedure PATH (not a single key) — every cached check list for this
+// PR is dropped and any mounted consumer re-fetches against the new
+// head. `listFiles` is per-page; we invalidate the full procedure too.
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner-native';
+
+import { useTRPC } from '@/lib/trpc';
+
+type PrRef = { owner: string; repo: string; number: number };
+
+function usePrRefKeys(ref: PrRef) {
+ const trpc = useTRPC();
+ return {
+ getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref),
+ listChecksPath: trpc.githubPrReview.listChecks.pathFilter(),
+ listFilesPath: trpc.githubPrReview.listFiles.pathFilter(),
+ };
+}
+
+async function invalidatePrCaches(
+ queryClient: ReturnType,
+ keys: ReturnType
+): Promise {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: keys.getPullRequest }),
+ queryClient.invalidateQueries(keys.listChecksPath),
+ queryClient.invalidateQueries(keys.listFilesPath),
+ ]);
+}
+
+export function useMergePullRequestMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.mergePullRequest.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidatePrCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export function useUpdateBranchMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.updateBranch.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidatePrCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export function useEnableAutoMergeMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.enableAutoMerge.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidatePrCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export function useDisableAutoMergeMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.disableAutoMerge.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidatePrCaches(queryClient, keys);
+ },
+ })
+ );
+}
diff --git a/apps/mobile/src/lib/pr-review/pending-review-provider.tsx b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx
new file mode 100644
index 0000000000..5215ce5120
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/pending-review-provider.tsx
@@ -0,0 +1,65 @@
+import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react';
+
+// One queued inline comment in the pending review. The composer fills
+// this in when the user taps "Add to review"; the submit sheet drains
+// the whole list into one `submitReview` batch call.
+//
+// `commitSha` records the PR head SHA at the time the comment was
+// queued so the submit sheet can flag "may be outdated" if the head
+// moves between queue and submit. Submission itself always uses the
+// LATEST head SHA (per the S3 contract) — a per-item 422 surfaces
+// inline so the user can decide whether to retry or drop the comment.
+export type PendingReviewItem = {
+ id: string;
+ path: string;
+ side: 'LEFT' | 'RIGHT';
+ line: number;
+ startLine?: number;
+ body: string;
+ commitSha: string;
+};
+
+type PendingReviewContextValue = {
+ items: PendingReviewItem[];
+ addComment: (item: PendingReviewItem) => void;
+ updateComment: (id: string, body: string) => void;
+ removeComment: (id: string) => void;
+ clear: () => void;
+};
+
+const PendingReviewContext = createContext(undefined);
+
+export function PendingReviewProvider({ children }: { readonly children: ReactNode }) {
+ const [items, setItems] = useState([]);
+
+ const addComment = useCallback((item: PendingReviewItem) => {
+ setItems(previous => [...previous, item]);
+ }, []);
+
+ const updateComment = useCallback((id: string, body: string) => {
+ setItems(previous => previous.map(item => (item.id === id ? { ...item, body } : item)));
+ }, []);
+
+ const removeComment = useCallback((id: string) => {
+ setItems(previous => previous.filter(item => item.id !== id));
+ }, []);
+
+ const clear = useCallback(() => {
+ setItems([]);
+ }, []);
+
+ const value = useMemo(
+ () => ({ items, addComment, updateComment, removeComment, clear }),
+ [items, addComment, updateComment, removeComment, clear]
+ );
+
+ return {children};
+}
+
+export function usePendingReview(): PendingReviewContextValue {
+ const context = useContext(PendingReviewContext);
+ if (!context) {
+ throw new Error('usePendingReview must be used within a PendingReviewProvider');
+ }
+ return context;
+}
diff --git a/apps/mobile/src/lib/pr-review/pr-bridges.test.ts b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts
new file mode 100644
index 0000000000..63170dc012
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/pr-bridges.test.ts
@@ -0,0 +1,66 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { clearDiffSelection, getDiffSelection, setDiffSelection } from './diff-selection-bridge';
+import {
+ type FileNavigatorRequest,
+ requestScrollToFile,
+ subscribeFileNavigatorRequest,
+} from './file-navigator-bridge';
+
+type NavListener = (request: FileNavigatorRequest) => void;
+
+const PR_A = { owner: 'octocat', repo: 'hello', number: 1 };
+const PR_B = { owner: 'octocat', repo: 'hello', number: 2 };
+
+describe('diff-selection-bridge', () => {
+ beforeEach(() => {
+ clearDiffSelection();
+ });
+
+ it('returns the selection only to the PR that produced it', () => {
+ setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' });
+
+ expect(getDiffSelection(PR_A)?.path).toBe('a.ts');
+ expect(getDiffSelection(PR_B)).toBeNull();
+ });
+
+ it('matches PR identity case-insensitively on owner/repo', () => {
+ setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' });
+
+ expect(getDiffSelection({ owner: 'OCTOCAT', repo: 'Hello', number: 1 })).not.toBeNull();
+ });
+
+ it('clears the selection so it never leaks into the next visit', () => {
+ setDiffSelection({ ...PR_A, path: 'a.ts', side: 'RIGHT', line: 3, selectedText: 'x' });
+ clearDiffSelection();
+
+ expect(getDiffSelection(PR_A)).toBeNull();
+ });
+});
+
+describe('file-navigator-bridge', () => {
+ it('delivers a scroll request only to listeners of the same PR', () => {
+ const listenerA = vi.fn();
+ const listenerB = vi.fn();
+ const unsubA = subscribeFileNavigatorRequest(PR_A, listenerA);
+ const unsubB = subscribeFileNavigatorRequest(PR_B, listenerB);
+
+ requestScrollToFile({ ...PR_A, path: 'a.ts' });
+
+ expect(listenerA).toHaveBeenCalledWith({ ...PR_A, path: 'a.ts' });
+ expect(listenerB).not.toHaveBeenCalled();
+
+ unsubA();
+ unsubB();
+ });
+
+ it('stops delivering after unsubscribe', () => {
+ const listener = vi.fn();
+ const unsub = subscribeFileNavigatorRequest(PR_A, listener);
+ unsub();
+
+ requestScrollToFile({ ...PR_A, path: 'a.ts' });
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/recent-prs.test.ts b/apps/mobile/src/lib/pr-review/recent-prs.test.ts
new file mode 100644
index 0000000000..bd02e1f256
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/recent-prs.test.ts
@@ -0,0 +1,123 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { clearRecentPrs, getRecentPrs, type RecentPr, upsertRecentPr } from './recent-prs';
+
+const store = new Map();
+
+vi.mock('expo-secure-store', () => ({
+ getItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ return store.get(key) ?? null;
+ }),
+ setItemAsync: vi.fn(async (key: string, value: string) => {
+ await Promise.resolve();
+ store.set(key, value);
+ }),
+ deleteItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ store.delete(key);
+ }),
+}));
+
+vi.mock('@/lib/storage-keys', () => ({
+ PR_REVIEW_RECENTS_KEY: 'pr-review-recents',
+}));
+
+beforeEach(() => {
+ // Clear the disk mock between tests. The module's write-queue is a
+ // module-level variable that survives across tests; since every test
+ // awaits its own upsert/clear chain before returning, the queue is
+ // always drained before the next test starts.
+ store.clear();
+});
+
+function makeRecent(overrides: Partial = {}): RecentPr {
+ return {
+ owner: 'octocat',
+ repo: 'hello-world',
+ number: 42,
+ title: 'Hello PR',
+ lastOpenedAt: 1_700_000_000_000,
+ ...overrides,
+ };
+}
+
+describe('recent-prs', () => {
+ it('returns an empty list when nothing has been stored', async () => {
+ await expect(getRecentPrs()).resolves.toEqual([]);
+ });
+
+ it('upsert puts a new entry at the front of the list', async () => {
+ await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 1, title: 'One' }));
+ await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 2, title: 'Two' }));
+
+ await expect(getRecentPrs()).resolves.toMatchObject([
+ { number: 2, title: 'Two' },
+ { number: 1, title: 'One' },
+ ]);
+ });
+
+ it('upsert moves an existing entry to the front and updates its title', async () => {
+ await upsertRecentPr(makeRecent({ number: 1, title: 'Old title' }));
+ await upsertRecentPr(makeRecent({ owner: 'octocat', repo: 'hello', number: 2, title: 'Two' }));
+ await upsertRecentPr(makeRecent({ number: 1, title: 'New title' }));
+
+ await expect(getRecentPrs()).resolves.toMatchObject([
+ { number: 1, title: 'New title' },
+ { number: 2, title: 'Two' },
+ ]);
+ });
+
+ it('caps the list at 10 entries, keeping the most recent first', async () => {
+ // Sequentially upsert so each write sees the previous write's result
+ // — the write-queue relies on this ordering. Promise.all would let
+ // the in-flight reads race.
+ const writes: Promise[] = [];
+ for (let index = 0; index < 10; index += 1) {
+ writes.push(upsertRecentPr(makeRecent({ number: index + 1, title: `T${index + 1}` })));
+ }
+ await Promise.all(writes);
+ await upsertRecentPr(makeRecent({ number: 99, title: 'Newest' }));
+
+ const recents = await getRecentPrs();
+ expect(recents).toHaveLength(10);
+ expect(recents[0]).toMatchObject({ number: 99, title: 'Newest' });
+ // Most-recent-first: after the cap, the oldest original (T1) drops off
+ // the tail, T10 sits at 1, and T2..T10 are all still in the list.
+ expect(recents[1]).toMatchObject({ number: 10, title: 'T10' });
+ expect(recents.at(-1)).toMatchObject({ number: 2, title: 'T2' });
+ expect(recents).not.toContainEqual(expect.objectContaining({ number: 1, title: 'T1' }));
+ });
+
+ it('treats corrupt stored data as an empty list', async () => {
+ store.set('pr-review-recents', '{not json');
+ await expect(getRecentPrs()).resolves.toEqual([]);
+ });
+
+ it('treats malformed entries (missing fields) as not present', async () => {
+ // Mixed bag: one valid entry + an entry missing required fields + a
+ // string + null. Only the valid one should survive the parse filter.
+ store.set(
+ 'pr-review-recents',
+ JSON.stringify([
+ {
+ owner: 'octocat',
+ repo: 'hello',
+ number: 1,
+ title: 'Good',
+ lastOpenedAt: 1_700_000_000_000,
+ },
+ { owner: 'octocat', repo: 'hello' },
+ 'not-an-object',
+ null,
+ ])
+ );
+ await expect(getRecentPrs()).resolves.toMatchObject([{ number: 1, title: 'Good' }]);
+ });
+
+ it('clearRecentPrs deletes the storage key', async () => {
+ store.set('pr-review-recents', 'placeholder');
+ await clearRecentPrs();
+ expect(store.has('pr-review-recents')).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/recent-prs.ts b/apps/mobile/src/lib/pr-review/recent-prs.ts
new file mode 100644
index 0000000000..ff3c0b0484
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/recent-prs.ts
@@ -0,0 +1,101 @@
+import * as SecureStore from 'expo-secure-store';
+
+import { PR_REVIEW_RECENTS_KEY } from '@/lib/storage-keys';
+
+export type RecentPr = {
+ owner: string;
+ repo: string;
+ number: number;
+ title: string;
+ lastOpenedAt: number;
+};
+
+const RECENT_PR_LIMIT = 10;
+
+// Same serialized write-queue pattern as last-active-instance: a sign-out
+// clear must never be overtaken by an in-flight upsert, which would leak the
+// previous account's recents into the next cold start.
+let writeQueue: Promise | null = null;
+
+async function enqueueWrite(op: () => Promise): Promise {
+ const previous = writeQueue;
+ const next = (async () => {
+ if (previous) {
+ try {
+ await previous;
+ } catch {
+ // An earlier failed write must not block the queue.
+ }
+ }
+ await op();
+ })();
+ writeQueue = next;
+ await next;
+}
+
+function recentPrKey(item: RecentPr): string {
+ return `${item.owner.toLowerCase()}/${item.repo.toLowerCase()}#${item.number}`;
+}
+
+function parseRecents(raw: string | null): RecentPr[] {
+ if (raw == null || raw.length === 0) {
+ return [];
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) {
+ return [];
+ }
+ return parsed.flatMap((entry): RecentPr[] => {
+ if (
+ entry &&
+ typeof entry === 'object' &&
+ typeof (entry as Record).owner === 'string' &&
+ typeof (entry as Record).repo === 'string' &&
+ typeof (entry as Record).number === 'number' &&
+ typeof (entry as Record).title === 'string' &&
+ typeof (entry as Record).lastOpenedAt === 'number'
+ ) {
+ return [entry as RecentPr];
+ }
+ return [];
+ });
+ } catch {
+ return [];
+ }
+}
+
+function toJsonString(recents: RecentPr[]): string {
+ // Stable shape — ensure order survives a round trip without any implicit
+ // normalization that could drop the newest entry on a partial write.
+ return JSON.stringify(recents);
+}
+
+export async function getRecentPrs(): Promise {
+ const raw = await SecureStore.getItemAsync(PR_REVIEW_RECENTS_KEY);
+ return parseRecents(raw);
+}
+
+/**
+ * Inserts/updates a recent PR entry, moves it to the front, and trims the
+ * list to the most recent RECENT_PR_LIMIT. The title is taken from the
+ * caller (which may be the user-typed URL before the PR loads, or a
+ * later load-time fetch that backfills the title) — the function never
+ * reads or overwrites the title from disk on its own.
+ */
+export async function upsertRecentPr(entry: RecentPr): Promise {
+ await enqueueWrite(async () => {
+ const existingRaw = await SecureStore.getItemAsync(PR_REVIEW_RECENTS_KEY);
+ const existing = parseRecents(existingRaw);
+ const incomingKey = recentPrKey(entry);
+ const filtered = existing.filter(item => recentPrKey(item) !== incomingKey);
+ const next = [entry, ...filtered].slice(0, RECENT_PR_LIMIT);
+ await SecureStore.setItemAsync(PR_REVIEW_RECENTS_KEY, toJsonString(next));
+ });
+}
+
+export async function clearRecentPrs(): Promise {
+ await enqueueWrite(async () => {
+ await SecureStore.deleteItemAsync(PR_REVIEW_RECENTS_KEY);
+ });
+}
diff --git a/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts
new file mode 100644
index 0000000000..3df2532958
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/use-pr-review-mutations.ts
@@ -0,0 +1,95 @@
+// S7a mutation hooks for inline PR review comments and the pending-
+// review batch submit. The pattern mirrors the existing S8 merge
+// mutations:
+// - `onError` toasts the message
+// - `onSettled` invalidates the PR review queries that the
+// mutation could have invalidated (overview `getPullRequest` for
+// `submitReview` because reviewDecision may flip; `listReviewThreads`
+// for both because a new thread lands immediately)
+// - the sheet / composer ALSO renders inline errors because toasts
+// paint behind formSheets on iOS
+//
+// `createReviewComment` posts ONE comment immediately (no pending
+// review). `submitReview` posts a BATCH — the composer enqueues
+// comments into the `PendingReviewProvider` and the submit sheet
+// drains that queue into one `submitReview` call. The submission
+// uses the LATEST head SHA (per the S3 contract) regardless of what
+// SHA each item was queued under; a per-item 422 surfaces inline.
+
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { toast } from 'sonner-native';
+
+import { useTRPC } from '@/lib/trpc';
+
+type PrRef = { owner: string; repo: string; number: number };
+
+function usePrRefKeys(ref: PrRef) {
+ const trpc = useTRPC();
+ return {
+ getPullRequest: trpc.githubPrReview.getPullRequest.queryKey(ref),
+ listReviewThreadsPath: trpc.githubPrReview.listReviewThreads.pathFilter(),
+ };
+}
+
+async function invalidateReviewCaches(
+ queryClient: ReturnType,
+ keys: ReturnType
+): Promise {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: keys.getPullRequest }),
+ queryClient.invalidateQueries(keys.listReviewThreadsPath),
+ ]);
+}
+
+export function useCreateReviewCommentMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.createReviewComment.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateReviewCaches(queryClient, keys);
+ },
+ })
+ );
+}
+
+export type SubmitReviewComment = {
+ path: string;
+ line: number;
+ side: 'LEFT' | 'RIGHT';
+ startLine?: number;
+ startSide?: 'LEFT' | 'RIGHT';
+ body: string;
+};
+
+export type SubmitReviewInput = {
+ owner: string;
+ repo: string;
+ number: number;
+ event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
+ body?: string;
+ commitSha: string;
+ comments?: SubmitReviewComment[];
+};
+
+export function useSubmitReviewMutation(ref: PrRef) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const keys = usePrRefKeys(ref);
+
+ return useMutation(
+ trpc.githubPrReview.submitReview.mutationOptions({
+ onError: (error: { message: string }) => {
+ toast.error(error.message);
+ },
+ onSettled: async () => {
+ await invalidateReviewCaches(queryClient, keys);
+ },
+ })
+ );
+}
diff --git a/apps/mobile/src/lib/pr-review/viewed-files.test.ts b/apps/mobile/src/lib/pr-review/viewed-files.test.ts
new file mode 100644
index 0000000000..0c526d38ef
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/viewed-files.test.ts
@@ -0,0 +1,143 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { clearViewedFiles, getViewedFiles, toggleViewedFile } from './viewed-files';
+
+const store = new Map();
+
+vi.mock('expo-secure-store', () => ({
+ getItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ return store.get(key) ?? null;
+ }),
+ setItemAsync: vi.fn(async (key: string, value: string) => {
+ await Promise.resolve();
+ store.set(key, value);
+ }),
+ deleteItemAsync: vi.fn(async (key: string) => {
+ await Promise.resolve();
+ store.delete(key);
+ }),
+}));
+
+vi.mock('@/lib/storage-keys', () => ({
+ PR_REVIEW_VIEWED_KEY: 'pr-review-viewed',
+}));
+
+beforeEach(() => {
+ store.clear();
+});
+
+const OWNER = 'octocat';
+const REPO = 'hello-world';
+const NUMBER = 42;
+const SHA1 = 'aaaaaaaaaaaaaaaa';
+const SHA2 = 'bbbbbbbbbbbbbbbb';
+const REF = { owner: OWNER, repo: REPO, number: NUMBER };
+
+function setStored(map: object) {
+ store.set('pr-review-viewed', JSON.stringify(map));
+}
+
+describe('viewed-files', () => {
+ it('returns an empty list when nothing has been stored', async () => {
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]);
+ });
+
+ it('returns the stored viewed paths when headSha matches', async () => {
+ setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts', 'b.ts'] } });
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['a.ts', 'b.ts']);
+ });
+
+ it('returns an empty list when headSha has changed (no leakage from previous SHA)', async () => {
+ setStored({ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['a.ts'] } });
+ await expect(getViewedFiles(REF, SHA2)).resolves.toEqual([]);
+ });
+
+ it('toggle adds a path when none was set for the current headSha', async () => {
+ setStored({});
+ await toggleViewedFile({ ...REF, headSha: SHA1, path: 'src/index.ts' });
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['src/index.ts']);
+ });
+
+ it('toggle removes a path when it is already viewed', async () => {
+ setStored({
+ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['src/index.ts', 'src/util.ts'] },
+ });
+ await toggleViewedFile({ ...REF, headSha: SHA1, path: 'src/index.ts' });
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['src/util.ts']);
+ });
+
+ it('toggle resets viewedPaths when headSha changes', async () => {
+ setStored({
+ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['old.ts', 'stale.ts'] },
+ });
+ await toggleViewedFile({ ...REF, headSha: SHA2, path: 'new.ts' });
+ await expect(getViewedFiles(REF, SHA2)).resolves.toEqual(['new.ts']);
+ // And the old SHA no longer leaks its viewed set.
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]);
+ });
+
+ it('caps the map at 20 PRs, evicting the least-recently-touched', async () => {
+ const seed: Record = {};
+ for (let index = 0; index < 20; index += 1) {
+ seed[`octocat/repo-${index}#1`] = { headSha: SHA1, viewedPaths: [] };
+ }
+ setStored(seed);
+ await toggleViewedFile({
+ owner: 'octocat',
+ repo: 'brand-new-repo',
+ number: 1,
+ headSha: SHA1,
+ path: 'a.ts',
+ });
+
+ // The newest entry exists with its single viewed file.
+ await expect(
+ getViewedFiles({ owner: 'octocat', repo: 'brand-new-repo', number: 1 }, SHA1)
+ ).resolves.toEqual(['a.ts']);
+ // The oldest (repo-0) was evicted; reading it yields an empty list.
+ await expect(
+ getViewedFiles({ owner: 'octocat', repo: 'repo-0', number: 1 }, SHA1)
+ ).resolves.toEqual([]);
+ // The most-recently-touched original (repo-19) is still present.
+ await expect(
+ getViewedFiles({ owner: 'octocat', repo: 'repo-19', number: 1 }, SHA1)
+ ).resolves.toEqual([]);
+ });
+
+ it('treats corrupt stored data as an empty map', async () => {
+ store.set('pr-review-viewed', '{not json');
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual([]);
+ });
+
+ it('drops structurally invalid entries (non-string headSha / non-array viewedPaths)', async () => {
+ setStored({
+ 'octocat/hello-world#42': { headSha: SHA1, viewedPaths: ['keep.ts'] },
+ 'octocat/bad-sha#1': { headSha: 123, viewedPaths: ['x'] },
+ 'octocat/bad-paths#2': { headSha: SHA1, viewedPaths: null },
+ 'octocat/bad-nested#3': { headSha: SHA1, viewedPaths: ['ok', 5] },
+ });
+ // The valid entry is preserved.
+ await expect(getViewedFiles(REF, SHA1)).resolves.toEqual(['keep.ts']);
+ // The malformed entries are discarded, not returned as non-arrays.
+ await expect(
+ getViewedFiles({ owner: 'octocat', repo: 'bad-paths', number: 2 }, SHA1)
+ ).resolves.toEqual([]);
+ // And toggling on top of malformed data does not throw.
+ await expect(
+ toggleViewedFile({
+ owner: 'octocat',
+ repo: 'bad-nested',
+ number: 3,
+ headSha: SHA1,
+ path: 'z.ts',
+ })
+ ).resolves.toBeUndefined();
+ });
+
+ it('clearViewedFiles deletes the storage key', async () => {
+ store.set('pr-review-viewed', 'placeholder');
+ await clearViewedFiles();
+ expect(store.has('pr-review-viewed')).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/viewed-files.ts b/apps/mobile/src/lib/pr-review/viewed-files.ts
new file mode 100644
index 0000000000..caa9ad8476
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/viewed-files.ts
@@ -0,0 +1,158 @@
+import * as SecureStore from 'expo-secure-store';
+
+import { PR_REVIEW_VIEWED_KEY } from '@/lib/storage-keys';
+
+type ViewedFileEntry = {
+ headSha: string;
+ viewedPaths: string[];
+};
+
+type ViewedFileMap = Record;
+
+const VIEWED_FILES_PR_LIMIT = 20;
+
+type ViewedFilePrRef = {
+ owner: string;
+ repo: string;
+ number: number;
+};
+
+// Same serialized write-queue pattern as last-active-instance and recent-prs.
+let writeQueue: Promise | null = null;
+
+async function enqueueWrite(op: () => Promise): Promise {
+ const previous = writeQueue;
+ const next = (async () => {
+ if (previous) {
+ try {
+ await previous;
+ } catch {
+ // An earlier failed write must not block the queue.
+ }
+ }
+ await op();
+ })();
+ writeQueue = next;
+ await next;
+}
+
+function viewedFilesKey(ref: ViewedFilePrRef): string {
+ return `${ref.owner.toLowerCase()}/${ref.repo.toLowerCase()}#${ref.number}`;
+}
+
+function isValidEntry(value: unknown): value is ViewedFileEntry {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+ const entry = value as Record;
+ return (
+ typeof entry.headSha === 'string' &&
+ Array.isArray(entry.viewedPaths) &&
+ entry.viewedPaths.every(path => typeof path === 'string')
+ );
+}
+
+function parseMap(raw: string | null): ViewedFileMap {
+ if (raw == null || raw.length === 0) {
+ return {};
+ }
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {};
+ }
+ // Drop any structurally invalid entry rather than trusting the cast, so
+ // one corrupt record can't make getViewedFiles return a non-array or
+ // make toggleViewedFile throw on `.includes`.
+ const result: ViewedFileMap = {};
+ for (const [key, value] of Object.entries(parsed as Record)) {
+ if (isValidEntry(value)) {
+ result[key] = { headSha: value.headSha, viewedPaths: value.viewedPaths };
+ }
+ }
+ return result;
+ } catch {
+ return {};
+ }
+}
+
+function toJsonString(map: ViewedFileMap): string {
+ return JSON.stringify(map);
+}
+
+function computeNextViewedPaths(
+ existing: ViewedFileEntry | undefined,
+ headSha: string,
+ path: string
+): string[] {
+ if (!existing || existing.headSha !== headSha) {
+ // Fresh PR or SHA changed: start a clean set with this one path.
+ return [path];
+ }
+ if (existing.viewedPaths.includes(path)) {
+ return existing.viewedPaths.filter(p => p !== path);
+ }
+ return [...existing.viewedPaths, path];
+}
+
+async function readMap(): Promise {
+ const raw = await SecureStore.getItemAsync(PR_REVIEW_VIEWED_KEY);
+ return parseMap(raw);
+}
+
+export async function getViewedFiles(ref: ViewedFilePrRef, headSha: string): Promise {
+ const map = await readMap();
+ const entry = map[viewedFilesKey(ref)];
+ if (!entry || entry.headSha !== headSha) {
+ return [];
+ }
+ return entry.viewedPaths;
+}
+
+/**
+ * Toggle a single file path in the viewed set for a PR. The record is keyed
+ * by `owner/repo#number`; when the incoming `headSha` differs from the
+ * stored one the viewedPaths are reset (file paths from a previous SHA are
+ * almost certainly stale and shouldn't be re-marked). The map itself is
+ * LRU-trimmed to VIEWED_FILES_PR_LIMIT PRs by most-recently-touched.
+ */
+type ToggleViewedFileInput = ViewedFilePrRef & {
+ headSha: string;
+ path: string;
+};
+
+export async function toggleViewedFile(input: ToggleViewedFileInput): Promise {
+ const { headSha, path } = input;
+ await enqueueWrite(async () => {
+ const map = await readMap();
+ const key = viewedFilesKey(input);
+ const existing = map[key];
+
+ const nextViewedPaths = computeNextViewedPaths(existing, headSha, path);
+
+ const nextEntry: ViewedFileEntry = { headSha, viewedPaths: nextViewedPaths };
+
+ // Re-insert the touched PR at the end of insertion order so we can
+ // trim oldest-first by Object.keys order.
+ const reordered: ViewedFileMap = {};
+ for (const [existingKey, value] of Object.entries(map)) {
+ if (existingKey !== key) {
+ reordered[existingKey] = value;
+ }
+ }
+ reordered[key] = nextEntry;
+
+ const trimmedEntries = Object.entries(reordered).slice(-VIEWED_FILES_PR_LIMIT);
+ const trimmed: ViewedFileMap = {};
+ for (const [trimmedKey, value] of trimmedEntries) {
+ trimmed[trimmedKey] = value;
+ }
+ await SecureStore.setItemAsync(PR_REVIEW_VIEWED_KEY, toJsonString(trimmed));
+ });
+}
+
+export async function clearViewedFiles(): Promise {
+ await enqueueWrite(async () => {
+ await SecureStore.deleteItemAsync(PR_REVIEW_VIEWED_KEY);
+ });
+}
diff --git a/apps/mobile/src/lib/profile-agent-navigation.ts b/apps/mobile/src/lib/profile-agent-navigation.ts
index 4508124320..115a75857e 100644
--- a/apps/mobile/src/lib/profile-agent-navigation.ts
+++ b/apps/mobile/src/lib/profile-agent-navigation.ts
@@ -22,3 +22,11 @@ export function getProfileAgentScope(
export function getCodeReviewerProfilePath(scope: string): Href {
return `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}` as Href;
}
+
+export function getPrReviewEntryPath(): Href {
+ return '/(app)/pr-review' as Href;
+}
+
+export function getPrReviewPath(owner: string, repo: string, number: number): Href {
+ return `/(app)/pr-review/${owner}/${repo}/${number}` as Href;
+}
diff --git a/apps/mobile/src/lib/storage-keys.ts b/apps/mobile/src/lib/storage-keys.ts
index c33ee361fa..8e354563e8 100644
--- a/apps/mobile/src/lib/storage-keys.ts
+++ b/apps/mobile/src/lib/storage-keys.ts
@@ -15,3 +15,5 @@ export const CONSENT_USER_KEY_PREFIX = 'consent-accepted-';
export const AGENT_MODEL_PREFERENCE_KEY = 'agent-model-preference';
export const REASONING_DEFAULT_EXPANDED_KEY = 'agent-reasoning-default-expanded';
export const REVIEW_REQUESTED_AT_KEY = 'store-review-requested-at';
+export const PR_REVIEW_RECENTS_KEY = 'pr-review-recents';
+export const PR_REVIEW_VIEWED_KEY = 'pr-review-viewed';
diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts
index eebcd19a60..b30a44500b 100644
--- a/apps/mobile/vitest.config.ts
+++ b/apps/mobile/vitest.config.ts
@@ -39,6 +39,7 @@ export default defineConfig({
'src/lib/kilo-pass/**/*.test.ts',
'src/lib/kilo-pass/**/*.test.tsx',
'src/lib/onboarding/**/*.test.ts',
+ 'src/lib/pr-review/**/*.test.ts',
'src/lib/voice-input/**/*.test.ts',
'src/components/**/*.test.ts',
],
diff --git a/apps/web/src/lib/github-pr-review/client.ts b/apps/web/src/lib/github-pr-review/client.ts
new file mode 100644
index 0000000000..aa530fe20c
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/client.ts
@@ -0,0 +1,20 @@
+import 'server-only';
+
+import { Octokit } from '@octokit/rest';
+
+/**
+ * Env seam used to point the GitHub API client at a deterministic test
+ * fixture (e.g. a local mock server in E2E). When unset, calls hit the real
+ * `api.github.com`. GraphQL requests are issued with `octokit.request('POST
+ * /graphql', …)`, which uses Octokit's `baseUrl` to derive the GraphQL URL —
+ * so the same env value covers REST and GraphQL.
+ */
+export const GITHUB_API_BASE_URL = process.env.GITHUB_API_BASE_URL ?? 'https://api.github.com';
+
+export function createGitHubPrReviewOctokit(token: string): Octokit {
+ return new Octokit({
+ auth: token,
+ baseUrl: GITHUB_API_BASE_URL,
+ userAgent: 'kilo-mobile-github-pr-review',
+ });
+}
diff --git a/apps/web/src/lib/github-pr-review/dev-seed.test.ts b/apps/web/src/lib/github-pr-review/dev-seed.test.ts
new file mode 100644
index 0000000000..da91672a07
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/dev-seed.test.ts
@@ -0,0 +1,131 @@
+/**
+ * @jest-environment node
+ */
+
+const mockEncryptKeyedEnvelope = jest.fn(
+ (value: string, _scheme: string, _key: unknown, aad: string) => `envelope:${value}:${aad}`
+);
+
+const insertValuesMock = jest.fn().mockReturnThis();
+const onConflictMock = jest.fn().mockReturnThis();
+const returningMock = jest.fn();
+const insertChain = {
+ values: insertValuesMock,
+ onConflictDoUpdate: onConflictMock,
+ returning: returningMock,
+};
+const insertMock = jest.fn(() => insertChain);
+const dbMock = { insert: insertMock };
+
+jest.mock('@/lib/drizzle', () => ({
+ get db() {
+ return dbMock;
+ },
+}));
+
+jest.mock('@/lib/config.server', () => ({
+ get USER_GITHUB_APP_TOKEN_ACTIVE_KEY_ID() {
+ return 'github-token-key-v1';
+ },
+ get USER_GITHUB_APP_TOKEN_ACTIVE_PUBLIC_KEY() {
+ return Buffer.from('test-public-key').toString('base64');
+ },
+}));
+
+jest.mock('@/lib/encryption', () => ({
+ encryptKeyedEnvelope: (...args: [string, string, unknown, string]) =>
+ mockEncryptKeyedEnvelope(...args),
+}));
+
+import { seedUserGithubToken } from './dev-seed';
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ insertValuesMock.mockReturnThis();
+ onConflictMock.mockReturnThis();
+ returningMock.mockResolvedValue([{ id: 'row-1' }]);
+});
+
+describe('seedUserGithubToken', () => {
+ it('encrypts the token twice with the matching AADs and upserts a standard row', async () => {
+ const result = await seedUserGithubToken({
+ kiloUserId: 'user-1',
+ token: 'fake-token',
+ githubLogin: 'octocat',
+ githubUserId: '42',
+ });
+
+ expect(result).toEqual({ upserted: true, githubLogin: 'octocat' });
+
+ // Two encryption calls — one for access, one for refresh — each with the
+ // AAD the real authorization path produces.
+ expect(mockEncryptKeyedEnvelope).toHaveBeenCalledTimes(2);
+ expect(mockEncryptKeyedEnvelope).toHaveBeenNthCalledWith(
+ 1,
+ 'fake-token',
+ 'github-user-token-rsa-aes-256-gcm',
+ expect.objectContaining({ keyId: 'github-token-key-v1' }),
+ 'github-user-authorization:v1:user-1:standard:42:access'
+ );
+ expect(mockEncryptKeyedEnvelope).toHaveBeenNthCalledWith(
+ 2,
+ 'fake-token',
+ 'github-user-token-rsa-aes-256-gcm',
+ expect.objectContaining({ keyId: 'github-token-key-v1' }),
+ 'github-user-authorization:v1:user-1:standard:42:refresh'
+ );
+ });
+
+ it('persists a standard row with far-future expiries and revoked-at null', async () => {
+ await seedUserGithubToken({
+ kiloUserId: 'user-1',
+ token: 'fake-token',
+ githubLogin: 'octocat',
+ githubUserId: '42',
+ });
+
+ const passedValues = insertValuesMock.mock.calls[0]?.[0];
+ expect(passedValues).toEqual(
+ expect.objectContaining({
+ kilo_user_id: 'user-1',
+ github_app_type: 'standard',
+ github_user_id: '42',
+ github_login: 'octocat',
+ access_token_expires_at: '9999-12-31T23:59:59.000Z',
+ refresh_token_expires_at: '9999-12-31T23:59:59.000Z',
+ revoked_at: null,
+ revocation_reason: null,
+ })
+ );
+ expect(passedValues.access_token_encrypted).toContain('access');
+ expect(passedValues.refresh_token_encrypted).toContain('refresh');
+ });
+
+ it('upserts on the (kilo_user_id, github_app_type) unique index', async () => {
+ await seedUserGithubToken({
+ kiloUserId: 'user-1',
+ token: 'fake-token',
+ githubLogin: 'octocat',
+ githubUserId: '42',
+ });
+
+ // The target is a composite (kilo_user_id, github_app_type); we don't
+ // reach into the column definitions, just assert the conflict clause was
+ // set so the row gets upserted instead of failing on duplicate-key.
+ const onConflictArg = onConflictMock.mock.calls[0]?.[0];
+ expect(onConflictArg).toBeDefined();
+ expect(onConflictArg.set).toBeDefined();
+ expect(onConflictArg.setWhere).toBeDefined();
+ });
+
+ it('returns upserted=false when no row is returned', async () => {
+ returningMock.mockResolvedValueOnce([]);
+ const result = await seedUserGithubToken({
+ kiloUserId: 'user-1',
+ token: 'fake-token',
+ githubLogin: 'octocat',
+ githubUserId: '42',
+ });
+ expect(result.upserted).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/github-pr-review/dev-seed.ts b/apps/web/src/lib/github-pr-review/dev-seed.ts
new file mode 100644
index 0000000000..e6810b3a62
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/dev-seed.ts
@@ -0,0 +1,84 @@
+import 'server-only';
+
+import { eq, sql } from 'drizzle-orm';
+import { user_github_app_tokens } from '@kilocode/db/schema';
+
+import { db } from '@/lib/drizzle';
+// Single source of truth for the envelope scheme, active key, and AAD. Reusing
+// the production helper guarantees the seeded row stays decryptable by
+// git-token-service even if the scheme/AAD ever changes.
+import { encryptUserGithubTokenEnvelope } from '@/lib/integrations/platforms/github/user-token-envelope';
+
+// The dev seed writes a far-future expiry so E2E never hits a "token expired"
+// branch while the suite is running. YEAR 9999 is the same convention the
+// schema fixtures use for non-expiring credentials.
+const FAR_FUTURE_ISO = '9999-12-31T23:59:59.000Z';
+
+export type SeedUserGithubTokenResult = {
+ upserted: boolean;
+ githubLogin: string;
+};
+
+/**
+ * Dev-only helper. Encrypts the supplied FAKE token with the same public-key
+ * envelope the real OAuth callback uses, then upserts a `standard`
+ * `user_github_app_tokens` row keyed on `kiloUserId`. The same `token` is
+ * used for both access and refresh — a fake token only needs to authenticate
+ * against the local mock GitHub server.
+ *
+ * The single-source-of-truth: this is the path the dev E2E suite relies on to
+ * skip the real OAuth round-trip.
+ */
+export async function seedUserGithubToken(input: {
+ kiloUserId: string;
+ token: string;
+ githubLogin: string;
+ githubUserId: string;
+}): Promise {
+ const values = {
+ kilo_user_id: input.kiloUserId,
+ github_app_type: 'standard' as const,
+ github_user_id: input.githubUserId,
+ github_login: input.githubLogin,
+ access_token_encrypted: encryptUserGithubTokenEnvelope(input.token, {
+ kiloUserId: input.kiloUserId,
+ githubUserId: input.githubUserId,
+ tokenType: 'access',
+ }),
+ access_token_expires_at: FAR_FUTURE_ISO,
+ refresh_token_encrypted: encryptUserGithubTokenEnvelope(input.token, {
+ kiloUserId: input.kiloUserId,
+ githubUserId: input.githubUserId,
+ tokenType: 'refresh',
+ }),
+ refresh_token_expires_at: FAR_FUTURE_ISO,
+ revoked_at: null,
+ revocation_reason: null,
+ };
+
+ const [stored] = await db
+ .insert(user_github_app_tokens)
+ .values(values)
+ .onConflictDoUpdate({
+ target: [user_github_app_tokens.kilo_user_id, user_github_app_tokens.github_app_type],
+ set: {
+ github_user_id: values.github_user_id,
+ github_login: values.github_login,
+ access_token_encrypted: values.access_token_encrypted,
+ access_token_expires_at: values.access_token_expires_at,
+ refresh_token_encrypted: values.refresh_token_encrypted,
+ refresh_token_expires_at: values.refresh_token_expires_at,
+ revoked_at: null,
+ revocation_reason: null,
+ credential_version: sql`${user_github_app_tokens.credential_version} + 1`,
+ updated_at: new Date().toISOString(),
+ },
+ setWhere: eq(user_github_app_tokens.github_user_id, input.githubUserId),
+ })
+ .returning({ id: user_github_app_tokens.id });
+
+ return {
+ upserted: Boolean(stored?.id),
+ githubLogin: input.githubLogin,
+ };
+}
diff --git a/apps/web/src/lib/github-pr-review/dtos.ts b/apps/web/src/lib/github-pr-review/dtos.ts
new file mode 100644
index 0000000000..40d13b1618
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/dtos.ts
@@ -0,0 +1,164 @@
+import 'server-only';
+
+import { z } from 'zod';
+
+export const GitHubPrReviewAuthorSchema = z
+ .object({
+ login: z.string().min(1),
+ avatarUrl: z.string().url().nullable(),
+ })
+ .strict();
+
+export const GitHubPrReviewOverviewSchema = z
+ .object({
+ number: z.number().int().positive(),
+ title: z.string(),
+ bodyMarkdown: z.string().nullable(),
+ author: GitHubPrReviewAuthorSchema.nullable(),
+ state: z.enum(['open', 'closed', 'merged']),
+ draft: z.boolean(),
+ baseRef: z.string(),
+ headRef: z.string(),
+ isCrossRepo: z.boolean(),
+ headRepoFullName: z.string().nullable(),
+ headSha: z.string().min(1),
+ prNodeId: z.string().min(1),
+ counts: z
+ .object({
+ commits: z.number().int().nonnegative(),
+ changedFiles: z.number().int().nonnegative(),
+ additions: z.number().int().nonnegative(),
+ deletions: z.number().int().nonnegative(),
+ })
+ .strict(),
+ mergeable: z.boolean().nullable(),
+ mergeableState: z.string().nullable(),
+ autoMerge: z
+ .object({
+ method: z.string(),
+ })
+ .nullable(),
+ reviewDecision: z.enum(['REVIEW_REQUIRED', 'APPROVED', 'CHANGES_REQUESTED']).nullable(),
+ repo: z
+ .object({
+ allowMergeCommit: z.boolean(),
+ allowSquashMerge: z.boolean(),
+ allowRebaseMerge: z.boolean(),
+ allowAutoMerge: z.boolean(),
+ deleteBranchOnMerge: z.boolean(),
+ allowUpdateBranch: z.boolean(),
+ viewerCanPush: z.boolean(),
+ viewerCanAdmin: z.boolean(),
+ viewerLogin: z.string().nullable(),
+ })
+ .strict(),
+ })
+ .strict();
+export type GitHubPrReviewOverview = z.infer;
+
+export const GitHubPrReviewCheckRunSchema = z
+ .object({
+ name: z.string().min(1),
+ status: z.string(),
+ conclusion: z.string().nullable(),
+ detailsUrl: z.string().url().nullable(),
+ appName: z.string().nullable(),
+ })
+ .strict();
+
+export const GitHubPrReviewChecksResultSchema = z
+ .object({
+ checkRuns: z.array(GitHubPrReviewCheckRunSchema),
+ rollup: z
+ .object({
+ total: z.number().int().nonnegative(),
+ success: z.number().int().nonnegative(),
+ failure: z.number().int().nonnegative(),
+ pending: z.number().int().nonnegative(),
+ skipped: z.number().int().nonnegative(),
+ })
+ .strict(),
+ })
+ .strict();
+export type GitHubPrReviewChecksResult = z.infer;
+
+export const GitHubPrReviewFileSchema = z
+ .object({
+ path: z.string().min(1),
+ previousPath: z.string().nullable(),
+ status: z.string(),
+ additions: z.number().int().nonnegative(),
+ deletions: z.number().int().nonnegative(),
+ patch: z.string().nullable(),
+ patchMissing: z.boolean(),
+ })
+ .strict();
+export type GitHubPrReviewFile = z.infer;
+
+export const GitHubPrReviewFilesResultSchema = z
+ .object({
+ files: z.array(GitHubPrReviewFileSchema),
+ nextCursor: z.number().int().nullable(),
+ })
+ .strict();
+export type GitHubPrReviewFilesResult = z.infer;
+
+export const GitHubPrReviewFileLinesResultSchema = z
+ .object({
+ lines: z.array(z.string()),
+ totalLines: z.number().int().nonnegative(),
+ })
+ .strict();
+export type GitHubPrReviewFileLinesResult = z.infer;
+
+export const GitHubPrReviewReactionSchema = z
+ .object({
+ content: z.string(),
+ count: z.number().int().nonnegative(),
+ viewerHasReacted: z.boolean(),
+ })
+ .strict();
+
+export const GitHubPrReviewReviewCommentSchema = z
+ .object({
+ commentId: z.number().int().positive(),
+ nodeId: z.string().min(1),
+ author: GitHubPrReviewAuthorSchema.nullable(),
+ bodyMarkdown: z.string(),
+ createdAt: z.string(),
+ reactions: z.array(GitHubPrReviewReactionSchema),
+ })
+ .strict();
+
+export const GitHubPrReviewReviewThreadSchema = z
+ .object({
+ threadId: z.string().min(1),
+ isResolved: z.boolean(),
+ isOutdated: z.boolean(),
+ subjectType: z.enum(['LINE', 'FILE']),
+ path: z.string().nullable(),
+ line: z.number().int().nullable(),
+ startLine: z.number().int().nullable(),
+ originalLine: z.number().int().nullable(),
+ originalStartLine: z.number().int().nullable(),
+ diffSide: z.enum(['LEFT', 'RIGHT']).nullable(),
+ comments: z.array(GitHubPrReviewReviewCommentSchema),
+ })
+ .strict();
+export type GitHubPrReviewReviewThread = z.infer;
+
+export const GitHubPrReviewReviewThreadsResultSchema = z
+ .object({
+ threads: z.array(GitHubPrReviewReviewThreadSchema),
+ nextCursor: z.string().nullable(),
+ })
+ .strict();
+export type GitHubPrReviewReviewThreadsResult = z.infer<
+ typeof GitHubPrReviewReviewThreadsResultSchema
+>;
+
+export const FILES_PAGE_SIZE = 50;
+export const FILES_MAX_PAGES = 60;
+export const FILE_LINES_MAX = 500;
+export const REVIEW_THREADS_PAGE_SIZE = 50;
+export const REVIEW_THREAD_COMMENTS_PAGE_SIZE = 50;
diff --git a/apps/web/src/lib/github-pr-review/errors.test.ts b/apps/web/src/lib/github-pr-review/errors.test.ts
new file mode 100644
index 0000000000..42b935287d
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/errors.test.ts
@@ -0,0 +1,192 @@
+import { classifyGitHubHttpError, classifyGitHubGraphQlErrors } from './errors';
+
+function httpError(
+ status: number,
+ message: string,
+ headers?: Record
+): Error & {
+ status: number;
+ response?: { headers?: Record };
+} {
+ const err = new Error(message) as Error & {
+ status: number;
+ response?: { headers?: Record };
+ };
+ err.status = status;
+ if (headers) {
+ err.response = { headers };
+ }
+ return err;
+}
+
+describe('classifyGitHubHttpError', () => {
+ it('maps 404 to NOT_FOUND with the documented message', () => {
+ const result = classifyGitHubHttpError(httpError(404, 'Not Found'));
+ expect(result.code).toBe('NOT_FOUND');
+ expect(result.message).toBe(
+ "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository"
+ );
+ });
+
+ it('maps 401 to PRECONDITION_FAILED with the reconnect message', () => {
+ const result = classifyGitHubHttpError(httpError(401, 'Bad credentials'));
+ expect(result.code).toBe('PRECONDITION_FAILED');
+ expect(result.message).toBe('GitHub connection is no longer valid — reconnect');
+ });
+
+ const NOW = 1_700_000_000_000;
+
+ it('maps 403 with x-ratelimit-remaining:0 to TOO_MANY_REQUESTS with an absolute reset epoch', () => {
+ const resetSeconds = Math.floor(NOW / 1000) + 600;
+ const result = classifyGitHubHttpError(
+ httpError(403, 'API rate limit exceeded', {
+ 'x-ratelimit-remaining': '0',
+ 'x-ratelimit-reset': String(resetSeconds),
+ }),
+ NOW
+ );
+ expect(result.code).toBe('TOO_MANY_REQUESTS');
+ // Absolute epoch (ms), not a relative delay.
+ expect(result.retryAfterEpochMs).toBe(resetSeconds * 1000);
+ expect(result.message).toMatch(/Try again in about 10 minutes/);
+ });
+
+ it('maps 403 retry-after (delta seconds) to an absolute epoch relative to now', () => {
+ const result = classifyGitHubHttpError(
+ httpError(403, 'Secondary rate limit', { 'retry-after': '120' }),
+ NOW
+ );
+ expect(result.code).toBe('TOO_MANY_REQUESTS');
+ expect(result.retryAfterEpochMs).toBe(NOW + 120_000);
+ });
+
+ it('maps 429 to TOO_MANY_REQUESTS regardless of headers', () => {
+ const result = classifyGitHubHttpError(httpError(429, 'Too Many Requests'));
+ expect(result.code).toBe('TOO_MANY_REQUESTS');
+ });
+
+ it('maps non-rate-limit 403 to FORBIDDEN preserving GitHub message', () => {
+ const result = classifyGitHubHttpError(
+ httpError(403, 'Resource not accessible by integration')
+ );
+ expect(result.code).toBe('FORBIDDEN');
+ expect(result.message).toBe('Resource not accessible by integration');
+ });
+
+ it('maps 422 stale line comment shape to BAD_REQUEST', () => {
+ const result = classifyGitHubHttpError(
+ httpError(422, 'Validation Failed: "path" wasn\'t supplied')
+ );
+ expect(result.code).toBe('BAD_REQUEST');
+ expect(result.message).toContain("wasn't supplied");
+ });
+
+ it('maps 422 approve-own-PR shape to BAD_REQUEST', () => {
+ const result = classifyGitHubHttpError(
+ httpError(422, 'Validation Failed: You cannot approve your own pull request')
+ );
+ expect(result.code).toBe('BAD_REQUEST');
+ expect(result.message).toContain('approve your own');
+ });
+
+ it('maps 422 review-submit shape to BAD_REQUEST', () => {
+ const result = classifyGitHubHttpError(
+ httpError(422, 'Validation Failed: Pull request review thread lock failed')
+ );
+ expect(result.code).toBe('BAD_REQUEST');
+ });
+
+ it('maps 422 update-branch expected_head_sha mismatch to BAD_REQUEST', () => {
+ const result = classifyGitHubHttpError(
+ httpError(422, "expected head sha didn't match current head ref")
+ );
+ expect(result.code).toBe('BAD_REQUEST');
+ expect(result.message).toContain('expected head sha');
+ });
+
+ it('maps 422 merge validation to BAD_REQUEST', () => {
+ const result = classifyGitHubHttpError(
+ httpError(422, 'Merge commits are not allowed on this repository')
+ );
+ expect(result.code).toBe('BAD_REQUEST');
+ expect(result.message).toContain('Merge commits');
+ });
+
+ it('maps 405 to CONFLICT with GitHub message', () => {
+ const result = classifyGitHubHttpError(
+ httpError(405, '405 Method Not Allowed: Merge method not allowed')
+ );
+ expect(result.code).toBe('CONFLICT');
+ expect(result.message).toContain('Merge method not allowed');
+ });
+
+ it('maps 409 to CONFLICT with GitHub message', () => {
+ const result = classifyGitHubHttpError(
+ httpError(409, 'Merge conflict: HEAD is not a fast-forward')
+ );
+ expect(result.code).toBe('CONFLICT');
+ expect(result.message).toContain('Merge conflict');
+ });
+
+ it('maps 5xx to BAD_GATEWAY with a human message', () => {
+ const result = classifyGitHubHttpError(httpError(502, 'Bad Gateway'));
+ expect(result.code).toBe('BAD_GATEWAY');
+ });
+
+ it('falls back to BAD_GATEWAY for non-Error inputs', () => {
+ expect(classifyGitHubHttpError('oops').code).toBe('BAD_GATEWAY');
+ expect(classifyGitHubHttpError(null).code).toBe('BAD_GATEWAY');
+ expect(classifyGitHubHttpError(undefined).code).toBe('BAD_GATEWAY');
+ });
+});
+
+describe('classifyGitHubGraphQlErrors', () => {
+ it('returns null when there are no errors', () => {
+ expect(classifyGitHubGraphQlErrors(undefined)).toBeNull();
+ expect(classifyGitHubGraphQlErrors([])).toBeNull();
+ });
+
+ it('maps GraphQL NOT_FOUND to NOT_FOUND', () => {
+ const result = classifyGitHubGraphQlErrors([
+ { type: 'NOT_FOUND', message: 'Could not resolve' },
+ ]);
+ expect(result?.code).toBe('NOT_FOUND');
+ expect(result?.message).toBe(
+ "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository"
+ );
+ });
+
+ it('maps GraphQL FORBIDDEN to FORBIDDEN preserving message', () => {
+ const result = classifyGitHubGraphQlErrors([
+ { type: 'FORBIDDEN', message: 'Resource not accessible by integration' },
+ ]);
+ expect(result?.code).toBe('FORBIDDEN');
+ expect(result?.message).toBe('Resource not accessible by integration');
+ });
+
+ it('maps GraphQL RATE_LIMITED to TOO_MANY_REQUESTS', () => {
+ const result = classifyGitHubGraphQlErrors([{ type: 'RATE_LIMITED', message: 'rate limit' }]);
+ expect(result?.code).toBe('TOO_MANY_REQUESTS');
+ });
+
+ it('maps GraphQL SECONDARY_RATE_LIMIT to TOO_MANY_REQUESTS', () => {
+ const result = classifyGitHubGraphQlErrors([
+ { type: 'SECONDARY_RATE_LIMIT', message: 'abuse' },
+ ]);
+ expect(result?.code).toBe('TOO_MANY_REQUESTS');
+ });
+
+ it('falls back to BAD_GATEWAY for unknown GraphQL error types', () => {
+ const result = classifyGitHubGraphQlErrors([{ type: 'INTERNAL', message: 'boom' }]);
+ expect(result?.code).toBe('BAD_GATEWAY');
+ expect(result?.message).toBe('boom');
+ });
+
+ it('only considers the first error in the array', () => {
+ const result = classifyGitHubGraphQlErrors([
+ { type: 'NOT_FOUND', message: 'first' },
+ { type: 'FORBIDDEN', message: 'second' },
+ ]);
+ expect(result?.code).toBe('NOT_FOUND');
+ });
+});
diff --git a/apps/web/src/lib/github-pr-review/errors.ts b/apps/web/src/lib/github-pr-review/errors.ts
new file mode 100644
index 0000000000..5fd49f8cf6
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/errors.ts
@@ -0,0 +1,168 @@
+import 'server-only';
+
+export type GitHubPrReviewErrorCode =
+ | 'NOT_FOUND'
+ | 'PRECONDITION_FAILED'
+ | 'TOO_MANY_REQUESTS'
+ | 'FORBIDDEN'
+ | 'BAD_REQUEST'
+ | 'CONFLICT'
+ | 'BAD_GATEWAY';
+
+export type ClassifiedGitHubError = {
+ code: GitHubPrReviewErrorCode;
+ message: string;
+ retryAfterEpochMs?: number;
+};
+
+type HeaderRecord = Record | undefined;
+
+function getHeader(headers: HeaderRecord, name: string): string | undefined {
+ if (!headers) return undefined;
+ const direct = headers[name];
+ if (typeof direct === 'string') return direct;
+ if (Array.isArray(direct)) return direct[0];
+ const lower = headers[name.toLowerCase()];
+ if (typeof lower === 'string') return lower;
+ if (Array.isArray(lower)) return lower[0];
+ return undefined;
+}
+
+function getErrorMessage(error: unknown): string {
+ if (typeof error !== 'object' || error === null) return '';
+ const message = (error as { message?: unknown }).message;
+ return typeof message === 'string' ? message : '';
+}
+
+type OctokitHttpError = {
+ status: number;
+ message: string;
+ response?: { headers?: HeaderRecord; data?: unknown };
+};
+
+function isOctokitHttpError(error: unknown): error is OctokitHttpError {
+ if (typeof error !== 'object' || error === null) return false;
+ const status = (error as { status?: unknown }).status;
+ return typeof status === 'number';
+}
+
+function isRateLimitHeaders(headers: HeaderRecord): boolean {
+ const remaining = getHeader(headers, 'x-ratelimit-remaining');
+ if (remaining === '0') return true;
+ return Boolean(getHeader(headers, 'retry-after'));
+}
+
+// Absolute epoch (ms) at which the caller may retry, derived purely from the
+// headers plus the supplied `now` (so the classifier stays deterministic and
+// testable). `x-ratelimit-reset` is an absolute epoch in seconds; `retry-after`
+// is either a delta in seconds (relative to `now`) or an HTTP date.
+function retryAfterEpochMs(headers: HeaderRecord, now: number): number | undefined {
+ const reset = getHeader(headers, 'x-ratelimit-reset');
+ if (reset) {
+ const seconds = Number(reset);
+ if (Number.isFinite(seconds) && seconds > 0) {
+ return seconds * 1000;
+ }
+ }
+ const retryAfter = getHeader(headers, 'retry-after');
+ if (retryAfter) {
+ const seconds = Number(retryAfter);
+ if (Number.isFinite(seconds) && seconds >= 0) {
+ return now + seconds * 1000;
+ }
+ const date = Date.parse(retryAfter);
+ if (Number.isFinite(date)) {
+ return date;
+ }
+ }
+ return undefined;
+}
+
+function rateLimitMessage(headers: HeaderRecord, now: number): string {
+ const resetAt = retryAfterEpochMs(headers, now);
+ if (resetAt === undefined) {
+ return 'GitHub rate limit reached. Please try again later.';
+ }
+ const minutes = Math.max(1, Math.round((resetAt - now) / 60_000));
+ return `GitHub rate limit reached. Try again in about ${minutes} minute${minutes === 1 ? '' : 's'}.`;
+}
+
+const NOT_FOUND_MESSAGE =
+ "PR not found, you don't have access, or the Kilo GitHub App isn't installed for this repository";
+const PRECONDITION_FAILED_MESSAGE = 'GitHub connection is no longer valid — reconnect';
+const FALLBACK_FORBIDDEN = 'You do not have permission to perform this action on this PR';
+const FALLBACK_BAD_REQUEST = 'GitHub rejected this request';
+const FALLBACK_CONFLICT = 'GitHub reported a conflict for this PR';
+const FALLBACK_BAD_GATEWAY = 'GitHub returned an unexpected error';
+
+export function classifyGitHubHttpError(
+ error: unknown,
+ now: number = Date.now()
+): ClassifiedGitHubError {
+ if (!isOctokitHttpError(error)) {
+ return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY };
+ }
+ const status = error.status;
+ const message = getErrorMessage(error) || FALLBACK_BAD_GATEWAY;
+ const headers = error.response?.headers;
+
+ if (status === 404) {
+ return { code: 'NOT_FOUND', message: NOT_FOUND_MESSAGE };
+ }
+ if (status === 401) {
+ return { code: 'PRECONDITION_FAILED', message: PRECONDITION_FAILED_MESSAGE };
+ }
+ if (status === 422) {
+ return { code: 'BAD_REQUEST', message: message || FALLBACK_BAD_REQUEST };
+ }
+ if (status === 405 || status === 409) {
+ return { code: 'CONFLICT', message: message || FALLBACK_CONFLICT };
+ }
+ if (status === 403 || status === 429) {
+ if (status === 429 || (headers && isRateLimitHeaders(headers))) {
+ return {
+ code: 'TOO_MANY_REQUESTS',
+ message: rateLimitMessage(headers, now),
+ retryAfterEpochMs: retryAfterEpochMs(headers, now),
+ };
+ }
+ return { code: 'FORBIDDEN', message: message || FALLBACK_FORBIDDEN };
+ }
+ if (status >= 500) {
+ return { code: 'BAD_GATEWAY', message: message || FALLBACK_BAD_GATEWAY };
+ }
+ if (status >= 400) {
+ return { code: 'BAD_REQUEST', message: message || FALLBACK_BAD_REQUEST };
+ }
+ return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY };
+}
+
+export type GraphQlErrorEntry = { type?: string; message?: string };
+
+function classifyGraphQlEntry(entry: GraphQlErrorEntry, now: number): ClassifiedGitHubError {
+ const type = (entry.type ?? '').toUpperCase();
+ const message = entry.message?.trim();
+ if (type === 'NOT_FOUND') {
+ return { code: 'NOT_FOUND', message: NOT_FOUND_MESSAGE };
+ }
+ if (type === 'FORBIDDEN') {
+ return { code: 'FORBIDDEN', message: message || FALLBACK_FORBIDDEN };
+ }
+ if (type === 'RATE_LIMITED' || type === 'SECONDARY_RATE_LIMIT' || type === 'ABUSE_DETECTION') {
+ return { code: 'TOO_MANY_REQUESTS', message: rateLimitMessage(undefined, now) };
+ }
+ if (message) {
+ return { code: 'BAD_GATEWAY', message };
+ }
+ return { code: 'BAD_GATEWAY', message: FALLBACK_BAD_GATEWAY };
+}
+
+export function classifyGitHubGraphQlErrors(
+ errors: ReadonlyArray | undefined,
+ now: number = Date.now()
+): ClassifiedGitHubError | null {
+ if (!errors || errors.length === 0) return null;
+ const first = errors[0];
+ if (!first) return null;
+ return classifyGraphQlEntry(first, now);
+}
diff --git a/apps/web/src/lib/github-pr-review/mappers.test.ts b/apps/web/src/lib/github-pr-review/mappers.test.ts
new file mode 100644
index 0000000000..b32b41235a
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/mappers.test.ts
@@ -0,0 +1,363 @@
+import {
+ buildChecksResult,
+ buildFilesPage,
+ buildOverviewDto,
+ buildReviewThreadsResult,
+ sliceFileLines,
+} from './mappers';
+import { FILES_MAX_PAGES } from './dtos';
+
+describe('buildOverviewDto', () => {
+ const basePr = {
+ number: 12,
+ title: 'Fix the flux capacitor',
+ body: 'It was broken',
+ user: { login: 'octocat', avatar_url: 'https://avatars.example/octocat' },
+ state: 'open' as const,
+ draft: false,
+ base: { ref: 'main', repo: { full_name: 'kilo/flux' } },
+ head: { ref: 'feature/fix', sha: 'abc123', repo: { full_name: 'kilo/flux' } },
+ node_id: 'PR_node',
+ commits: 3,
+ changed_files: 5,
+ additions: 100,
+ deletions: 20,
+ mergeable: true,
+ mergeable_state: 'clean',
+ auto_merge: { merge_method: 'squash' },
+ };
+
+ it('returns overview DTO with all required fields populated', () => {
+ const dto = buildOverviewDto({
+ pr: basePr,
+ repo: {
+ allow_merge_commit: true,
+ allow_squash_merge: true,
+ allow_rebase_merge: true,
+ allow_auto_merge: true,
+ delete_branch_on_merge: true,
+ allow_update_branch: true,
+ permissions: { push: true, admin: false },
+ },
+ graphQl: {
+ repository: { pullRequest: { reviewDecision: 'APPROVED' } },
+ viewer: { login: 'octocat' },
+ },
+ viewer: { login: 'octocat' },
+ });
+ expect(dto.title).toBe('Fix the flux capacitor');
+ expect(dto.state).toBe('open');
+ expect(dto.draft).toBe(false);
+ expect(dto.reviewDecision).toBe('APPROVED');
+ expect(dto.autoMerge).toEqual({ method: 'squash' });
+ expect(dto.isCrossRepo).toBe(false);
+ expect(dto.headRepoFullName).toBe('kilo/flux');
+ expect(dto.repo.viewerCanPush).toBe(true);
+ expect(dto.repo.viewerCanAdmin).toBe(false);
+ expect(dto.repo.viewerLogin).toBe('octocat');
+ });
+
+ it('maps merged PR to "merged" state regardless of GitHub state', () => {
+ const dto = buildOverviewDto({
+ pr: { ...basePr, merged: true, state: 'closed' },
+ repo: {},
+ graphQl: null,
+ viewer: null,
+ });
+ expect(dto.state).toBe('merged');
+ });
+
+ it('flags cross-repo PRs when head and base differ', () => {
+ const dto = buildOverviewDto({
+ pr: {
+ ...basePr,
+ head: { ref: 'feature/fix', sha: 'abc123', repo: { full_name: 'octocat/flux' } },
+ },
+ repo: {},
+ graphQl: null,
+ viewer: null,
+ });
+ expect(dto.isCrossRepo).toBe(true);
+ expect(dto.headRepoFullName).toBe('octocat/flux');
+ });
+
+ it('handles missing author and reviewDecision', () => {
+ const dto = buildOverviewDto({
+ pr: { ...basePr, user: null },
+ repo: {},
+ graphQl: { repository: { pullRequest: null }, viewer: null },
+ viewer: null,
+ });
+ expect(dto.author).toBeNull();
+ expect(dto.reviewDecision).toBeNull();
+ expect(dto.repo.viewerLogin).toBeNull();
+ });
+});
+
+describe('buildChecksResult', () => {
+ it('merges check runs and dedupes commit statuses to the latest per context', () => {
+ const result = buildChecksResult({
+ checkRuns: [
+ {
+ name: 'ci',
+ status: 'completed',
+ conclusion: 'success',
+ details_url: 'https://example.com/a',
+ app: { name: 'GitHub Actions' },
+ },
+ ],
+ commitStatuses: [
+ {
+ context: 'codecov',
+ state: 'success',
+ target_url: 'https://codecov.example/r1',
+ updated_at: '2026-01-01T00:00:00Z',
+ },
+ {
+ context: 'codecov',
+ state: 'failure',
+ target_url: 'https://codecov.example/r2',
+ updated_at: '2026-01-02T00:00:00Z',
+ },
+ { context: 'lint', state: 'success', target_url: null, updated_at: null },
+ ],
+ });
+ expect(result.checkRuns).toHaveLength(3);
+ const codecov = result.checkRuns.find(c => c.name === 'codecov');
+ expect(codecov?.conclusion).toBe('failure');
+ expect(codecov?.detailsUrl).toBe('https://codecov.example/r2');
+ expect(result.rollup.total).toBe(3);
+ expect(result.rollup.success).toBe(2);
+ expect(result.rollup.failure).toBe(1);
+ });
+
+ it('counts pending commit statuses and in-progress/null check runs in the pending bucket', () => {
+ const result = buildChecksResult({
+ checkRuns: [
+ { name: 'build', status: 'in_progress', conclusion: null },
+ { name: 'lint', status: 'completed', conclusion: null },
+ { name: 'test', status: 'completed', conclusion: 'success' },
+ ],
+ commitStatuses: [
+ { context: 'deploy', state: 'pending', target_url: null, updated_at: null },
+ { context: 'coverage', state: 'success', target_url: null, updated_at: null },
+ ],
+ });
+ // 3 check runs + 2 statuses = 5, and every one lands in exactly one bucket.
+ expect(result.rollup.total).toBe(5);
+ expect(result.rollup.success).toBe(2); // test + coverage
+ expect(result.rollup.failure).toBe(0);
+ expect(result.rollup.skipped).toBe(0);
+ // build(in_progress) + lint(completed/null) + deploy(pending) = 3
+ expect(result.rollup.pending).toBe(3);
+ expect(
+ result.rollup.success + result.rollup.failure + result.rollup.pending + result.rollup.skipped
+ ).toBe(result.rollup.total);
+ });
+});
+
+describe('buildFilesPage', () => {
+ const makeFile = (i: number) => ({
+ filename: `src/file${i}.ts`,
+ status: 'modified',
+ additions: 1,
+ deletions: 0,
+ });
+
+ it('returns nextCursor null on a short page even when below the cap', () => {
+ const result = buildFilesPage({ page: 1, perPage: 50, rawFiles: [makeFile(0), makeFile(1)] });
+ expect(result.files).toHaveLength(2);
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it('returns nextCursor on a full page below the cap', () => {
+ const raw = Array.from({ length: 50 }, (_, i) => makeFile(i));
+ const result = buildFilesPage({ page: 1, perPage: 50, rawFiles: raw });
+ expect(result.nextCursor).toBe(2);
+ });
+
+ it('clamps to FILES_MAX_PAGES and returns null nextCursor at the cap', () => {
+ const raw = Array.from({ length: 50 }, (_, i) => makeFile(i));
+ const result = buildFilesPage({ page: FILES_MAX_PAGES, perPage: 50, rawFiles: raw });
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it('returns nextCursor at page 59 when full', () => {
+ const raw = Array.from({ length: 50 }, (_, i) => makeFile(i));
+ const result = buildFilesPage({ page: 59, perPage: 50, rawFiles: raw });
+ expect(result.nextCursor).toBe(60);
+ });
+
+ it('returns nextCursor at page 60 when full (cap reached → null)', () => {
+ const raw = Array.from({ length: 50 }, (_, i) => makeFile(i));
+ const result = buildFilesPage({ page: 60, perPage: 50, rawFiles: raw });
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it('flags patchMissing when GitHub omits the patch', () => {
+ const result = buildFilesPage({
+ page: 1,
+ perPage: 50,
+ rawFiles: [{ filename: 'big.bin', status: 'modified', additions: 0, deletions: 0 }],
+ });
+ expect(result.files[0]?.patchMissing).toBe(true);
+ expect(result.files[0]?.patch).toBeNull();
+ });
+
+ it('preserves previousPath on renames', () => {
+ const result = buildFilesPage({
+ page: 1,
+ perPage: 50,
+ rawFiles: [
+ {
+ filename: 'new.ts',
+ previous_filename: 'old.ts',
+ status: 'renamed',
+ additions: 1,
+ deletions: 1,
+ },
+ ],
+ });
+ expect(result.files[0]?.previousPath).toBe('old.ts');
+ });
+});
+
+describe('sliceFileLines', () => {
+ const content = 'a\nb\nc\nd\ne';
+
+ it('returns the requested slice and totalLines', () => {
+ const result = sliceFileLines({ rawContent: content, startLine: 2, endLine: 4 });
+ expect(result.lines).toEqual(['b', 'c', 'd']);
+ expect(result.totalLines).toBe(5);
+ });
+
+ it('caps the returned slice at 500 lines', () => {
+ const huge = Array.from({ length: 1000 }, (_, i) => `line${i}`).join('\n');
+ const result = sliceFileLines({ rawContent: huge, startLine: 1, endLine: 10_000 });
+ expect(result.lines).toHaveLength(500);
+ });
+});
+
+describe('buildReviewThreadsResult', () => {
+ it('maps a thread with file-level subject and null line', () => {
+ const result = buildReviewThreadsResult({
+ page: 1,
+ hasNextPage: false,
+ endCursor: null,
+ threads: [
+ {
+ id: 'thread-1',
+ isResolved: false,
+ isOutdated: false,
+ subjectType: 'FILE',
+ path: 'src/file.ts',
+ diffSide: 'RIGHT',
+ comments: [
+ {
+ databaseId: 42,
+ id: 'comment-node-1',
+ author: { login: 'octocat', avatarUrl: 'https://avatars.example/octocat' },
+ body: 'LGTM',
+ createdAt: '2026-01-01T00:00:00Z',
+ reactions: [{ content: '+1', count: 2, viewerHasReacted: false }],
+ },
+ ],
+ },
+ ],
+ });
+ expect(result.threads[0]?.subjectType).toBe('FILE');
+ expect(result.threads[0]?.line).toBeNull();
+ expect(result.threads[0]?.path).toBe('src/file.ts');
+ expect(result.threads[0]?.comments[0]?.reactions[0]?.count).toBe(2);
+ expect(result.nextCursor).toBeNull();
+ });
+
+ it('maps an outdated thread anchored by originalLine/originalStartLine', () => {
+ const result = buildReviewThreadsResult({
+ page: 1,
+ hasNextPage: false,
+ endCursor: null,
+ threads: [
+ {
+ id: 'thread-2',
+ isResolved: true,
+ isOutdated: true,
+ subjectType: 'LINE',
+ path: 'src/old.ts',
+ line: 10,
+ startLine: 9,
+ originalLine: 20,
+ originalStartLine: 19,
+ diffSide: 'LEFT',
+ comments: [],
+ },
+ ],
+ });
+ expect(result.threads[0]?.isOutdated).toBe(true);
+ expect(result.threads[0]?.originalLine).toBe(20);
+ expect(result.threads[0]?.originalStartLine).toBe(19);
+ expect(result.threads[0]?.diffSide).toBe('LEFT');
+ });
+
+ it('tolerates deleted-author comments (author = null)', () => {
+ const result = buildReviewThreadsResult({
+ page: 1,
+ hasNextPage: false,
+ endCursor: null,
+ threads: [
+ {
+ id: 'thread-3',
+ isResolved: false,
+ isOutdated: false,
+ comments: [
+ {
+ databaseId: 7,
+ id: 'comment-node-7',
+ author: null,
+ body: 'comment from deleted user',
+ createdAt: '2026-01-01T00:00:00Z',
+ reactions: [],
+ },
+ ],
+ },
+ ],
+ });
+ expect(result.threads[0]?.comments[0]?.author).toBeNull();
+ });
+
+ it('exposes nextCursor when GitHub reports hasNextPage and an endCursor', () => {
+ const result = buildReviewThreadsResult({
+ page: 1,
+ hasNextPage: true,
+ endCursor: 'Y3Vyc29yOnYyOpHOAAAAAA==',
+ threads: [],
+ });
+ expect(result.nextCursor).toBe('Y3Vyc29yOnYyOpHOAAAAAA==');
+ });
+
+ it('folds a multi-page comment list into a single complete thread', () => {
+ // Mapper is invoked per-thread with the already-aggregated comment list;
+ // the procedure is responsible for the per-thread paginated fetch.
+ const result = buildReviewThreadsResult({
+ page: 1,
+ hasNextPage: false,
+ endCursor: null,
+ threads: [
+ {
+ id: 'thread-4',
+ isResolved: false,
+ isOutdated: false,
+ comments: Array.from({ length: 120 }, (_, i) => ({
+ databaseId: 1000 + i,
+ id: `c-${i}`,
+ author: null,
+ body: `comment ${i}`,
+ createdAt: '2026-01-01T00:00:00Z',
+ reactions: [],
+ })),
+ },
+ ],
+ });
+ expect(result.threads[0]?.comments).toHaveLength(120);
+ });
+});
diff --git a/apps/web/src/lib/github-pr-review/mappers.ts b/apps/web/src/lib/github-pr-review/mappers.ts
new file mode 100644
index 0000000000..843da39184
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/mappers.ts
@@ -0,0 +1,338 @@
+import 'server-only';
+
+import { z } from 'zod';
+
+import {
+ FILES_MAX_PAGES,
+ REVIEW_THREADS_PAGE_SIZE,
+ type GitHubPrReviewChecksResult,
+ type GitHubPrReviewFile,
+ type GitHubPrReviewFilesResult,
+ type GitHubPrReviewReviewThread,
+ type GitHubPrReviewReviewThreadsResult,
+ GitHubPrReviewFilesResultSchema,
+ GitHubPrReviewReviewThreadsResultSchema,
+} from './dtos';
+import {
+ GitHubPrReviewAuthorSchema,
+ GitHubPrReviewOverviewSchema,
+ type GitHubPrReviewOverview,
+} from './dtos';
+
+export type PullRequestRestData = {
+ number: number;
+ title: string;
+ body: string | null;
+ user: { login: string; avatar_url: string } | null;
+ state: 'open' | 'closed';
+ draft?: boolean;
+ merged?: boolean;
+ base: { ref: string; repo?: { full_name: string } | null };
+ head: { ref: string; sha: string; repo?: { full_name: string } | null };
+ node_id: string;
+ commits: number;
+ changed_files: number;
+ additions: number;
+ deletions: number;
+ mergeable: boolean | null;
+ mergeable_state?: string | null;
+ auto_merge?: { merge_method?: string | null } | null;
+};
+
+export type RepoRestData = {
+ allow_merge_commit?: boolean | null;
+ allow_squash_merge?: boolean | null;
+ allow_rebase_merge?: boolean | null;
+ allow_auto_merge?: boolean | null;
+ delete_branch_on_merge?: boolean | null;
+ allow_update_branch?: boolean | null;
+ permissions?: { push?: boolean; admin?: boolean; maintain?: boolean } | null;
+};
+
+export type ViewerInfo = { login: string } | null;
+
+export type OverviewGraphQlData = {
+ repository: {
+ pullRequest: {
+ reviewDecision: string | null;
+ } | null;
+ } | null;
+ viewer: { login: string } | null;
+} | null;
+
+function normalizeReviewDecision(value: string | null): GitHubPrReviewOverview['reviewDecision'] {
+ if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') {
+ return value;
+ }
+ return null;
+}
+
+const GitHubRestUserSchema = z
+ .object({
+ login: z.string().min(1),
+ avatar_url: z.string().url(),
+ })
+ .strict();
+
+export function buildOverviewDto(args: {
+ pr: PullRequestRestData;
+ repo: RepoRestData;
+ graphQl: OverviewGraphQlData;
+ viewer: ViewerInfo;
+}): GitHubPrReviewOverview {
+ const { pr, repo, graphQl, viewer } = args;
+ const state: GitHubPrReviewOverview['state'] = pr.merged
+ ? 'merged'
+ : pr.state === 'open'
+ ? 'open'
+ : 'closed';
+ const authorParsed = pr.user
+ ? GitHubPrReviewAuthorSchema.safeParse({
+ login: pr.user.login,
+ avatarUrl: pr.user.avatar_url,
+ })
+ : null;
+ const author = authorParsed?.success ? authorParsed.data : null;
+ const headRepoFullName = pr.head.repo?.full_name ?? null;
+ const isCrossRepo = Boolean(
+ pr.base.repo?.full_name && headRepoFullName && pr.base.repo.full_name !== headRepoFullName
+ );
+ const autoMerge =
+ pr.auto_merge && pr.auto_merge.merge_method ? { method: pr.auto_merge.merge_method } : null;
+ const overview: GitHubPrReviewOverview = {
+ number: pr.number,
+ title: pr.title,
+ bodyMarkdown: pr.body ?? null,
+ author,
+ state,
+ draft: Boolean(pr.draft),
+ baseRef: pr.base.ref,
+ headRef: pr.head.ref,
+ isCrossRepo,
+ headRepoFullName,
+ headSha: pr.head.sha,
+ prNodeId: pr.node_id,
+ counts: {
+ commits: pr.commits,
+ changedFiles: pr.changed_files,
+ additions: pr.additions,
+ deletions: pr.deletions,
+ },
+ mergeable: pr.mergeable,
+ mergeableState: pr.mergeable_state ?? null,
+ autoMerge,
+ reviewDecision: normalizeReviewDecision(
+ graphQl?.repository?.pullRequest?.reviewDecision ?? null
+ ),
+ repo: {
+ allowMergeCommit: Boolean(repo.allow_merge_commit),
+ allowSquashMerge: Boolean(repo.allow_squash_merge),
+ allowRebaseMerge: Boolean(repo.allow_rebase_merge),
+ allowAutoMerge: Boolean(repo.allow_auto_merge),
+ deleteBranchOnMerge: Boolean(repo.delete_branch_on_merge),
+ allowUpdateBranch: Boolean(repo.allow_update_branch),
+ viewerCanPush: Boolean(repo.permissions?.push),
+ viewerCanAdmin: Boolean(repo.permissions?.admin),
+ viewerLogin: viewer?.login ?? null,
+ },
+ };
+ return GitHubPrReviewOverviewSchema.parse(overview);
+}
+
+const GitHubCheckRunRestSchema = z
+ .object({
+ name: z.string().min(1),
+ status: z.string(),
+ conclusion: z.string().nullable().optional(),
+ details_url: z.string().url().nullable().optional(),
+ html_url: z.string().url().nullable().optional(),
+ app: z
+ .object({
+ name: z.string().nullable().optional(),
+ })
+ .nullable()
+ .optional(),
+ })
+ .passthrough();
+
+type CheckRunInput = z.infer;
+
+export function buildChecksResult(args: {
+ checkRuns: CheckRunInput[];
+ commitStatuses: Array<{
+ context: string;
+ state: string;
+ target_url: string | null;
+ updated_at: string | null;
+ }>;
+}): GitHubPrReviewChecksResult {
+ const checkRunDtos = args.checkRuns.map(c => ({
+ name: c.name,
+ status: c.status,
+ conclusion: c.conclusion ?? null,
+ detailsUrl: c.details_url ?? c.html_url ?? null,
+ appName: c.app?.name ?? null,
+ }));
+ // Dedupe commit statuses per context, keeping the latest by updated_at.
+ const byContext = new Map();
+ for (const status of args.commitStatuses) {
+ const existing = byContext.get(status.context);
+ if (!existing) {
+ byContext.set(status.context, status);
+ continue;
+ }
+ const existingTime = existing.updated_at ? Date.parse(existing.updated_at) : 0;
+ const nextTime = status.updated_at ? Date.parse(status.updated_at) : 0;
+ if (nextTime >= existingTime) {
+ byContext.set(status.context, status);
+ }
+ }
+ const statusDtos = Array.from(byContext.values()).map(s => ({
+ name: s.context,
+ // A commit status is only terminal for success/failure/error states; a
+ // `pending` state must remain non-completed so the rollup counts it.
+ status: s.state === 'pending' ? 'pending' : 'completed',
+ conclusion: s.state,
+ detailsUrl: s.target_url,
+ appName: null,
+ }));
+ const merged = [...checkRunDtos, ...statusDtos];
+ const rollup = {
+ total: merged.length,
+ success: merged.filter(c => rollupState(c.status, c.conclusion) === 'success').length,
+ failure: merged.filter(c => rollupState(c.status, c.conclusion) === 'failure').length,
+ pending: merged.filter(c => rollupState(c.status, c.conclusion) === 'pending').length,
+ skipped: merged.filter(c => rollupState(c.status, c.conclusion) === 'skipped').length,
+ };
+ return { checkRuns: merged, rollup };
+}
+
+// Classify a merged check/status into exactly one rollup bucket. Anything that
+// has not reached a terminal conclusion (non-completed status, or completed
+// with a null/unknown conclusion) counts as pending so no check is dropped.
+function rollupState(
+ status: string,
+ conclusion: string | null
+): 'success' | 'failure' | 'pending' | 'skipped' {
+ if (status !== 'completed') return 'pending';
+ const value = conclusion ?? '';
+ if (/^success$/i.test(value)) return 'success';
+ if (/failure|error|cancelled|timed_out|action_required|stale/i.test(value)) return 'failure';
+ if (/skipped|neutral/i.test(value)) return 'skipped';
+ return 'pending';
+}
+
+type PullRequestFileInput = {
+ filename: string;
+ previous_filename?: string;
+ status: string;
+ additions: number;
+ deletions: number;
+ patch?: string;
+};
+
+export function buildFilesPage(args: {
+ page: number;
+ perPage: number;
+ rawFiles: PullRequestFileInput[];
+}): GitHubPrReviewFilesResult {
+ const { page, perPage, rawFiles } = args;
+ const clampedPage = Math.max(1, Math.min(FILES_MAX_PAGES, page));
+ const files: GitHubPrReviewFile[] = rawFiles.map(f => ({
+ path: f.filename,
+ previousPath: f.previous_filename ?? null,
+ status: f.status,
+ additions: f.additions,
+ deletions: f.deletions,
+ patch: f.patch ?? null,
+ patchMissing: !f.patch,
+ }));
+ const reachedCap = clampedPage >= FILES_MAX_PAGES;
+ const shortPage = files.length < perPage;
+ const nextCursor = shortPage || reachedCap ? null : clampedPage + 1;
+ return GitHubPrReviewFilesResultSchema.parse({ files, nextCursor });
+}
+
+export function sliceFileLines(args: { rawContent: string; startLine: number; endLine: number }): {
+ lines: string[];
+ totalLines: number;
+} {
+ const { rawContent, startLine, endLine } = args;
+ const all = rawContent.split(/\r?\n/);
+ const totalLines = all.length;
+ const start = Math.max(1, startLine);
+ const requestedEnd = Math.max(start, endLine);
+ const cappedEnd = Math.min(requestedEnd, start + 500 - 1);
+ const slice = all.slice(start - 1, cappedEnd);
+ return { lines: slice, totalLines };
+}
+
+export type GraphQlReviewThreadInput = {
+ id: string;
+ isResolved: boolean;
+ isOutdated: boolean;
+ subjectType?: string | null;
+ path?: string | null;
+ line?: number | null;
+ startLine?: number | null;
+ originalLine?: number | null;
+ originalStartLine?: number | null;
+ diffSide?: 'LEFT' | 'RIGHT' | null;
+ comments: GraphQlReviewCommentInput[];
+};
+
+export type GraphQlReviewCommentInput = {
+ databaseId: number;
+ id: string;
+ author: { login: string; avatarUrl: string } | null;
+ body: string;
+ createdAt: string;
+ reactions: Array<{ content: string; count: number; viewerHasReacted: boolean }>;
+};
+
+export function buildReviewThreadsResult(args: {
+ threads: GraphQlReviewThreadInput[];
+ page: number;
+ hasNextPage: boolean;
+ endCursor: string | null;
+}): GitHubPrReviewReviewThreadsResult {
+ const { threads, page, hasNextPage, endCursor } = args;
+ const dtos: GitHubPrReviewReviewThread[] = threads.map(t => {
+ const subjectType: 'LINE' | 'FILE' = t.subjectType === 'FILE' ? 'FILE' : 'LINE';
+ const diffSide = t.diffSide === 'LEFT' || t.diffSide === 'RIGHT' ? t.diffSide : null;
+ return {
+ threadId: t.id,
+ isResolved: t.isResolved,
+ isOutdated: t.isOutdated,
+ subjectType,
+ path: t.path ?? null,
+ line: t.line ?? null,
+ startLine: t.startLine ?? null,
+ originalLine: t.originalLine ?? null,
+ originalStartLine: t.originalStartLine ?? null,
+ diffSide,
+ comments: t.comments.map(c => ({
+ commentId: c.databaseId,
+ nodeId: c.id,
+ author: c.author ? { login: c.author.login, avatarUrl: c.author.avatarUrl } : null,
+ bodyMarkdown: c.body,
+ createdAt: c.createdAt,
+ reactions: c.reactions.map(r => ({
+ content: r.content,
+ count: r.count,
+ viewerHasReacted: r.viewerHasReacted,
+ })),
+ })),
+ };
+ });
+ const nextCursor =
+ hasNextPage && endCursor && page < Number.MAX_SAFE_INTEGER / REVIEW_THREADS_PAGE_SIZE
+ ? endCursor
+ : null;
+ return GitHubPrReviewReviewThreadsResultSchema.parse({
+ threads: dtos,
+ nextCursor,
+ });
+}
+
+export { GitHubRestUserSchema };
diff --git a/apps/web/src/lib/github-pr-review/mutations.test.ts b/apps/web/src/lib/github-pr-review/mutations.test.ts
new file mode 100644
index 0000000000..a3fb88ce68
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/mutations.test.ts
@@ -0,0 +1,373 @@
+/**
+ * @jest-environment node
+ */
+import { z } from 'zod';
+
+import {
+ AUTO_MERGE_METHODS,
+ AutoMergeMethodSchema,
+ CommentPositionSchema,
+ MERGE_METHODS,
+ MergeMethodSchema,
+ REACTION_CONTENTS,
+ ReactionContentSchema,
+ REVIEW_EVENTS,
+ ReviewEventSchema,
+ ReviewSideSchema,
+ buildAddReactionVariables,
+ buildCreateReviewCommentParams,
+ buildDeleteRefParams,
+ buildDisableAutoMergeVariables,
+ buildEnableAutoMergeVariables,
+ buildMergePullRequestParams,
+ buildRemoveReactionVariables,
+ buildReplyToCommentParams,
+ buildResolveThreadVariables,
+ buildSubmitReviewParams,
+ buildUnresolveThreadVariables,
+ buildUpdateBranchParams,
+} from './mutations';
+
+describe('GitHub PR review mutation enums', () => {
+ it('matches the GitHub reaction content enum', () => {
+ expect([...REACTION_CONTENTS].sort()).toEqual(
+ ['CONFUSED', 'EYES', 'HEART', 'HOORAY', 'LAUGH', 'ROCKET', 'THUMBS_DOWN', 'THUMBS_UP'].sort()
+ );
+ });
+
+ it('matches the three review events', () => {
+ expect([...REVIEW_EVENTS].sort()).toEqual(['APPROVE', 'COMMENT', 'REQUEST_CHANGES']);
+ });
+
+ it('matches the three merge methods (lowercase REST casing)', () => {
+ expect([...MERGE_METHODS].sort()).toEqual(['merge', 'rebase', 'squash']);
+ });
+
+ it('matches the three auto-merge methods (uppercase GraphQL casing)', () => {
+ expect([...AUTO_MERGE_METHODS].sort()).toEqual(['MERGE', 'REBASE', 'SQUASH']);
+ });
+
+ it('rejects reaction content outside the enum', () => {
+ expect(ReactionContentSchema.safeParse('STAR').success).toBe(false);
+ expect(ReactionContentSchema.safeParse('THUMBS_UP').success).toBe(true);
+ });
+
+ it('rejects unsupported review events', () => {
+ expect(ReviewEventSchema.safeParse('APPROVE').success).toBe(true);
+ expect(ReviewEventSchema.safeParse('PENDING').success).toBe(false);
+ });
+
+ it('rejects unsupported merge methods', () => {
+ expect(MergeMethodSchema.safeParse('squash').success).toBe(true);
+ expect(MergeMethodSchema.safeParse('SQUASH').success).toBe(false);
+ });
+
+ it('rejects unsupported auto-merge methods', () => {
+ expect(AutoMergeMethodSchema.safeParse('MERGE').success).toBe(true);
+ expect(AutoMergeMethodSchema.safeParse('merge').success).toBe(false);
+ });
+
+ it('accepts only LEFT/RIGHT for review side', () => {
+ expect(ReviewSideSchema.safeParse('LEFT').success).toBe(true);
+ expect(ReviewSideSchema.safeParse('right').success).toBe(false);
+ });
+});
+
+describe('CommentPositionSchema', () => {
+ it('accepts a single-line position', () => {
+ const result = CommentPositionSchema.safeParse({
+ path: 'src/foo.ts',
+ line: 10,
+ side: 'RIGHT',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects startLine > line for a multi-line position', () => {
+ const result = CommentPositionSchema.safeParse({
+ path: 'src/foo.ts',
+ line: 5,
+ side: 'RIGHT',
+ startLine: 10,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('accepts a multi-line position with startLine <= line', () => {
+ const result = CommentPositionSchema.safeParse({
+ path: 'src/foo.ts',
+ line: 20,
+ side: 'RIGHT',
+ startLine: 15,
+ startSide: 'RIGHT',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('rejects startLine without startSide (partial multi-line range)', () => {
+ const result = CommentPositionSchema.safeParse({
+ path: 'src/foo.ts',
+ line: 20,
+ side: 'RIGHT',
+ startLine: 15,
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('rejects startSide without startLine (partial multi-line range)', () => {
+ const result = CommentPositionSchema.safeParse({
+ path: 'src/foo.ts',
+ line: 20,
+ side: 'RIGHT',
+ startSide: 'RIGHT',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('enforces the pairing rule through the submitReview batch (.extend) shape', () => {
+ const batchComment = CommentPositionSchema.extend({
+ body: z.string().min(1),
+ }).strict();
+ const partial = batchComment.safeParse({
+ path: 'src/foo.ts',
+ line: 20,
+ side: 'RIGHT',
+ startLine: 15,
+ body: 'x',
+ });
+ expect(partial.success).toBe(false);
+ const paired = batchComment.safeParse({
+ path: 'src/foo.ts',
+ line: 20,
+ side: 'RIGHT',
+ startLine: 15,
+ startSide: 'RIGHT',
+ body: 'x',
+ });
+ expect(paired.success).toBe(true);
+ });
+});
+
+describe('buildCreateReviewCommentParams', () => {
+ it('maps to the REST field names', () => {
+ const params = buildCreateReviewCommentParams({
+ owner: 'octocat',
+ repo: 'hello',
+ number: 7,
+ body: 'nit',
+ commitSha: '0'.repeat(40),
+ path: 'src/foo.ts',
+ line: 12,
+ side: 'RIGHT',
+ });
+ expect(params).toEqual({
+ owner: 'octocat',
+ repo: 'hello',
+ pull_number: 7,
+ body: 'nit',
+ commit_id: '0'.repeat(40),
+ path: 'src/foo.ts',
+ line: 12,
+ side: 'RIGHT',
+ });
+ });
+
+ it('forwards multi-line start fields when present', () => {
+ const params = buildCreateReviewCommentParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ body: 'b',
+ commitSha: '0'.repeat(40),
+ path: 'p',
+ line: 5,
+ side: 'LEFT',
+ startLine: 3,
+ startSide: 'LEFT',
+ });
+ expect(params).toMatchObject({ start_line: 3, start_side: 'LEFT' });
+ });
+
+ it('omits multi-line start fields when not provided', () => {
+ const params = buildCreateReviewCommentParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ body: 'b',
+ commitSha: '0'.repeat(40),
+ path: 'p',
+ line: 5,
+ side: 'RIGHT',
+ });
+ expect(params).not.toHaveProperty('start_line');
+ expect(params).not.toHaveProperty('start_side');
+ });
+});
+
+describe('buildReplyToCommentParams', () => {
+ it('maps to the REST field names', () => {
+ expect(
+ buildReplyToCommentParams({
+ owner: 'octocat',
+ repo: 'hello',
+ number: 7,
+ commentId: 99,
+ body: 'thanks',
+ })
+ ).toEqual({
+ owner: 'octocat',
+ repo: 'hello',
+ pull_number: 7,
+ comment_id: 99,
+ body: 'thanks',
+ });
+ });
+});
+
+describe('buildSubmitReviewParams', () => {
+ it('omits body and comments when neither is provided', () => {
+ const params = buildSubmitReviewParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ event: 'APPROVE',
+ commitSha: '0'.repeat(40),
+ });
+ expect(params).toEqual({
+ owner: 'o',
+ repo: 'r',
+ pull_number: 1,
+ event: 'APPROVE',
+ commit_id: '0'.repeat(40),
+ });
+ expect(params).not.toHaveProperty('body');
+ expect(params).not.toHaveProperty('comments');
+ });
+
+ it('passes a batch of inline comments with multi-line fields when set', () => {
+ const params = buildSubmitReviewParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ event: 'REQUEST_CHANGES',
+ body: 'see below',
+ commitSha: '0'.repeat(40),
+ comments: [
+ { path: 'a.ts', line: 5, side: 'RIGHT', body: 'one' },
+ { path: 'b.ts', line: 10, side: 'RIGHT', startLine: 8, startSide: 'RIGHT', body: 'two' },
+ ],
+ });
+ expect(params.comments).toHaveLength(2);
+ expect(params.comments?.[1]).toMatchObject({ start_line: 8, start_side: 'RIGHT' });
+ });
+});
+
+describe('buildMergePullRequestParams', () => {
+ it('maps to the REST merge field names', () => {
+ const params = buildMergePullRequestParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ method: 'squash',
+ expectedHeadSha: 'a'.repeat(40),
+ });
+ expect(params).toEqual({
+ owner: 'o',
+ repo: 'r',
+ pull_number: 1,
+ merge_method: 'squash',
+ sha: 'a'.repeat(40),
+ });
+ });
+
+ it('forwards commit title/message when provided', () => {
+ const params = buildMergePullRequestParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ method: 'merge',
+ expectedHeadSha: 'a'.repeat(40),
+ commitTitle: 'Title (#1)',
+ commitMessage: 'Body',
+ });
+ expect(params).toMatchObject({ commit_title: 'Title (#1)', commit_message: 'Body' });
+ });
+});
+
+describe('buildDeleteRefParams', () => {
+ it('prefixes heads/ on the ref', () => {
+ expect(buildDeleteRefParams({ owner: 'o', repo: 'r', headRef: 'feature/x' })).toEqual({
+ owner: 'o',
+ repo: 'r',
+ ref: 'heads/feature/x',
+ });
+ });
+});
+
+describe('buildUpdateBranchParams', () => {
+ it('maps to expected_head_sha', () => {
+ expect(
+ buildUpdateBranchParams({
+ owner: 'o',
+ repo: 'r',
+ number: 1,
+ expectedHeadSha: 'a'.repeat(40),
+ })
+ ).toEqual({
+ owner: 'o',
+ repo: 'r',
+ pull_number: 1,
+ expected_head_sha: 'a'.repeat(40),
+ });
+ });
+});
+
+describe('GraphQL variable builders', () => {
+ it('builds enableAutoMerge variables with optional headline/body', () => {
+ expect(
+ buildEnableAutoMergeVariables({
+ prNodeId: 'PR_1',
+ method: 'SQUASH',
+ commitTitle: 'Title',
+ commitMessage: 'Body',
+ })
+ ).toEqual({
+ input: {
+ pullRequestId: 'PR_1',
+ mergeMethod: 'SQUASH',
+ commitHeadline: 'Title',
+ commitBody: 'Body',
+ },
+ });
+ });
+
+ it('builds enableAutoMerge variables without optional fields', () => {
+ expect(buildEnableAutoMergeVariables({ prNodeId: 'PR_1', method: 'MERGE' })).toEqual({
+ input: { pullRequestId: 'PR_1', mergeMethod: 'MERGE' },
+ });
+ });
+
+ it('builds disableAutoMerge variables', () => {
+ expect(buildDisableAutoMergeVariables({ prNodeId: 'PR_1' })).toEqual({
+ input: { pullRequestId: 'PR_1' },
+ });
+ });
+
+ it('builds resolve/unresolve thread variables', () => {
+ expect(buildResolveThreadVariables({ threadId: 'T_1' })).toEqual({
+ input: { threadId: 'T_1' },
+ });
+ expect(buildUnresolveThreadVariables({ threadId: 'T_1' })).toEqual({
+ input: { threadId: 'T_1' },
+ });
+ });
+
+ it('builds add/remove reaction variables with the comment node id and content', () => {
+ expect(buildAddReactionVariables({ commentNodeId: 'C_1', content: 'THUMBS_UP' })).toEqual({
+ input: { subjectId: 'C_1', content: 'THUMBS_UP' },
+ });
+ expect(buildRemoveReactionVariables({ commentNodeId: 'C_1', content: 'HEART' })).toEqual({
+ input: { subjectId: 'C_1', content: 'HEART' },
+ });
+ });
+});
diff --git a/apps/web/src/lib/github-pr-review/mutations.ts b/apps/web/src/lib/github-pr-review/mutations.ts
new file mode 100644
index 0000000000..ce3fa3ae36
--- /dev/null
+++ b/apps/web/src/lib/github-pr-review/mutations.ts
@@ -0,0 +1,270 @@
+import 'server-only';
+
+import * as z from 'zod';
+
+// Single source of truth for the GitHub reaction enum so Zod validation and
+// GraphQL variable builders stay aligned with what the GitHub GraphQL API
+// actually accepts. GitHub rejects unknown content strings with a 400.
+export const REACTION_CONTENTS = [
+ 'THUMBS_UP',
+ 'THUMBS_DOWN',
+ 'LAUGH',
+ 'HOORAY',
+ 'CONFUSED',
+ 'HEART',
+ 'ROCKET',
+ 'EYES',
+] as const;
+export const ReactionContentSchema = z.enum(REACTION_CONTENTS);
+export type ReactionContent = z.infer;
+
+// `pulls.createReview.event` — the only three legal values for a batch submit.
+export const REVIEW_EVENTS = ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'] as const;
+export const ReviewEventSchema = z.enum(REVIEW_EVENTS);
+export type ReviewEvent = z.infer;
+
+// `pulls.merge.merge_method` — the three merge strategies surfaced to the UI.
+export const MERGE_METHODS = ['merge', 'squash', 'rebase'] as const;
+export const MergeMethodSchema = z.enum(MERGE_METHODS);
+export type MergeMethod = z.infer;
+
+// GitHub's `PullRequestAutoMergeMethod` (GraphQL) — independent of the REST
+// `merge_method` casing above, so we keep a separate constant.
+export const AUTO_MERGE_METHODS = ['MERGE', 'REBASE', 'SQUASH'] as const;
+export const AutoMergeMethodSchema = z.enum(AUTO_MERGE_METHODS);
+export type AutoMergeMethod = z.infer