Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ export default function AppLayout() {
}}
>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="pr-review"
options={{
headerShown: false,
}}
/>
<Stack.Screen name="agent-chat/new" options={{ headerShown: false }} />
<Stack.Screen name="agent-chat/[session-id]" />
<Stack.Screen
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { type Href, Stack, useLocalSearchParams } from 'expo-router';

import { InvalidRouteState } from '@/components/invalid-route-state';
import { PrReviewConnectGate } from '@/components/pr-review/pr-review-connect-gate';
import { PendingReviewProvider } from '@/lib/pr-review/pending-review-provider';
import { useFormSheetDetents } from '@/lib/form-sheet';
import { parseParam } from '@/lib/route-params';

type Params = {
owner: string;
repo: string;
number: string;
};

/**
* Param guard + provider hoist for the PR review surface. Every route
* under `[number]/` is a descendant of this layout, so rejecting an
* invalid owner/repo/number here blocks all of them before any
* query/mutation runs. The four sheet routes are registered as siblings
* INSIDE this layout so they all see the same `PendingReviewProvider`
* context (the provider lifetime is the mounted navigation entry, so
* pending comments survive opening/closing the sheets and the back
* stack, but clear when the user leaves the PR entirely).
*/
export default function PrReviewNumberLayout() {
const params = useLocalSearchParams<Params>();
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 <InvalidRouteState backTo={'/(app)/pr-review' as Href} />;
}

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 (
<PrReviewConnectGate>
<PendingReviewProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="comment-composer" options={sheetOptions} />
<Stack.Screen name="review-submit" options={sheetOptions} />
<Stack.Screen name="merge" options={sheetOptions} />
<Stack.Screen name="file-navigator" options={sheetOptions} />
</Stack>
</PendingReviewProvider>
</PrReviewConnectGate>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrReviewCommentComposerScreen } from '@/components/pr-review/pr-review-comment-composer-screen';

export default function PrReviewCommentComposerRoute() {
return <PrReviewCommentComposerScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrReviewFileNavigatorScreen } from '@/components/pr-review/pr-review-file-navigator-screen';

export default function PrReviewFileNavigatorRoute() {
return <PrReviewFileNavigatorScreen />;
}
Original file line number Diff line number Diff line change
@@ -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<Params>();
const numberValue = Number.parseInt(number, 10);

return (
<>
<Stack.Screen options={{ headerShown: false }} />
<PrReviewScreen owner={owner} repo={repo} number={numberValue} />
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrReviewMergeScreen } from '@/components/pr-review/pr-review-merge-screen';

export default function PrReviewMergeRoute() {
return <PrReviewMergeScreen />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PrReviewReviewSubmitScreen } from '@/components/pr-review/pr-review-review-submit-screen';

export default function PrReviewReviewSubmitRoute() {
return <PrReviewReviewSubmitScreen />;
}
10 changes: 10 additions & 0 deletions apps/mobile/src/app/(app)/pr-review/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<PrReviewConnectGate>
<PrReviewEntryScreen />
</PrReviewConnectGate>
);
}
38 changes: 38 additions & 0 deletions apps/mobile/src/components/agents/chat-link-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { openExternalUrl } from '@/lib/external-link';

import {
buildChatLinkActionSheet,
buildPrLinkTapActionSheet,
getSelectedChatLinkAction,
performChatLinkAction,
} from './chat-link-actions';
Expand Down Expand Up @@ -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');

Expand Down
27 changes: 24 additions & 3 deletions apps/mobile/src/components/agents/chat-link-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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<typeof buildChatLinkActionSheet>,
sheet: ReturnType<typeof buildChatLinkActionSheet> | ReturnType<typeof buildPrLinkTapActionSheet>,
index: number | undefined
): ChatLinkAction | null {
if (index === undefined) {
Expand Down
74 changes: 70 additions & 4 deletions apps/mobile/src/components/agents/chat-markdown-text.tsx
Original file line number Diff line number Diff line change
@@ -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<MarkdownTextProps, 'onLongPressLink'>;
type ChatMarkdownTextProps = Omit<MarkdownTextProps, 'onLongPressLink' | 'onPressLink'>;

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<ChatMarkdownTextProps>) {
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,
Expand All @@ -30,14 +87,23 @@ export function ChatMarkdownText(props: Readonly<ChatMarkdownTextProps>) {
},
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 <MarkdownText {...props} onLongPressLink={handleLongPressLink} />;
return (
<MarkdownText {...props} onLongPressLink={handleLongPressLink} onPressLink={handlePressLink} />
);
}
Loading