diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx
index c2870b7865..ebbf38049e 100644
--- a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/_layout.tsx
@@ -74,6 +74,7 @@ export default function PrReviewNumberLayout() {
comment-composer formSheet instead of the PR overview. */}
+
diff --git a/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx
new file mode 100644
index 0000000000..be6b7214c0
--- /dev/null
+++ b/apps/mobile/src/app/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment.tsx
@@ -0,0 +1,37 @@
+import { type Href, useLocalSearchParams, useRouter } from 'expo-router';
+
+import { InvalidRouteState } from '@/components/invalid-route-state';
+import { PrConversationCommentComposer } from '@/components/pr-review/discussion/pr-conversation-comment-composer';
+import { parseParam, parsePositiveIntParam } from '@/lib/route-params';
+
+type Params = {
+ owner: string;
+ repo: string;
+ number: string;
+};
+
+// Conversation (issue) comment formSheet, pushed by the Discussion tab's
+// bottom CTA bar. The sheet chrome and the PendingReviewProvider hoist live
+// in the `[number]` layout; this route only parses its params.
+export default function PrConversationCommentRoute() {
+ const router = useRouter();
+ const params = useLocalSearchParams();
+ const owner = parseParam(params.owner);
+ const repo = parseParam(params.repo);
+ const number = parsePositiveIntParam(params.number);
+
+ if (!owner || !repo || number === null) {
+ return ;
+ }
+
+ return (
+ {
+ router.back();
+ }}
+ />
+ );
+}
diff --git a/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx b/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx
new file mode 100644
index 0000000000..eb231d6cb1
--- /dev/null
+++ b/apps/mobile/src/components/offline-banner.mounted.test-helpers.tsx
@@ -0,0 +1,86 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */
+// Shared mount harness for offline-banner.mounted.test.tsx. The mutable refs
+// below are reset by that suite's beforeEach/afterEach; keeping them here lets
+// the suite stay under the max-lines budget without duplicating the provider
+// stack. This module is imported by the test file, whose hoisted vi.mock
+// registrations are already in place when these imports evaluate.
+
+import { type ReactElement } from 'react';
+import { onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { createTRPCClient, httpLink } from '@trpc/client';
+import TestRenderer, { act } from 'react-test-renderer';
+import { vi } from 'vitest';
+
+import { type ConnectivityState, isOnline } from '@/lib/connectivity-online';
+import { TRPCProvider } from '@/lib/trpc';
+import { OfflineBanner } from './offline-banner';
+import { type MobileRouter } from '@kilocode/trpc/mobile';
+
+/** The fetch transport the tRPC client answers with; scripted per test. */
+export const transport = vi.fn();
+
+const settingsData = {
+ isEnabled: false,
+ repositorySelectionMode: 'all',
+ selectedRepositoryIds: [],
+ analysisMode: 'auto',
+};
+
+const trpcClient = createTRPCClient({
+ links: [httpLink({ url: 'https://settings.test/api/trpc', fetch: transport })],
+});
+
+/** Mutable harness state the owning suite resets around each test. */
+export const harness = {
+ queryClient: new QueryClient(),
+ renderers: [] as TestRenderer.ReactTestRenderer[],
+ sourceListener: undefined as ((value: ConnectivityState) => void) | undefined,
+};
+
+/** Mounts the banner (or any element) under the shared provider stack. */
+export async function mountTree(element: ReactElement = ) {
+ await act(() => {
+ harness.renderers.push(
+ TestRenderer.create(
+
+
+ {element}
+
+
+ )
+ );
+ });
+ const renderer = harness.renderers.at(-1);
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+export function findHost(root: TestRenderer.ReactTestInstance, type: string) {
+ return root.findAll(node => node.type === type);
+}
+
+/** Commits a NetInfo report into the online manager and the test source. */
+export function emit(value: ConnectivityState) {
+ act(() => {
+ onlineManager.setOnline(isOnline(value));
+ harness.sourceListener?.(value);
+ });
+}
+
+export async function advanceBy(ms: number) {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(ms);
+ });
+}
+
+/** The paused-transport tRPC payload the settings-screen scenario reads. */
+export function settingsResponse(procedure: string): unknown {
+ const data: Record = {
+ getConfig: settingsData,
+ getRepositories: [{ id: 1, full_name: 'kilo/repo' }],
+ list: [{ organizationId: 'org_123', role: 'owner' }],
+ };
+ return { result: { data: data[procedure] } };
+}
diff --git a/apps/mobile/src/components/offline-banner.mounted.test.tsx b/apps/mobile/src/components/offline-banner.mounted.test.tsx
index c1142ef179..83a4bf9cc1 100644
--- a/apps/mobile/src/components/offline-banner.mounted.test.tsx
+++ b/apps/mobile/src/components/offline-banner.mounted.test.tsx
@@ -1,36 +1,30 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer mounts React/RN trees without a DOM */
-import { type ReactElement, useSyncExternalStore } from 'react';
+import { useSyncExternalStore } from 'react';
import { type MobileRouter } from '@kilocode/trpc/mobile';
-import { onlineManager, QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { createTRPCClient, httpLink } from '@trpc/client';
-import TestRenderer, { act } from 'react-test-renderer';
+import { onlineManager, QueryClient } from '@tanstack/react-query';
+import { act } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import '@/i18n';
-import { type ConnectivityState, isOnline } from '@/lib/connectivity-online';
+import { type ConnectivityState } from '@/lib/connectivity-online';
import { createOfflineBannerStore, type OfflineBannerStore } from '@/lib/offline-banner-state';
-import { TRPCProvider } from '@/lib/trpc';
-import { OfflineBanner } from './offline-banner';
+import { OFFLINE_BANNER_HEIGHT } from './offline-banner';
import { SettingsOverviewScreen } from './security-agent/settings-overview-screen';
+import {
+ advanceBy,
+ emit,
+ findHost,
+ harness,
+ mountTree,
+ settingsResponse,
+ transport,
+} from './offline-banner.mounted.test-helpers';
const state = vi.hoisted(() => ({ store: undefined as OfflineBannerStore | undefined }));
const announceForA11y = vi.hoisted(() => vi.fn());
-const transport = vi.fn();
-const settingsData = {
- isEnabled: false,
- repositorySelectionMode: 'all',
- selectedRepositoryIds: [],
- analysisMode: 'auto',
-};
-const trpcClient = createTRPCClient({
- links: [httpLink({ url: 'https://settings.test/api/trpc', fetch: transport })],
-});
const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false };
const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true };
const probe = vi.fn<() => Promise>();
-const renderers: TestRenderer.ReactTestRenderer[] = [];
-let sourceListener: ((value: ConnectivityState) => void) | undefined = undefined;
-let queryClient = new QueryClient();
let previousOnline = true;
let responseGate: Promise | undefined = undefined;
@@ -98,69 +92,30 @@ vi.mock('@/components/tab-screen', () => ({
useTabBarBottomPadding: () => 0,
}));
-async function mountTree(element: ReactElement = ) {
- await act(() => {
- renderers.push(
- TestRenderer.create(
-
-
- {element}
-
-
- )
- );
- });
- const renderer = renderers.at(-1);
- if (!renderer) {
- throw new Error('renderer was not created');
- }
- return renderer;
-}
-
-function findHost(root: TestRenderer.ReactTestInstance, type: string) {
- return root.findAll(node => node.type === type);
-}
-
-function emit(value: ConnectivityState) {
- act(() => {
- onlineManager.setOnline(isOnline(value));
- sourceListener?.(value);
- });
-}
-
-async function advanceBy(ms: number) {
- await act(async () => {
- await vi.advanceTimersByTimeAsync(ms);
- });
-}
-
describe('OfflineBanner mounted with confirmed connectivity', () => {
beforeEach(() => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.useFakeTimers();
previousOnline = onlineManager.isOnline();
onlineManager.setOnline(false);
- queryClient = new QueryClient({ defaultOptions: { queries: { retry: 3, gcTime: Infinity } } });
+ harness.queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: 3, gcTime: Infinity } },
+ });
responseGate = undefined;
transport.mockReset().mockImplementation(async input => {
await responseGate;
const procedure =
new URL(input instanceof Request ? input.url : input).pathname.split('.').at(-1) ?? '';
- const data: Record = {
- getConfig: settingsData,
- getRepositories: [{ id: 1, full_name: 'kilo/repo' }],
- list: [{ organizationId: 'org_123', role: 'owner' }],
- };
- return Response.json({ result: { data: data[procedure] } });
+ return Response.json(settingsResponse(procedure));
});
probe.mockReset().mockResolvedValue(false);
announceForA11y.mockClear();
state.store = createOfflineBannerStore({
source: {
subscribe: listener => {
- sourceListener = listener;
+ harness.sourceListener = listener;
return () => {
- sourceListener = undefined;
+ harness.sourceListener = undefined;
};
},
},
@@ -179,11 +134,11 @@ describe('OfflineBanner mounted with confirmed connectivity', () => {
});
afterEach(() => {
act(() => {
- for (const renderer of renderers.splice(0)) {
+ for (const renderer of harness.renderers.splice(0)) {
renderer.unmount();
}
});
- queryClient.clear();
+ harness.queryClient.clear();
onlineManager.setOnline(previousOnline);
state.store?.destroy();
state.store = undefined;
@@ -227,6 +182,10 @@ describe('OfflineBanner mounted with confirmed connectivity', () => {
const alert = findHost(renderer.root, 'Animated.View')[0];
expect(alert?.props.accessibilityRole).toBe('alert');
expect(alert?.props.accessibilityLabel).toBe('No internet connection');
+ // The painted row is exactly OFFLINE_BANNER_HEIGHT tall: surfaces reserve
+ // that constant above their pinned headers so the overlay never covers a
+ // title (uxs2 spot check). Keep the height style in sync with the export.
+ expect(alert?.props.style).toEqual({ height: OFFLINE_BANNER_HEIGHT });
expect(findHost(renderer.root, 'WifiOff')).toHaveLength(1);
expect(announceForA11y).toHaveBeenCalledExactlyOnceWith('No internet connection');
});
@@ -274,6 +233,42 @@ describe('OfflineBanner mounted with confirmed connectivity', () => {
expect(announceForA11y).not.toHaveBeenCalled();
});
+ it('clears a stale banner on radio-back-without-reachability via the immediate app probe', async () => {
+ // e6-after-net (uxs3 spot check): airplane mode → 3G. NetInfo reports the
+ // connection back but its external reachability probe never answers, so
+ // the event is `unknown` — the banner used to stay painted forever. The
+ // committed-offline + radio-up combination now fires the app's own probe
+ // at once; a reachable answer clears the mounted banner without any
+ // further NetInfo event or timer wait.
+ const renderer = await mountTree();
+ emit(offlineState);
+ await advanceBy(5000);
+ expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1);
+
+ probe.mockResolvedValue(true);
+ emit({ isConnected: true, isInternetReachable: null });
+ // No five-second wait: the recovery probe is immediate. advanceBy(0) only
+ // flushes the probe's microtasks under act — no timer fires.
+ await advanceBy(0);
+ expect(renderer.toJSON()).toBeNull();
+ expect(announceForA11y).toHaveBeenCalledWith('Internet connection restored');
+ });
+
+ it('keeps the mounted banner when the immediate radio-up probe fails', async () => {
+ // The same event with a still-unreachable backend must NOT clear the
+ // banner or announce a restoration that did not happen.
+ const renderer = await mountTree();
+ emit(offlineState);
+ await advanceBy(5000);
+ announceForA11y.mockClear();
+
+ probe.mockResolvedValue(false);
+ emit({ isConnected: true, isInternetReachable: null });
+ await advanceBy(0);
+ expect(findHost(renderer.root, 'Animated.View')).toHaveLength(1);
+ expect(announceForA11y).not.toHaveBeenCalled();
+ });
+
it.each(['personal', 'org_123'])(
'retrieves complete %s settings after a successful probe without another NetInfo event',
async scope => {
@@ -282,7 +277,7 @@ describe('OfflineBanner mounted with confirmed connectivity', () => {
probe.mockResolvedValue(true);
const banner = await mountTree();
const screen = await mountTree();
- const activeQueries = queryClient.getQueryCache().findAll({ type: 'active' });
+ const activeQueries = harness.queryClient.getQueryCache().findAll({ type: 'active' });
expect(activeQueries).toHaveLength(scope === 'personal' ? 2 : 3);
expect(activeQueries.every(query => query.state.fetchStatus === 'paused')).toBe(true);
expect(activeQueries.every(query => query.state.data === undefined)).toBe(true);
@@ -296,7 +291,7 @@ describe('OfflineBanner mounted with confirmed connectivity', () => {
const onRetry = findHost(screen.root, 'Button')[0]?.props.onPress as () => void;
act(onRetry);
await advanceBy(10);
- expect(queryClient.isFetching()).toBe(activeQueries.length);
+ expect(harness.queryClient.isFetching()).toBe(activeQueries.length);
expect(findHost(screen.root, 'Button')[0]?.props.loading).toBe(true);
await act(() => {
response.resolve(undefined);
diff --git a/apps/mobile/src/components/offline-banner.tsx b/apps/mobile/src/components/offline-banner.tsx
index d21fe94b47..9dad725f2d 100644
--- a/apps/mobile/src/components/offline-banner.tsx
+++ b/apps/mobile/src/components/offline-banner.tsx
@@ -10,9 +10,21 @@ import { announceForA11y } from '@/lib/a11y/announce';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state';
+/**
+ * Fixed height of the banner row. The banner is an absolute overlay pinned at
+ * `top: insets.top`, so a surface whose header starts at the safe-area top
+ * must reserve this height while the banner is visible or the overlay covers
+ * the header title (uxs2 spot check, e6-offline-hang). The banner renders at
+ * exactly this height (no vertical padding) so the constant cannot drift from
+ * the painted row.
+ */
+export const OFFLINE_BANNER_HEIGHT = 36;
+
/**
* App-wide offline banner. Absolute overlay, so app content keeps its layout
* position; `pointerEvents="none"` passes every touch to the header below.
+ * Surfaces with a pinned top header reserve `OFFLINE_BANNER_HEIGHT` above the
+ * header while the banner is visible so it never covers the title.
*/
export function OfflineBanner() {
const isOffline = useOfflineBannerState();
@@ -46,7 +58,8 @@ export function OfflineBanner() {
accessible
accessibilityRole="alert"
accessibilityLabel={t('offline.noInternet')}
- className="flex-row items-center justify-center gap-2 bg-warn px-4 py-2"
+ className="flex-row items-center justify-center gap-2 bg-warn px-4"
+ style={{ height: OFFLINE_BANNER_HEIGHT }}
>
{t('offline.noInternet')}
diff --git a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx
index c6d172fc45..4a7c3f14fe 100644
--- a/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/discussion-thread.tsx
@@ -68,6 +68,8 @@ type DiscussionThreadProps = {
readonly onToggleExpand: () => void;
/** The viewer's GitHub login, passed to comment rows for self-target gating. */
readonly viewerLogin?: string | null;
+ /** Invoked when the inline reply field gains focus (see useReplyFocusScroll). */
+ readonly onReplyFocus?: () => void;
};
export function DiscussionThread({
@@ -78,6 +80,7 @@ export function DiscussionThread({
expanded,
onToggleExpand,
viewerLogin = null,
+ onReplyFocus,
}: Readonly) {
const resolve = useResolveThreadMutation();
const unresolve = useUnresolveThreadMutation();
@@ -164,6 +167,7 @@ export function DiscussionThread({
number={number}
commentId={firstComment.commentId}
reply={reply}
+ onInputFocus={onReplyFocus}
/>
) : null}
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx
new file mode 100644
index 0000000000..dc3b6e0c7b
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.test.tsx
@@ -0,0 +1,173 @@
+/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as pr-review-discussion-tab.test.tsx) */
+// The bottom CTA bar: static chrome that wraps a full-width primary Button.
+// Covers the label/icon/role wiring, the press wiring, the safe-area padding
+// while the keyboard is closed, and the keyboard-open lift (the request:
+// bottom action accessible with the keyboard open).
+
+import { createElement } from 'react';
+import TestRenderer, { act } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import '@/i18n';
+import type * as ReactI18next from 'react-i18next';
+import { PrCommentCta } from './pr-comment-cta';
+
+vi.mock('react-i18next', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => {
+ const i18n = actual.getI18n();
+ return { t: i18n.t.bind(i18n), i18n };
+ },
+ };
+});
+
+const insetsState = vi.hoisted(() => ({ bottom: 0 }));
+const keyboardSubscribers = vi.hoisted(() => ({
+ show: null as ((event: { endCoordinates: { height: number } }) => void) | null,
+ hide: null as (() => void) | null,
+}));
+
+vi.mock('react-native', () => ({
+ View: 'View',
+ Platform: { OS: 'ios' },
+ Keyboard: {
+ addListener: vi.fn((event: string, listener: (event?: unknown) => void) => {
+ if (event === 'keyboardWillShow') {
+ keyboardSubscribers.show = listener as (event: {
+ endCoordinates: { height: number };
+ }) => void;
+ }
+ if (event === 'keyboardWillHide') {
+ keyboardSubscribers.hide = listener as () => void;
+ }
+ return { remove: vi.fn() };
+ }),
+ },
+ AppState: { addEventListener: vi.fn(() => ({ remove: vi.fn() })) },
+}));
+
+vi.mock('react-native-safe-area-context', () => ({
+ useSafeAreaInsets: () => insetsState,
+}));
+
+vi.mock('@/lib/hooks/use-theme-colors', () => ({
+ useThemeColors: () => ({ primaryForeground: '#000000' }),
+}));
+
+vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus' }));
+
+const BASE_PROPS = {
+ onPress: vi.fn(() => undefined),
+ keyboardLift: true,
+};
+
+function mountCta(props: Partial = {}): TestRenderer.ReactTestRenderer {
+ const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
+ act(() => {
+ ref.current = TestRenderer.create(createElement(PrCommentCta, { ...BASE_PROPS, ...props }));
+ });
+ const renderer = ref.current;
+ if (!renderer) {
+ throw new Error('renderer was not created');
+ }
+ return renderer;
+}
+
+function paddedViews(renderer: TestRenderer.ReactTestRenderer): TestRenderer.ReactTestInstance[] {
+ return renderer.root.findAll(
+ node =>
+ typeof node.type === 'string' &&
+ (node.type as string) === 'View' &&
+ node.props.style != null &&
+ // AppAwareKeyboardPaddingView passes a style ARRAY ([style, {paddingBottom}]);
+ // the inner safe-area view passes an object.
+ (Array.isArray(node.props.style)
+ ? node.props.style.some((part: unknown) => {
+ if (part == null || typeof part !== 'object') {
+ return false;
+ }
+ return 'paddingBottom' in part;
+ })
+ : 'paddingBottom' in (node.props.style as Record))
+ );
+}
+
+function paddingValues(renderer: TestRenderer.ReactTestRenderer): number[] {
+ return paddedViews(renderer).flatMap(node => {
+ const style = node.props.style;
+ const parts = Array.isArray(style) ? style : [style];
+ return parts
+ .filter(
+ (part): part is Record =>
+ part != null && typeof part === 'object' && 'paddingBottom' in part
+ )
+ .map(part => part.paddingBottom as number);
+ });
+}
+
+describe('PrCommentCta', () => {
+ beforeEach(() => {
+ insetsState.bottom = 0;
+ keyboardSubscribers.show = null;
+ keyboardSubscribers.hide = null;
+ BASE_PROPS.onPress.mockClear();
+ });
+
+ it('renders the primary comment button with the CTA copy and role', () => {
+ const renderer = mountCta();
+ const button = renderer.root.find(node => String(node.type) === 'Button');
+ expect(button.props.onPress).toBe(BASE_PROPS.onPress);
+ expect(button.props.accessibilityRole).toBe('button');
+ expect(button.props.accessibilityLabel).toBe('Comment on this pull request');
+ expect(renderer.root.find(node => String(node.type) === 'MessageSquarePlus')).toBeDefined();
+ const label = renderer.root.find(node => String(node.type) === 'Text');
+ expect(label.props.children).toBe('Comment on this pull request');
+ });
+
+ it('press pushes through the onPress wiring', () => {
+ const renderer = mountCta();
+ act(() => {
+ (renderer.root.find(node => String(node.type) === 'Button').props.onPress as () => void)();
+ });
+ expect(BASE_PROPS.onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('pads above the device safe area while the keyboard is closed', () => {
+ insetsState.bottom = 34;
+ const renderer = mountCta();
+ const paddings = paddingValues(renderer);
+ // Keyboard-padding view reports 0 while closed; the inner view applies
+ // useDetailScreenBottomPadding (max(bottom, 16) + 16).
+ expect(paddings).toContain(0);
+ expect(paddings).toContain(50);
+ });
+
+ it('lifts above the keyboard while it is open', () => {
+ const renderer = mountCta();
+ if (!keyboardSubscribers.show) {
+ throw new Error('keyboard show listener was not registered');
+ }
+ act(() => {
+ keyboardSubscribers.show?.({ endCoordinates: { height: 336 } });
+ });
+ expect(paddingValues(renderer)).toContain(336);
+ });
+
+ it('does not react to keyboard events at all while the lift is gated off', () => {
+ // The host passes keyboardLift=false when another surface owns the
+ // keyboard (the conversation-comment formSheet): the bar must not even
+ // arm the padding view's listener, or a foreign keyboard shrinks the
+ // list viewport behind the sheet and parks the last thread's reply field
+ // under the bar (uxs3 spot check, e4-confirm-discard).
+ const renderer = mountCta({ keyboardLift: false });
+ expect(keyboardSubscribers.show).toBeNull();
+ expect(keyboardSubscribers.hide).toBeNull();
+ // The bar itself still renders; only the lift wrapper is gone.
+ expect(renderer.root.find(node => String(node.type) === 'Button')).toBeDefined();
+ expect(paddingValues(renderer)).not.toContain(0);
+ });
+});
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx
new file mode 100644
index 0000000000..67149e46e9
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/pr-comment-cta.tsx
@@ -0,0 +1,56 @@
+// Bottom call-to-action bar for the PR review Discussion tab.
+//
+// Static chrome: no async content renders here, so there is no skeleton and
+// no layout shift when the list content changes above it. The bar is a column
+// sibling under the tab body (see pr-review-discussion-tab.tsx): the body
+// keeps flex-1, the bar keeps its natural height.
+//
+// Keyboard: AppAwareKeyboardPaddingView lifts the bar above the keyboard
+// while it is open; while it is closed that padding is 0 and
+// useDetailScreenBottomPadding (applied to the inner view) clears the device
+// safe area. The two paddings are separate because the keyboard-padding view
+// owns its own paddingBottom style slot.
+
+import { MessageSquarePlus } from '@/components/ui/icons';
+import { useTranslation } from 'react-i18next';
+import { View } from 'react-native';
+
+import { AppAwareKeyboardPaddingView } from '@/components/kilo-chat/app-aware-keyboard-padding';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useThemeColors } from '@/lib/hooks/use-theme-colors';
+import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
+
+type PrCommentCtaProps = Readonly<{
+ onPress: () => void;
+ /**
+ * Whether the bar may lift above an open keyboard. Only while the
+ * Discussion tab is actually focused: the keyboard events are global, and
+ * a lift driven by a keyboard the user opened on ANOTHER surface (the
+ * conversation-comment formSheet) shrinks the list viewport behind the
+ * sheet and parks the last thread's reply field under the bar (uxs3 spot
+ * check, e4-confirm-discard). The host passes the screen's focus state.
+ */
+ keyboardLift: boolean;
+}>;
+
+export function PrCommentCta({ onPress, keyboardLift }: PrCommentCtaProps) {
+ const { t } = useTranslation();
+ const colors = useThemeColors();
+ const bottomPadding = useDetailScreenBottomPadding();
+ const bar = (
+
+
+
+ );
+ // Unmounted (not just un-padded) while unfocused: the padding view's own
+ // keyboard listener must not react to another surface's keyboard at all.
+ return keyboardLift ? {bar} : bar;
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts
new file mode 100644
index 0000000000..dcc47703ef
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test-helpers.ts
@@ -0,0 +1,302 @@
+// Test support for pr-conversation-comment-composer.test.tsx: the module-mock
+// harness, the shared fixtures, and the element-query helpers. The composer is
+// mounted as a plain function (no renderer), mirroring
+// pr-review-comment-composer.test.tsx; the React hook stub below keeps one
+// state box per hook slot: a setter writes the box, the next mount call reads
+// it back.
+//
+// The vi.mock registrations in this module body run while it is evaluated —
+// which is why the test file must import this module FIRST, before the
+// composer, '@/i18n', or any other module that has to resolve against these
+// mocks.
+
+import * as React from 'react';
+import type * as ReactI18next from 'react-i18next';
+import { expect, vi } from 'vitest';
+
+type AlertButton = { text?: string; style?: string; onPress?: () => void };
+export type AlertCall = { title: string; message: string; buttons: AlertButton[] };
+export type InlineErrorProps = {
+ inlineError?: string;
+ inlineErrorKind?: string;
+ inlineErrorIsLocal?: boolean;
+};
+/** A dismissal trigger: the control the user presses to leave the composer. */
+export type Trigger = (element: React.ReactElement) => void;
+
+const hoisted = vi.hoisted(() => ({
+ hookState: { boxes: [] as unknown[], cursor: 0 },
+ alertCalls: [] as AlertCall[],
+ addCommentMocks: {
+ mutateAsync: vi.fn<() => Promise>(),
+ isPending: false,
+ error: null as unknown,
+ },
+ draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({
+ settled: true,
+ value: null,
+ })),
+ termsGateMock: vi.fn(),
+ // The composer arms the hardware-back listener once per mount on every
+ // platform (no fork); the test invokes the captured handler directly.
+ backHandler: { current: null as null | (() => boolean) },
+ platformMock: { OS: 'ios' as string },
+ // The ledger persistence-failure marker is the one server failure that
+ // keeps Comment down; the marker check is flipped per test.
+ persistenceFailed: { value: false },
+ // The ledger ambiguous marker flips the same way: the verify-before-
+ // retrying copy, never the generic retryable one.
+ ambiguous: { value: false },
+ // The committed connectivity the submit gate reads. 'online' by default;
+ // the offline-gate tests flip it to 'offline'.
+ connectivity: { value: 'online' as 'online' | 'offline' | 'unknown' },
+}));
+
+export const hookState = hoisted.hookState;
+export const alertCalls = hoisted.alertCalls;
+export const addCommentMocks = hoisted.addCommentMocks;
+export const draftLoadMock = hoisted.draftLoadMock;
+export const termsGateMock = hoisted.termsGateMock;
+export const backHandler = hoisted.backHandler;
+export const platformMock = hoisted.platformMock;
+export const persistenceFailed = hoisted.persistenceFailed;
+export const ambiguous = hoisted.ambiguous;
+export const connectivity = hoisted.connectivity;
+
+vi.mock('react-i18next', async importOriginal => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => {
+ const i18n = actual.getI18n();
+ return { t: i18n.t.bind(i18n), i18n };
+ },
+ };
+});
+
+vi.mock('react', async () => {
+ const actual = await vi.importActual('react');
+ return {
+ ...actual,
+ useState: vi.fn((initial: T) => {
+ const index = hookState.cursor;
+ hookState.cursor += 1;
+ if (hookState.boxes.length <= index) {
+ hookState.boxes.push(initial);
+ }
+ const write = (value: T) => {
+ hookState.boxes[index] = value;
+ };
+ return [hookState.boxes[index] as T, write] as [T, (value: T) => void];
+ }),
+ useMemo: vi.fn((factory: () => T) => factory()),
+ useRef: vi.fn((initial: T) => {
+ const ref: React.RefObject = { current: initial };
+ return ref;
+ }),
+ useEffect: vi.fn((effect: React.EffectCallback) => {
+ effect();
+ }),
+ useCallback: vi.fn( unknown>(fn: T) => fn),
+ };
+});
+
+vi.mock('react-native', () => ({
+ Alert: {
+ alert: (title: string, message: string, buttons: AlertCall['buttons']) => {
+ alertCalls.push({ title, message, buttons });
+ },
+ },
+ BackHandler: {
+ addEventListener: (event: string, handler: () => boolean) => {
+ const armed = event === 'hardwareBackPress';
+ if (armed) {
+ backHandler.current = handler;
+ }
+ return {
+ remove: () => {
+ if (armed) {
+ backHandler.current = null;
+ }
+ },
+ };
+ },
+ },
+ Keyboard: { addListener: vi.fn(() => ({ remove: vi.fn() })) },
+ ScrollView: 'ScrollView',
+ View: 'View',
+ TextInput: 'TextInput',
+ Platform: platformMock,
+}));
+
+vi.mock('expo-haptics', () => ({
+ notificationAsync: vi.fn(),
+ NotificationFeedbackType: { Success: 'Success' },
+}));
+
+vi.mock('@/components/ui/button', () => ({ Button: 'Button' }));
+vi.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+
+vi.mock('@/components/pr-review/pr-form-sheet-chrome', () => ({
+ PrFormSheetHeader: 'PrFormSheetHeader',
+ PrFormSheetFooter: 'PrFormSheetFooter',
+}));
+
+vi.mock('@/components/pr-review/composer-inline-error', () => ({
+ ComposerInlineError: 'ComposerInlineError',
+}));
+
+vi.mock('@/components/pr-review/pr-review-comment-composer-parts', () => ({
+ CommentBodyField: 'CommentBodyField',
+}));
+
+vi.mock('@/components/pr-review/discussion/reply-input', () => ({
+ ensureTermsAcceptedOutcome: termsGateMock,
+}));
+
+vi.mock('@/lib/hooks/use-current-user-id', () => ({
+ useCurrentUserId: () => ({ userId: 'u1', isLoading: false }),
+}));
+
+vi.mock('@/lib/persist/drafts', () => ({
+ saveDraft: vi.fn(),
+ clearDraft: vi.fn(),
+ prConversationCommentDraftKey: vi.fn(() => 'pr-conversation-comment:key'),
+}));
+
+vi.mock('@/lib/persist/use-draft-load', () => ({
+ useFencedDraftLoad: () => draftLoadMock(),
+}));
+
+vi.mock('@/lib/persist/use-draft-flush', () => ({
+ useDraftFlushOnBackground: vi.fn(),
+}));
+
+vi.mock('@/lib/pr-review/merge/pr-operation-ledger', () => ({
+ isPrOperationPersistenceFailed: () => persistenceFailed.value,
+ isPrOperationAmbiguous: () => ambiguous.value,
+}));
+
+// The submit gate reads the committed connectivity snapshot. The real module
+// pulls in NetInfo + the app's own probe store, which the node environment
+// cannot resolve; the gate's decision is flipped per test instead.
+vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
+ getCommittedConnectivityStatus: () => connectivity.value,
+}));
+
+vi.mock('@/lib/pr-review/discussion/use-review-discussion-mutations', () => ({
+ useAddPrCommentMutation: () => addCommentMocks,
+}));
+
+// The two in-sheet dismissal triggers; the hardware-back trigger needs the
+// harness's listener capture, so it joins them in dismissTriggers below.
+export const footerCancelTrigger: Trigger = element => {
+ pressButton(element, 'Cancel');
+};
+const headerCloseTrigger: Trigger = element => {
+ (requireByType(element, 'PrFormSheetHeader').props as { onBack?: () => void }).onBack?.();
+};
+
+/** Presses the Button whose accessibilityLabel matches. */
+export function pressButton(element: React.ReactElement, label: string): void {
+ const button = buttonByLabel(element, label);
+ (button.props as { onPress?: () => void }).onPress?.();
+}
+
+/** Returns the Button whose accessibilityLabel matches, failing when absent. */
+export function buttonByLabel(element: React.ReactElement, label: string): React.ReactElement {
+ const button = findAllByType(element, 'Button').find(
+ candidate => (candidate.props as { accessibilityLabel?: string }).accessibilityLabel === label
+ );
+ if (!button) {
+ throw new Error(`Button "${label}" not found`);
+ }
+ return button;
+}
+
+/** Types into the mounted composer's body field. */
+export function typeBody(element: React.ReactElement, text: string): void {
+ const field = requireByType(element, 'CommentBodyField');
+ (field.props as { onChangeText?: (value: string) => void }).onChangeText?.(text);
+}
+
+/** Presses the discard Alert's Keep editing button. */
+export function pressKeepEditing(call: AlertCall): void {
+ call.buttons.find(button => button.text === 'Keep editing')?.onPress?.();
+}
+
+/** Presses the discard Alert's destructive Discard button. */
+export function pressDiscard(call: AlertCall): void {
+ call.buttons.find(button => button.style === 'destructive')?.onPress?.();
+}
+
+/** Drains the microtask queue plus one macrotask tick. */
+export async function flushMicrotasks(): Promise {
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+}
+
+function findAllByType(node: unknown, type: string): React.ReactElement[] {
+ const found: React.ReactElement[] = [];
+ const walk = (current: unknown): void => {
+ if (Array.isArray(current)) {
+ for (const child of current) {
+ walk(child);
+ }
+ return;
+ }
+ if (!React.isValidElement(current)) {
+ return;
+ }
+ if (current.type === type) {
+ found.push(current);
+ }
+ const children = (current.props as Record).children;
+ if (Array.isArray(children)) {
+ for (const child of children) {
+ walk(child);
+ }
+ } else if (children != null) {
+ walk(children);
+ }
+ };
+ walk(node);
+ return found;
+}
+
+export function requireByType(node: unknown, type: string): React.ReactElement {
+ const element = findAllByType(node, type)[0];
+ if (!element) {
+ throw new Error(`${type} not found`);
+ }
+ return element;
+}
+
+export const baseProps = { owner: 'octocat', repo: 'hello', number: 7, onDismiss: vi.fn() };
+
+export const DRAFT_KEY = 'pr-conversation-comment:key';
+
+// The three dismissal triggers (footer Cancel, header close, hardware back —
+// armed by every mount, on every platform) must run the same gate. The back
+// trigger also asserts the event is consumed: handleCancel owns the pop, so
+// the router must not dismiss the sheet a second time under the dialog.
+export const dismissTriggers: readonly (readonly [string, Trigger])[] = [
+ ['the footer Cancel', footerCancelTrigger],
+ ['the header close', headerCloseTrigger],
+ [
+ 'the hardware back',
+ () => {
+ expect(backHandler.current?.()).toBe(true);
+ },
+ ],
+];
+
+/** Returns the last recorded Alert call, failing when none was shown. */
+export const lastAlert = (): AlertCall => {
+ const call = alertCalls.at(-1);
+ if (!call) {
+ throw new Error('No discard Alert was shown');
+ }
+ return call;
+};
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx
new file mode 100644
index 0000000000..e824dd0b06
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.test.tsx
@@ -0,0 +1,359 @@
+// Clear-rule and state coverage for the conversation (issue) comment
+// composer's durable draft: cleared on a successful post and on a confirmed
+// discard, kept on every failure path and on a keep-editing discard.
+// `Alert.alert` is captured so the test can press the gate's buttons.
+//
+// The module mocks, fixtures, and element-query helpers live in
+// pr-conversation-comment-composer.test-helpers. That import MUST stay first:
+// the helpers register the module mocks while they are evaluated, and the
+// composer, '@/i18n', and every mocked module below must resolve against
+// them. The composer itself is mounted as a plain function (no renderer),
+// mirroring pr-review-comment-composer.test.tsx.
+
+import type * as React from 'react';
+import {
+ addCommentMocks,
+ alertCalls,
+ ambiguous,
+ backHandler,
+ baseProps,
+ buttonByLabel,
+ connectivity,
+ dismissTriggers,
+ DRAFT_KEY,
+ draftLoadMock,
+ flushMicrotasks,
+ footerCancelTrigger,
+ hookState,
+ type InlineErrorProps,
+ lastAlert,
+ persistenceFailed,
+ platformMock,
+ pressButton,
+ pressDiscard,
+ pressKeepEditing,
+ requireByType,
+ termsGateMock,
+ typeBody,
+} from './pr-conversation-comment-composer.test-helpers';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import '@/i18n';
+import * as Haptics from 'expo-haptics';
+import { PrConversationCommentComposer } from './pr-conversation-comment-composer';
+import { clearDraft, saveDraft } from '@/lib/persist/drafts';
+
+function mountComposer(): React.ReactElement {
+ // One render pass per mount call: the cursor restarts at 0 while the boxes
+ // persist, mirroring React's state-across-renders semantics.
+ hookState.cursor = 0;
+ // eslint-disable-next-line new-cap
+ return PrConversationCommentComposer(baseProps);
+}
+
+describe('PrConversationCommentComposer', () => {
+ beforeEach(() => {
+ hookState.boxes = [];
+ hookState.cursor = 0;
+ alertCalls.length = 0;
+ backHandler.current = null;
+ // The arming no longer depends on the platform; the iOS case (where RN
+ // no-ops the event) has its own test below.
+ platformMock.OS = 'android';
+ persistenceFailed.value = false;
+ ambiguous.value = false;
+ connectivity.value = 'online';
+ addCommentMocks.mutateAsync.mockReset();
+ addCommentMocks.isPending = false;
+ addCommentMocks.error = null;
+ draftLoadMock.mockReturnValue({ settled: true, value: null });
+ termsGateMock.mockReset().mockResolvedValue({ kind: 'accepted' });
+ vi.clearAllMocks();
+ // A successful clear is the default; the discard flow only dismisses
+ // after the stored draft is confirmed removed.
+ vi.mocked(clearDraft).mockResolvedValue(true);
+ });
+
+ it('posts nothing on an empty body and shows the local empty-body error', () => {
+ let element = mountComposer();
+ pressButton(element, 'Comment');
+
+ expect(addCommentMocks.mutateAsync).not.toHaveBeenCalled();
+ expect(clearDraft).not.toHaveBeenCalled();
+
+ element = mountComposer();
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe('Comment body cannot be empty.');
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request');
+ expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true);
+ // The local validation error keeps Comment down until the body changes:
+ // retrying an empty post is the same dead end, so this is the one
+ // bad-request that blocks (the server bad-request must not).
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(true);
+ });
+
+ it('re-enables Comment for retry after a server bad-request failure', async () => {
+ // The typed text survives the failed post through the durable draft (the
+ // helpers module recreates refs per mount call, so the seed restores it).
+ draftLoadMock.mockReturnValue({ settled: true, value: 'hello' });
+ const error = new Error('rejected');
+ Object.assign(error, { data: { code: 'BAD_REQUEST' } });
+ addCommentMocks.error = error;
+ let element = mountComposer();
+ element = mountComposer();
+
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe(
+ "This comment can't be posted. The pull request may have changed."
+ );
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request');
+ // The failed post must never dead-end the composer: the typed text is
+ // intact and Comment is live again for the retry (uxs2 spot check).
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(
+ false
+ );
+ pressButton(element, 'Comment');
+ await flushMicrotasks();
+ expect(addCommentMocks.mutateAsync).toHaveBeenCalledWith({
+ owner: 'octocat',
+ repo: 'hello',
+ number: 7,
+ body: 'hello',
+ });
+ });
+
+ it('keeps Comment down only for the retry-blocking ledger persistence marker', () => {
+ persistenceFailed.value = true;
+ addCommentMocks.error = new Error('We could not record this action. Please try again later.');
+ let element = mountComposer();
+ element = mountComposer();
+
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe(
+ 'We could not record this action. Please try again later.'
+ );
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('bad-request');
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(true);
+
+ // Editing the body starts a fresh intent (new fingerprint, rotated key),
+ // so the block lifts with the error.
+ element = mountComposer();
+ typeBody(element, 'edited');
+ element = mountComposer();
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(
+ false
+ );
+ });
+
+ it('saves typed text to the durable draft', () => {
+ typeBody(mountComposer(), 'hello');
+
+ expect(saveDraft).toHaveBeenCalledWith('u1', DRAFT_KEY, 'hello');
+ });
+
+ it('clears the draft, dismisses, and fires success haptics on a successful post', async () => {
+ addCommentMocks.mutateAsync.mockResolvedValueOnce({});
+ const element = mountComposer();
+ typeBody(element, 'hello');
+ pressButton(element, 'Comment');
+ await flushMicrotasks();
+
+ expect(addCommentMocks.mutateAsync).toHaveBeenCalledWith({
+ owner: 'octocat',
+ repo: 'hello',
+ number: 7,
+ body: 'hello',
+ });
+ expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY);
+ expect(baseProps.onDismiss).toHaveBeenCalledTimes(1);
+ expect(Haptics.notificationAsync).toHaveBeenCalledWith(
+ Haptics.NotificationFeedbackType.Success
+ );
+ });
+
+ it('keeps the draft and shows the retryable copy (never the raw provider error) on a failed post', async () => {
+ addCommentMocks.mutateAsync.mockRejectedValueOnce(new Error('Network request failed'));
+ let element = mountComposer();
+ typeBody(element, 'hello');
+ pressButton(element, 'Comment');
+ await flushMicrotasks();
+
+ // The failure is preserved on the mutation error, so the next mount
+ // mirrors it into the inline box.
+ addCommentMocks.error = new Error('Network request failed');
+ element = mountComposer();
+ element = mountComposer();
+
+ const inline = requireByType(element, 'ComposerInlineError');
+ // The raw provider message (the backend's GitHub access/install text is
+ // actionable to nobody) never reaches the inline box: the specified
+ // retryable copy does (uxs3 spot check, e6-offline-banner).
+ expect((inline.props as InlineErrorProps).inlineError).toBe('Could not post comment.');
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable');
+ // Draft intact: no clear, the typed text was saved.
+ expect(clearDraft).not.toHaveBeenCalled();
+ expect(saveDraft).toHaveBeenCalledWith('u1', DRAFT_KEY, 'hello');
+ expect(baseProps.onDismiss).not.toHaveBeenCalled();
+ // Retry stays offered: Comment is enabled for the same tap to re-post.
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(
+ false
+ );
+ });
+
+ it('shows the verify-before-retrying copy for the ambiguous ledger marker, not the generic one', () => {
+ ambiguous.value = true;
+ addCommentMocks.error = new Error('boom');
+ mountComposer();
+ const element = mountComposer();
+
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe(
+ "Couldn't confirm — check the PR before retrying."
+ );
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable');
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(
+ false
+ );
+ });
+
+ it('fails a submit while CONFIRMED offline at once with the retryable copy — no request, no spinner, retry offered', async () => {
+ // The hang the spot check caught: with the offline banner up, the tap
+ // started a request that died on the 15s UI deadline behind a spinner
+ // with a disabled Cancel, so retry was never offered (uxs3, e6-offline-
+ // hang / e6-still-pending / e6). The gate rejects locally instead:
+ // nothing is pending, the draft stays, Comment stays enabled.
+ connectivity.value = 'offline';
+ let element = mountComposer();
+ typeBody(element, 'hello');
+ pressButton(element, 'Comment');
+
+ expect(addCommentMocks.mutateAsync).not.toHaveBeenCalled();
+ expect(termsGateMock).not.toHaveBeenCalled();
+
+ element = mountComposer();
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe('Could not post comment.');
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable');
+ // Local rejection: announced through the inline box, not a toast.
+ expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true);
+ expect((buttonByLabel(element, 'Comment').props as { disabled?: boolean }).disabled).toBe(
+ false
+ );
+ expect(clearDraft).not.toHaveBeenCalled();
+ expect(baseProps.onDismiss).not.toHaveBeenCalled();
+
+ // Back online: the SAME control posts again — the retry path is live.
+ // (The helpers module hands out fresh refs per mount call, so the body is
+ // typed into the current render, mirroring the durable-draft restore.)
+ connectivity.value = 'online';
+ addCommentMocks.mutateAsync.mockResolvedValueOnce({});
+ typeBody(element, 'hello');
+ pressButton(element, 'Comment');
+ await flushMicrotasks();
+ expect(addCommentMocks.mutateAsync).toHaveBeenCalledTimes(1);
+ });
+
+ it.each([
+ ['BAD_REQUEST', "This comment can't be posted. The pull request may have changed."],
+ ['FORBIDDEN', "You don't have permission to comment on this pull request."],
+ ])('maps a %s reject to the comment inline copy', (code, message) => {
+ const error = new Error('rejected');
+ Object.assign(error, { data: { code } });
+ addCommentMocks.error = error;
+ mountComposer();
+ const element = mountComposer();
+
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe(message);
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe(
+ code === 'BAD_REQUEST' ? 'bad-request' : 'forbidden'
+ );
+ });
+
+ it('disables submit and the input while the mutation is pending', () => {
+ addCommentMocks.isPending = true;
+ const element = mountComposer();
+
+ const button = requireByType(element, 'Button');
+ const field = requireByType(element, 'CommentBodyField');
+ expect((button.props as { disabled?: boolean }).disabled).toBe(true);
+ expect((button.props as { loading?: boolean }).loading).toBe(true);
+ expect((field.props as { isDisabled?: boolean }).isDisabled).toBe(true);
+ });
+
+ it.each(dismissTriggers)(
+ 'runs the discard gate on %s: keep-editing keeps, discard clears',
+ async (_name, trigger) => {
+ let element = mountComposer();
+ typeBody(element, 'hello');
+ trigger(element);
+ expect(lastAlert().buttons.map(button => button.text)).toEqual(['Keep editing', 'Discard']);
+
+ pressKeepEditing(lastAlert());
+ expect(clearDraft).not.toHaveBeenCalled();
+ expect(baseProps.onDismiss).not.toHaveBeenCalled();
+
+ // Confirmed discard: the clear settles BEFORE the dismiss, so the next
+ // open can never load the discarded text from the store mid-removal.
+ element = mountComposer();
+ typeBody(element, 'hello again');
+ trigger(element);
+ pressDiscard(lastAlert());
+ expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY);
+ await flushMicrotasks();
+ expect(baseProps.onDismiss).toHaveBeenCalledTimes(1);
+ }
+ );
+
+ it.each(dismissTriggers)(
+ 'dismisses %s with an empty body without a discard confirm',
+ (_name, trigger) => {
+ const element = mountComposer();
+ trigger(element);
+
+ expect(alertCalls).toHaveLength(0);
+ expect(clearDraft).not.toHaveBeenCalled();
+ expect(baseProps.onDismiss).toHaveBeenCalledTimes(1);
+ }
+ );
+
+ it('arms the hardware back listener on iOS too: one implementation, no platform fork', () => {
+ // On iOS RN ships BackHandler as a never-firing no-op, so arming the
+ // listener unconditionally is safe; the composer must not fork on the
+ // platform to skip it.
+ platformMock.OS = 'ios';
+ mountComposer();
+
+ expect(backHandler.current).toBeTypeOf('function');
+ });
+
+ it('stays on the composer with a retryable error when the discard clear fails', async () => {
+ vi.mocked(clearDraft).mockResolvedValueOnce(false);
+ let element = mountComposer();
+ typeBody(element, 'hello');
+ footerCancelTrigger(element);
+ pressDiscard(lastAlert());
+ await flushMicrotasks();
+
+ expect(clearDraft).toHaveBeenCalledWith('u1', DRAFT_KEY);
+ // The stored draft could not be removed: dismissing would resurface the
+ // text on the next open, so the sheet stays and Cancel retries the clear.
+ expect(baseProps.onDismiss).not.toHaveBeenCalled();
+
+ element = mountComposer();
+ const inline = requireByType(element, 'ComposerInlineError');
+ expect((inline.props as InlineErrorProps).inlineError).toBe(
+ 'Could not discard the draft. Please try again.'
+ );
+ expect((inline.props as InlineErrorProps).inlineErrorKind).toBe('retryable');
+ expect((inline.props as InlineErrorProps).inlineErrorIsLocal).toBe(true);
+ });
+
+ it('seeds the body field from the settled draft', () => {
+ draftLoadMock.mockReturnValue({ settled: true, value: 'saved comment' });
+ const element = mountComposer();
+
+ const field = requireByType(element, 'CommentBodyField');
+ expect((field.props as { defaultValue?: string }).defaultValue).toBe('saved comment');
+ });
+});
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx
new file mode 100644
index 0000000000..91e3fbde0c
--- /dev/null
+++ b/apps/mobile/src/components/pr-review/discussion/pr-conversation-comment-composer.tsx
@@ -0,0 +1,379 @@
+// Composer for a regular PR conversation (issue) comment, opened from the
+// Discussion tab's bottom CTA bar. Body-only sibling of
+// pr-review-comment-composer.tsx: an issue comment needs no path/side/line
+// anchor and no commit sha, so there is no getPullRequest fetch, no
+// Add-to-review, and no suggestion insert.
+//
+// The durable draft (per account and PR) survives dismissal and failed
+// submissions; it is cleared only on a successful post or a confirmed
+// discard. Failures mirror ReplyInput's inline classification and preserve
+// the draft so the user can retry without retyping.
+
+import * as Haptics from 'expo-haptics';
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, BackHandler, Keyboard, ScrollView, type TextInput, View } from 'react-native';
+import {
+ ComposerInlineError,
+ type ComposerInlineErrorKind,
+} from '@/components/pr-review/composer-inline-error';
+import { PrFormSheetFooter, PrFormSheetHeader } from '@/components/pr-review/pr-form-sheet-chrome';
+import { CommentBodyField } from '@/components/pr-review/pr-review-comment-composer-parts';
+import { ensureTermsAcceptedOutcome } from '@/components/pr-review/discussion/reply-input';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
+import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state';
+import { clearDraft, prConversationCommentDraftKey, saveDraft } from '@/lib/persist/drafts';
+import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush';
+import { useFencedDraftLoad } from '@/lib/persist/use-draft-load';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
+import {
+ isPrOperationAmbiguous,
+ isPrOperationPersistenceFailed,
+} from '@/lib/pr-review/merge/pr-operation-ledger';
+import { useAddPrCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations';
+import { i18n } from '@/i18n';
+
+type PrConversationCommentComposerProps = Readonly<{
+ owner: string;
+ repo: string;
+ number: number;
+ onDismiss: () => void;
+}>;
+
+export function PrConversationCommentComposer({
+ owner,
+ repo,
+ number,
+ onDismiss,
+}: PrConversationCommentComposerProps) {
+ const { t } = useTranslation();
+ const addComment = useAddPrCommentMutation();
+
+ // Durable comment draft, keyed by account and PR. Nothing is saved or
+ // restored while the user id is unknown.
+ const { userId, isLoading: isIdentityLoading } = useCurrentUserId();
+ const commentDraftKey = prConversationCommentDraftKey(owner, repo, number);
+ const draft = useFencedDraftLoad({ userId, isIdentityLoading, entityKey: commentDraftKey });
+ useDraftFlushOnBackground(userId, commentDraftKey, true);
+
+ // iOS uncontrolled: ref + defaultValue; no value+state.
+ const bodyRef = useRef('');
+ const bodyInputRef = useRef(null);
+ const scrollRef = useRef(null);
+
+ // Seed the refs from the settled draft once per identity/destination, during
+ // render, before the body field mounts. Re-seeding on a key change (and
+ // resetting to empty when there is no draft) keeps a reused instance from
+ // showing or saving the previous account's or PR's text.
+ const draftSeedKeyRef = useRef(null);
+ const draftSeedKey = `${userId ?? 'anonymous'}\u0000${commentDraftKey}`;
+ if (draft.settled && draftSeedKeyRef.current !== draftSeedKey) {
+ draftSeedKeyRef.current = draftSeedKey;
+ bodyRef.current = draft.value ?? '';
+ }
+
+ const [inlineError, setInlineError] = useState(null);
+ const [inlineErrorKind, setInlineErrorKind] = useState(null);
+ const [inlineErrorIsLocal, setInlineErrorIsLocal] = useState(false);
+ // The ONLY server failure that must block a retry is the ledger persistence
+ // marker (the row never became `reconcile_pending`, so the ambiguous-outcome
+ // promise does not hold). Every other server rejection — bad-request
+ // included — keeps Comment enabled: the failed post must never dead-end the
+ // composer, and a blind retry is ledger-safe (non-retryable failures rotate
+ // the operation key, retryable ones dedupe on the same key).
+ const [retryBlocked, setRetryBlocked] = useState(false);
+
+ const isSubmitting = addComment.isPending;
+
+ // automaticallyAdjustKeyboardInsets can scroll the focused field under the
+ // pinned header. Compact kb layout fits at offset 0 — snap back so body +
+ // footer CTAs stay in the inset viewport together.
+ useEffect(() => {
+ const sub = Keyboard.addListener('keyboardDidShow', () => {
+ requestAnimationFrame(() => {
+ scrollRef.current?.scrollTo({ y: 0, animated: false });
+ });
+ });
+ return () => {
+ sub.remove();
+ };
+ }, []);
+
+ // Mirror the mutation error into the inline box (ReplyInput's inline
+ // classification). The add-comment mutation is NOT optimistic, so the user
+ // can hit the inline error and retry without waiting for a re-fetch. Every
+ // failure path preserves the draft, and every failure path except the
+ // retry-blocking ledger marker re-enables Comment for the retry.
+ useEffect(() => {
+ if (!addComment.error) {
+ return;
+ }
+ setInlineErrorIsLocal(false);
+ // The ledger persistence-failure marker is retry-blocking: the same
+ // operation key must not be retried.
+ if (isPrOperationPersistenceFailed(addComment.error)) {
+ setInlineError(i18n.t('prReview.operation.persistenceFailed'));
+ setInlineErrorKind('bad-request');
+ setRetryBlocked(true);
+ return;
+ }
+ setRetryBlocked(false);
+ const classification = classifyPrReviewMutationError(addComment.error);
+ if (isPrOperationAmbiguous(addComment.error)) {
+ // The effect may have committed: the user must verify the PR, not be
+ // shown the generic retryable copy. Retry stays enabled.
+ setInlineError(i18n.t('prReview.operation.ambiguous'));
+ setInlineErrorKind('retryable');
+ } else if (classification.kind === 'terms-required') {
+ void (async () => {
+ const outcome = await ensureTermsAcceptedOutcome();
+ if (outcome.kind === 'accepted') {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ } else if (outcome.kind === 'outdated') {
+ setInlineError(t('prReview.discussion.termsOutdatedCopy'));
+ setInlineErrorKind('bad-request');
+ } else if (outcome.kind === 'unknown') {
+ setInlineError(t('prReview.discussion.termsCheckRetryCopy'));
+ setInlineErrorKind('retryable');
+ } else {
+ setInlineError(t('prReview.discussion.termsCopy'));
+ setInlineErrorKind(null);
+ }
+ })();
+ } else if (classification.kind === 'bad-request') {
+ setInlineError(t('prReview.discussion.commentBadRequest'));
+ setInlineErrorKind('bad-request');
+ } else if (classification.kind === 'forbidden') {
+ // The provider-permission fallback: the PR DTO carries no can-comment
+ // signal, so a permission rejection surfaces here as inline copy.
+ setInlineError(t('prReview.discussion.commentForbidden'));
+ setInlineErrorKind('forbidden');
+ } else if (classification.kind === 'reconnect') {
+ setInlineError(t('prReview.connectionExpired'));
+ setInlineErrorKind('reconnect');
+ } else {
+ // A generic/transient failure shows the specified retryable copy, never
+ // the raw provider error (the backend's GitHub access/install text is
+ // actionable to nobody; the toast and the inline box must agree). The
+ // draft stays intact and Comment stays enabled for the retry (uxs3 spot
+ // check, e6-offline-banner).
+ setInlineError(t('prReview.mutationError.couldNotPostComment'));
+ setInlineErrorKind('retryable');
+ }
+ }, [addComment.error, t]);
+
+ function handleBodyChange(value: string) {
+ bodyRef.current = value;
+ // A bad-request error clears on body edit — including the retry-blocking
+ // persistence marker, whose edit changes the intent fingerprint anyway;
+ // forbidden/reconnect stay until the next submit.
+ if (inlineErrorKind === 'bad-request') {
+ setInlineError(null);
+ setInlineErrorKind(null);
+ setInlineErrorIsLocal(false);
+ setRetryBlocked(false);
+ }
+ if (userId) {
+ saveDraft(userId, commentDraftKey, value);
+ }
+ }
+
+ async function handleSubmit() {
+ if (addComment.isPending) {
+ return;
+ }
+ const body = bodyRef.current;
+ if (body.trim().length === 0) {
+ setInlineError(t('prReview.composer.bodyEmpty'));
+ setInlineErrorKind('bad-request');
+ setInlineErrorIsLocal(true);
+ return;
+ }
+ setInlineError(null);
+ setInlineErrorKind(null);
+ setInlineErrorIsLocal(false);
+ setRetryBlocked(false);
+ // Confirmed offline: fail the submit at once with the retryable copy
+ // instead of starting a request that hangs on the UI deadline behind a
+ // spinner with a disabled Cancel (uxs3 spot check, e6-offline-hang). The
+ // draft stays intact, nothing is pending, and the same tap retries once
+ // the banner clears. This is a local rejection with no toast owner, so it
+ // announces through AccessibleStatus.
+ if (getCommittedConnectivityStatus() === 'offline') {
+ setInlineError(t('prReview.mutationError.couldNotPostComment'));
+ setInlineErrorKind('retryable');
+ setInlineErrorIsLocal(true);
+ return;
+ }
+ const outcome = await ensureTermsAcceptedOutcome();
+ if (outcome.kind === 'outdated') {
+ setInlineError(t('prReview.discussion.termsOutdatedCopy'));
+ setInlineErrorKind('bad-request');
+ setInlineErrorIsLocal(false);
+ return;
+ }
+ if (outcome.kind === 'dismissed') {
+ return;
+ }
+ try {
+ await addComment.mutateAsync({ owner, repo, number, body });
+ if (userId) {
+ void clearDraft(userId, commentDraftKey);
+ }
+ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
+ // The mutation hook announces success for a11y; its settle invalidation
+ // renders the posted comment in the discussion behind the sheet.
+ onDismiss();
+ } catch {
+ // Classified into the inline box by the effect above; the draft is
+ // preserved so the user can retry.
+ }
+ }
+
+ function handleCancel() {
+ if (addComment.isPending) {
+ return;
+ }
+ if (bodyRef.current.trim().length > 0) {
+ Alert.alert(t('prReview.composer.discardTitle'), t('prReview.composer.discardMessage'), [
+ { text: t('common.keepEditing'), style: 'cancel' },
+ {
+ text: t('common.discard'),
+ style: 'destructive',
+ onPress: () => {
+ void (async () => {
+ // The clear must SETTLE before the dismiss: the next open's
+ // draft load races an in-flight removeItem otherwise, and the
+ // discarded text reappears for one open. When the clear FAILS,
+ // stay on the composer with the text intact (the drafts.ts
+ // contract for the returned boolean): dismissing anyway would
+ // resurface the draft on the next open as if the discard never
+ // happened. The Cancel/back gate is the retry CTA, so the
+ // inline error is retryable.
+ if (userId) {
+ const cleared = await clearDraft(userId, commentDraftKey);
+ if (!cleared) {
+ setInlineError(t('agentChat.newSession.discardFailed'));
+ setInlineErrorKind('retryable');
+ setInlineErrorIsLocal(true);
+ return;
+ }
+ }
+ onDismiss();
+ })();
+ },
+ },
+ ]);
+ return;
+ }
+ onDismiss();
+ }
+
+ // Latest-handler ref (the pr-diff-file-navigator.tsx pattern): the
+ // hardware-back listener is armed once per mount and must run the CURRENT
+ // render's gate. The first render can predate identity — a first-render
+ // closure would keep a null userId and silently skip the draft clear on a
+ // confirmed discard, and a stale isPending would let back pop the sheet
+ // mid-submit.
+ const handleCancelRef = useRef(handleCancel);
+ handleCancelRef.current = handleCancel;
+
+ // The hardware back press runs the same gate as the header close and the
+ // footer Cancel: text present asks before discarding. Without the
+ // interception the back press pops the sheet directly and the discard
+ // dialog never appears. `true` consumes the event — handleCancel owns the
+ // pop (onDismiss → router.back), so the router must not pop it a second
+ // time under the dialog. One implementation for both platforms: BackHandler
+ // is RN's cross-platform back API (Android fires it for the hardware/gesture
+ // back; on iOS RN ships it as a never-firing no-op, where the formSheet
+ // swipe-down is the back affordance and the durable draft covers it).
+ useEffect(() => {
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ handleCancelRef.current();
+ return true;
+ });
+ return () => {
+ sub.remove();
+ };
+ }, []);
+
+ // A failed post never dead-ends the composer: only the LOCAL empty-body
+ // validation and the retry-blocking persistence marker keep Comment down
+ // (both clear on the next edit or submit). forbidden/reconnect stay down
+ // because their recovery lives outside the submit button (the reconnect
+ // notice CTA / leaving the sheet); a server bad-request stays retryable so
+ // the user can re-post without retyping — the ledger dedupes the retry.
+ const submitDisabled =
+ isSubmitting ||
+ (inlineErrorKind === 'bad-request' && (inlineErrorIsLocal || retryBlocked)) ||
+ inlineErrorKind === 'forbidden' ||
+ inlineErrorKind === 'reconnect';
+
+ // PickerSheet invariant: [header, ScrollView] as direct children (no
+ // wrapper View, no sticky-footer sibling). Footer is trailing scroll
+ // content so keyboard insets keep CTAs tappable without overpainting the
+ // pinned header. The header close runs the SAME discard gate as the
+ // footer Cancel: text present asks before discarding (Android hardware
+ // back is intercepted into the same gate). Only a confirmed discard (or a
+ // successful post) clears the draft; a dismissal that keeps the text —
+ // keep editing, drag-down — relies on the durable draft, so the unmount
+ // flush must stay enabled.
+ return (
+ <>
+
+
+
+ {draft.settled ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx
index 88fdca7364..c2b7eea1e2 100644
--- a/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/pr-review-discussion-list.tsx
@@ -40,6 +40,19 @@ type PrReviewDiscussionListProps = {
readonly laterPageError: boolean;
readonly onLoadMore: () => void;
readonly onRetryLoadMore: () => void;
+ /**
+ * Invoked when a thread row's inline reply field gains focus, with the
+ * row's index. The tab scrolls the row above the keyboard-lifted bottom
+ * CTA bar (useReplyFocusScroll). Optional; absent = no scroll handling.
+ */
+ readonly onReplyInputFocus?: (index: number) => void;
+ /**
+ * Invoked with the list viewport's height on every layout commit. The tab
+ * anchors the keyboard-open reply scroll on the COMMITTED viewport — the
+ * CTA bar's keyboard lift lands asynchronously and shrinks this frame
+ * (useReplyFocusScroll). Optional; absent = no viewport reporting.
+ */
+ readonly onViewportLayout?: (height: number) => void;
};
export function PrReviewDiscussionList({
@@ -57,6 +70,8 @@ export function PrReviewDiscussionList({
laterPageError,
onLoadMore,
onRetryLoadMore,
+ onReplyInputFocus,
+ onViewportLayout,
}: Readonly) {
const trpc = useTRPC();
// Account-local hidden users (blocked + muted GitHub logins) filter rows.
@@ -138,6 +153,9 @@ export function PrReviewDiscussionList({
onToggleExpand={() => {
onToggleExpand(thread, index);
}}
+ onReplyFocus={() => {
+ onReplyInputFocus?.(index);
+ }}
/>
);
@@ -145,6 +163,9 @@ export function PrReviewDiscussionList({
contentContainerStyle={DISCUSSION_LIST_CONTENT_STYLE}
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
+ onLayout={event => {
+ onViewportLayout?.(event.nativeEvent.layout.height);
+ }}
ListFooterComponent={
{
@@ -33,14 +34,24 @@ vi.mock('react-i18next', async importOriginal => {
type AlertButton = { text?: string; onPress?: () => void };
type AlertCall = { title: string; message: string; buttons: AlertButton[] };
-const { alertCalls, getTermsStatusMock, acceptTermsMock, draftLoadMock } = vi.hoisted(() => ({
- alertCalls: [] as AlertCall[],
- getTermsStatusMock: vi.fn(),
- acceptTermsMock: vi.fn(),
- draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({
- settled: true,
- value: null,
- })),
+const { alertCalls, getTermsStatusMock, acceptTermsMock, draftLoadMock, connectivity } = vi.hoisted(
+ () => ({
+ alertCalls: [] as AlertCall[],
+ getTermsStatusMock: vi.fn(),
+ acceptTermsMock: vi.fn(),
+ draftLoadMock: vi.fn((): { settled: boolean; value: string | null } => ({
+ settled: true,
+ value: null,
+ })),
+ // The committed connectivity the submit gate reads; 'online' by default,
+ // flipped per test. The real module pulls in NetInfo + the probe store,
+ // which the node environment cannot resolve.
+ connectivity: { value: 'online' as 'online' | 'offline' | 'unknown' },
+ })
+);
+
+vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
+ getCommittedConnectivityStatus: () => connectivity.value,
}));
vi.mock('react-native', () => ({
@@ -110,12 +121,29 @@ vi.mock('@/lib/hooks/use-current-user-id', () => ({
// `ReplyInput` is mounted by calling it as a plain function (no renderer), so
// the React hook primitives are stubbed to no-op/simple versions, mirroring
// pr-merge-sheet.test.tsx. The pure `ensureTermsAcceptedOutcome` tests above
-// do not touch these.
+// do not touch these. useState keeps a box per slot (same pattern as the
+// composer test) so a press can flip the inline-error state and the next
+// mount renders it.
+const hookState = vi.hoisted(() => ({ boxes: [] as unknown[], cursor: 0 }));
+
vi.mock('react', async () => {
const actual = await vi.importActual('react');
return {
...actual,
- useState: vi.fn((initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void]),
+ useState: vi.fn((initial: T) => {
+ const index = hookState.cursor;
+ hookState.cursor += 1;
+ if (hookState.boxes.length <= index) {
+ hookState.boxes.push(initial);
+ }
+ const write = (value: T) => {
+ hookState.boxes[index] =
+ typeof value === 'function'
+ ? (value as (prev: T) => T)(hookState.boxes[index] as T)
+ : value;
+ };
+ return [hookState.boxes[index] as T, write] as [T, (value: T) => void];
+ }),
useMemo: vi.fn((factory: () => T) => factory()),
useRef: vi.fn((initial: T) => {
const ref: React.RefObject = { current: initial };
@@ -260,6 +288,11 @@ function makeReply(mutate: unknown): ReplyMutation {
return { mutate, isPending: false, error: null } as unknown as ReplyMutation;
}
+/** A reply mutation result frozen in its ERROR state (no request in flight). */
+function makeFailedReply(error: unknown): ReplyMutation {
+ return { mutate: vi.fn(), isPending: false, error } as unknown as ReplyMutation;
+}
+
type FindElementArgs = {
node: unknown;
type: string;
@@ -300,16 +333,21 @@ function findElement({ node, type, prop, value }: FindElementArgs): React.ReactE
return null;
}
-/** Mounts ReplyInput, types a body, and presses the submit button. */
-function mountAndSubmit(reply: ReplyMutation): void {
+/** Mounts ReplyInput (one render pass: cursor restarts, boxes persist). */
+function mountReplyInput(reply: ReplyMutation): React.ReactElement {
+ hookState.cursor = 0;
// eslint-disable-next-line new-cap
- const element = ReplyInput({
+ return ReplyInput({
owner: 'octocat',
repo: 'hello',
number: 1,
commentId: 42,
reply,
});
+}
+
+/** Types a body into the mounted input and returns the submit button. */
+function typeAndSubmit(element: React.ReactElement, text = 'hello'): void {
const input = findElement({
node: element,
type: 'TextInput',
@@ -319,7 +357,7 @@ function mountAndSubmit(reply: ReplyMutation): void {
if (!input) {
throw new Error('Reply body TextInput not found');
}
- (input.props as { onChangeText?: (value: string) => void }).onChangeText?.('hello');
+ (input.props as { onChangeText?: (value: string) => void }).onChangeText?.(text);
const button = findElement({
node: element,
type: 'Button',
@@ -332,11 +370,58 @@ function mountAndSubmit(reply: ReplyMutation): void {
(button.props as { onPress?: () => void }).onPress?.();
}
+/** Drains the microtask queue plus one macrotask tick. */
+async function flushMacrotask(): Promise {
+ await new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+}
+
+/** The mounted tree's inline error Text (absent when no error renders). */
+function inlineErrorText(element: React.ReactElement): string | null {
+ const texts = ((): React.ReactElement[] => {
+ const found: React.ReactElement[] = [];
+ const walk = (node: unknown): void => {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ walk(child);
+ }
+ return;
+ }
+ if (!React.isValidElement(node)) {
+ return;
+ }
+ if (node.type === 'Text') {
+ found.push(node);
+ }
+ walk((node.props as Record).children);
+ };
+ walk(element);
+ return found;
+ })();
+ const error = texts.find(
+ text =>
+ typeof (text.props as { children?: unknown }).children === 'string' &&
+ ((text.props as { className?: string }).className ?? '').includes('text-destructive')
+ );
+ return error ? (error.props as { children: string }).children : null;
+}
+
+/** Mounts ReplyInput, types a body, and presses the submit button. */
+function mountAndSubmit(reply: ReplyMutation): void {
+ const element = mountReplyInput(reply);
+ typeAndSubmit(element);
+}
+
describe('ReplyInput draft clear on submit', () => {
beforeEach(() => {
+ hookState.boxes = [];
+ hookState.cursor = 0;
alertCalls.length = 0;
getTermsStatusMock.mockReset();
acceptTermsMock.mockReset();
+ connectivity.value = 'online';
+ draftLoadMock.mockReturnValue({ settled: true, value: null });
});
afterEach(() => {
@@ -379,20 +464,18 @@ describe('ReplyInput draft clear on submit', () => {
});
describe('ReplyInput seeds the field from the settled draft during render', () => {
+ beforeEach(() => {
+ hookState.boxes = [];
+ hookState.cursor = 0;
+ });
+
afterEach(() => {
vi.clearAllMocks();
});
- function mountReplyInput(): React.ReactElement | null {
+ function mountReplyBody(): React.ReactElement | null {
return findElement({
- // eslint-disable-next-line new-cap
- node: ReplyInput({
- owner: 'octocat',
- repo: 'hello',
- number: 1,
- commentId: 42,
- reply: makeReply(vi.fn()),
- }),
+ node: mountReplyInput(makeReply(vi.fn())),
type: 'TextInput',
prop: 'accessibilityLabel',
value: 'Reply body',
@@ -401,7 +484,7 @@ describe('ReplyInput seeds the field from the settled draft during render', () =
it('seeds the defaultValue from the settled draft value', () => {
draftLoadMock.mockReturnValue({ settled: true, value: 'saved reply' });
- const input = mountReplyInput();
+ const input = mountReplyBody();
if (!input) {
throw new Error('Reply body TextInput not found');
}
@@ -410,7 +493,7 @@ describe('ReplyInput seeds the field from the settled draft during render', () =
it('seeds an empty field when the settled draft has no value (no stale previous-thread text)', () => {
draftLoadMock.mockReturnValue({ settled: true, value: null });
- const input = mountReplyInput();
+ const input = mountReplyBody();
if (!input) {
throw new Error('Reply body TextInput not found');
}
@@ -419,16 +502,14 @@ describe('ReplyInput seeds the field from the settled draft during render', () =
});
describe('ReplyInput gates input on draft settle', () => {
+ beforeEach(() => {
+ hookState.boxes = [];
+ hookState.cursor = 0;
+ });
+
it('hides the input and disables submit until the draft settles', () => {
draftLoadMock.mockReturnValue({ settled: false, value: null });
- // eslint-disable-next-line new-cap
- const hidden = ReplyInput({
- owner: 'octocat',
- repo: 'hello',
- number: 1,
- commentId: 42,
- reply: makeReply(vi.fn()),
- });
+ const hidden = mountReplyInput(makeReply(vi.fn()));
expect(
findElement({
node: hidden,
@@ -449,14 +530,7 @@ describe('ReplyInput gates input on draft settle', () => {
expect((button.props as { disabled?: boolean }).disabled).toBe(true);
draftLoadMock.mockReturnValue({ settled: true, value: null });
- // eslint-disable-next-line new-cap
- const shown = ReplyInput({
- owner: 'octocat',
- repo: 'hello',
- number: 1,
- commentId: 42,
- reply: makeReply(vi.fn()),
- });
+ const shown = mountReplyInput(makeReply(vi.fn()));
expect(
findElement({
node: shown,
@@ -467,3 +541,87 @@ describe('ReplyInput gates input on draft settle', () => {
).not.toBeNull();
});
});
+
+// The failed-reply surface (uxs3 spot check, e6-offline-hang / e6-offline-
+// banner): a generic provider failure shows the specified retryable copy —
+// never the raw GitHub text — and a CONFIRMED-offline submit fails at once
+// with that copy, without a request, keeping Reply enabled for the retry.
+describe('ReplyInput failure copy and offline gate', () => {
+ beforeEach(() => {
+ hookState.boxes = [];
+ hookState.cursor = 0;
+ alertCalls.length = 0;
+ getTermsStatusMock.mockReset();
+ acceptTermsMock.mockReset();
+ connectivity.value = 'online';
+ draftLoadMock.mockReturnValue({ settled: true, value: null });
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('mirrors a generic provider failure as the retryable copy, never the raw message', () => {
+ const raw = new Error(
+ 'You do not have access to this repository. Install the Kilo GitHub App to continue.'
+ );
+ // The mirror effect writes the state boxes during this mount; the next
+ // render reads them back.
+ mountReplyInput(makeFailedReply(raw));
+ const shown = mountReplyInput(makeFailedReply(raw));
+ expect(inlineErrorText(shown)).toBe('Could not reply.');
+ // The raw provider text never reaches the tree at all.
+ expect(JSON.stringify(shown)).not.toContain('Kilo GitHub App');
+ // Retry stays offered: the button is not disabled by the retryable kind.
+ const button = findElement({
+ node: shown,
+ type: 'Button',
+ prop: 'accessibilityLabel',
+ value: 'Submit reply',
+ });
+ if (!button) {
+ throw new Error('Submit reply Button not found');
+ }
+ expect((button.props as { disabled?: boolean }).disabled).toBe(false);
+ });
+
+ it('mirrors the ambiguous ledger marker as the verify-before-retrying copy', () => {
+ const ambiguous = new Error(PR_OPERATION_AMBIGUOUS_MESSAGE);
+ mountReplyInput(makeFailedReply(ambiguous));
+ const shown = mountReplyInput(makeFailedReply(ambiguous));
+ expect(inlineErrorText(shown)).toBe(PR_OPERATION_AMBIGUOUS_MESSAGE);
+ });
+
+ it('fails a submit while CONFIRMED offline at once — no request, retryable copy, Reply stays enabled', async () => {
+ connectivity.value = 'offline';
+ const mutate = vi.fn();
+ const element = mountReplyInput(makeReply(mutate));
+ typeAndSubmit(element);
+ await flushMacrotask();
+
+ // No request was started (the hang behind the spinner with the disabled
+ // Cancel is structurally impossible now), and the Terms gate never ran.
+ expect(mutate).not.toHaveBeenCalled();
+ expect(getTermsStatusMock).not.toHaveBeenCalled();
+
+ const shown = mountReplyInput(makeReply(mutate));
+ expect(inlineErrorText(shown)).toBe('Could not reply.');
+ const button = findElement({
+ node: shown,
+ type: 'Button',
+ prop: 'accessibilityLabel',
+ value: 'Submit reply',
+ });
+ if (!button) {
+ throw new Error('Submit reply Button not found');
+ }
+ expect((button.props as { disabled?: boolean }).disabled).toBe(false);
+
+ // Back online: the same tap posts.
+ connectivity.value = 'online';
+ getTermsStatusMock.mockResolvedValue({ accepted: true, currentVersion: 'v1' });
+ typeAndSubmit(shown);
+ await flushMacrotask();
+ expect(mutate).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
index 3d6902ddee..832e8c7501 100644
--- a/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
+++ b/apps/mobile/src/components/pr-review/discussion/reply-input.tsx
@@ -15,13 +15,17 @@ import { Text } from '@/components/ui/text';
import { i18n } from '@/i18n';
import { WEB_BASE_URL } from '@/lib/config';
import { useCurrentUserId } from '@/lib/hooks/use-current-user-id';
+import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { clearDraft, prReplyDraftKey, saveDraft } from '@/lib/persist/drafts';
import { useDraftFlushOnBackground } from '@/lib/persist/use-draft-flush';
import { useFencedDraftLoad } from '@/lib/persist/use-draft-load';
import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import { type useReplyToCommentMutation } from '@/lib/pr-review/discussion/use-review-discussion-mutations';
-import { isPrOperationPersistenceFailed } from '@/lib/pr-review/merge/pr-operation-ledger';
+import {
+ isPrOperationAmbiguous,
+ isPrOperationPersistenceFailed,
+} from '@/lib/pr-review/merge/pr-operation-ledger';
import { trpcClient } from '@/lib/trpc';
/**
@@ -139,9 +143,22 @@ type ReplyInputProps = {
readonly number: number;
readonly commentId: number;
readonly reply: ReturnType;
+ /**
+ * Invoked when the reply field gains focus. The discussion tab uses it to
+ * scroll the focused thread row above the keyboard-lifted bottom CTA bar
+ * (see useReplyFocusScroll); optional because not every host scrolls.
+ */
+ readonly onInputFocus?: () => void;
};
-export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly) {
+export function ReplyInput({
+ owner,
+ repo,
+ number,
+ commentId,
+ reply,
+ onInputFocus,
+}: Readonly) {
const colors = useThemeColors();
const { t } = useTranslation();
const bodyRef = useRef('');
@@ -184,6 +201,13 @@ export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly {
const outcome = await ensureTermsAcceptedOutcome();
@@ -211,11 +235,11 @@ export function ReplyInput({ owner, repo, number, commentId, reply }: Readonly {
bodyRef.current = value;
if (userId) {
diff --git a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx
index c29a18c02d..a3bb280158 100644
--- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.test.tsx
@@ -7,6 +7,19 @@ import { PrReviewDiscussionTab } from './pr-review-discussion-tab';
const insetsState = vi.hoisted(() => ({ top: 0, bottom: 0, left: 0, right: 0 }));
+const pushMock = vi.hoisted(() => vi.fn());
+
+// The tab's screen focus drives the CTA bar's keyboard lift (a foreign
+// surface's keyboard must not lift the bar behind it — uxs3, e4-confirm-
+// discard). Flippable so the suite can mount the tab unfocused.
+const focusState = vi.hoisted(() => ({ value: true }));
+
+const replyScrollFns = vi.hoisted(() => ({
+ markFocus: vi.fn(),
+ onViewportLayout: vi.fn(),
+ invalidate: vi.fn(),
+}));
+
const discussionState = vi.hoisted(() => ({
query: {
isPending: false,
@@ -26,12 +39,21 @@ vi.mock('react-native', () => ({
View: 'View',
Platform: { OS: 'ios' },
}));
+vi.mock('expo-router', () => ({
+ useRouter: () => ({ push: pushMock }),
+ useIsFocused: () => focusState.value,
+}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => insetsState,
}));
vi.mock('@/lib/pr-review/discussion/use-pr-review-discussion-threads', () => ({
usePrReviewDiscussionThreads: () => discussionState,
}));
+vi.mock('@/lib/pr-review/discussion/use-reply-focus-scroll', () => ({
+ // The tab-level focus scroll is covered by use-reply-focus-scroll.test.ts;
+ // here it is inert so the tab body states stay the subject.
+ useReplyFocusScroll: () => replyScrollFns,
+}));
vi.mock('@/lib/a11y/motion', () => ({
useMotionPolicy: () => ({ scrollAnimated: false }),
}));
@@ -48,6 +70,9 @@ vi.mock('@/components/ui/icons', () => ({ MessageSquarePlus: 'MessageSquarePlus'
vi.mock('@/components/pr-review/discussion/pr-review-discussion-list', () => ({
PrReviewDiscussionList: 'PrReviewDiscussionList',
}));
+vi.mock('@/components/pr-review/discussion/pr-comment-cta', () => ({
+ PrCommentCta: 'PrCommentCta',
+}));
const BASE_PROPS = {
owner: 'octocat',
@@ -102,9 +127,15 @@ function resetState(): void {
discussionState.laterPageError = false;
}
+function expectCtaPresence(renderer: TestRenderer.ReactTestRenderer, present: boolean): void {
+ const ctas = renderer.root.findAll(node => String(node.type) === 'PrCommentCta');
+ expect(ctas.length > 0).toBe(present);
+}
+
describe('PrReviewDiscussionTab full-body states', () => {
beforeEach(() => {
insetsState.bottom = 0;
+ pushMock.mockClear();
resetState();
});
@@ -114,6 +145,7 @@ describe('PrReviewDiscussionTab full-body states', () => {
const error = renderer.root.find(node => String(node.type) === 'QueryError');
expect(error.props.placement).toBeUndefined();
expect(bottomPaddedViews(renderer)).toHaveLength(0);
+ expectCtaPresence(renderer, false);
if (kind === 'retryable') {
act(() => {
(error.props.onRetry as () => void)();
@@ -130,11 +162,13 @@ describe('PrReviewDiscussionTab full-body states', () => {
const centered = renderer.root.find(node => String(node.type) === 'CenteredState');
expect(centered.find(node => String(node.type) === 'PrReviewReconnectNotice')).toBeDefined();
expect(bottomPaddedViews(renderer)).toHaveLength(0);
+ expectCtaPresence(renderer, false);
});
it('keeps the loading skeleton padding', () => {
discussionState.query.isPending = true;
expectSinglePadding(mountTab(), 32);
+ expectCtaPresence(mountTab(), false);
});
it('lets EmptyState own the empty body and keeps its Files action', () => {
@@ -168,7 +202,7 @@ describe('PrReviewDiscussionTab full-body states', () => {
).toHaveLength(0);
});
- it('renders the happy list without a chrome wrapper', () => {
+ it('renders the happy list under the comment CTA bar', () => {
discussionState.conversation = [{ nodeId: 'c1', createdAt: null }];
const renderer = mountTab();
@@ -178,5 +212,88 @@ describe('PrReviewDiscussionTab full-body states', () => {
node => typeof node.type === 'string' && (node.type as string) === 'PrReviewDiscussionList'
)
).toHaveLength(1);
+ expectCtaPresence(renderer, true);
+ });
+
+ it('renders the comment CTA bar on the empty view', () => {
+ const renderer = mountTab();
+ expect(renderer.root.find(node => String(node.type) === 'EmptyState')).toBeDefined();
+ expectCtaPresence(renderer, true);
+ });
+
+ it('pushes the conversation-comment route from the CTA bar', () => {
+ const renderer = mountTab();
+ const cta = renderer.root.find(node => String(node.type) === 'PrCommentCta');
+ act(() => {
+ (cta.props.onPress as () => void)();
+ });
+ expect(pushMock).toHaveBeenCalledWith({
+ pathname: '/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment',
+ params: { owner: 'octocat', repo: 'hello-world', number: 7 },
+ });
+ });
+});
+
+// The keyboard-lift gating and the viewport-anchored reply scroll wiring
+// (uxs3 spot check: e4-confirm-discard — a foreign sheet's keyboard lifted
+// the bar behind it and clipped the last thread's reply field; e7-typed —
+// the scroll parked against a pre-lift viewport). The scroll itself is
+// covered by use-reply-focus-scroll.test.ts; here the TAB must hand the hook
+// the real viewport commits and must gate the lift on screen focus.
+describe('PrReviewDiscussionTab keyboard-lift gating and reply-scroll wiring', () => {
+ beforeEach(() => {
+ focusState.value = true;
+ resetState();
+ replyScrollFns.markFocus.mockClear();
+ replyScrollFns.onViewportLayout.mockClear();
+ replyScrollFns.invalidate.mockClear();
+ });
+
+ function mountHappyList(): TestRenderer.ReactTestRenderer {
+ discussionState.conversation = [{ nodeId: 'c1', createdAt: null }];
+ return mountTab();
+ }
+
+ it('gates the CTA lift on screen focus: lifted while focused, parked while not', () => {
+ const focused = mountHappyList();
+ expect(
+ (
+ focused.root.find(node => String(node.type) === 'PrCommentCta').props as {
+ keyboardLift?: boolean;
+ }
+ ).keyboardLift
+ ).toBe(true);
+
+ focusState.value = false;
+ const blurred = mountHappyList();
+ expect(
+ (
+ blurred.root.find(node => String(node.type) === 'PrCommentCta').props as {
+ keyboardLift?: boolean;
+ }
+ ).keyboardLift
+ ).toBe(false);
+ });
+
+ it('feeds the list viewport commits and reply focuses into the scroll hook, and a drag invalidates it', () => {
+ const renderer = mountHappyList();
+ const list = renderer.root.find(node => String(node.type) === 'PrReviewDiscussionList');
+ const listProps = list.props as {
+ onReplyInputFocus?: (index: number) => void;
+ onViewportLayout?: (height: number) => void;
+ onScrollBeginDrag?: () => void;
+ };
+ expect(listProps.onViewportLayout).toBeTypeOf('function');
+ expect(listProps.onReplyInputFocus).toBeTypeOf('function');
+
+ listProps.onReplyInputFocus?.(3);
+ expect(replyScrollFns.markFocus).toHaveBeenCalledWith(3);
+ listProps.onViewportLayout?.(420);
+ expect(replyScrollFns.onViewportLayout).toHaveBeenCalledWith(420);
+
+ // A user drag wins over the parked scroll (and still invalidates the
+ // content-position settle, the pre-existing behavior).
+ listProps.onScrollBeginDrag?.();
+ expect(replyScrollFns.invalidate).toHaveBeenCalledTimes(1);
});
});
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
index baca0fc176..23940fdd93 100644
--- a/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-discussion-tab.tsx
@@ -28,6 +28,13 @@
// kinds and a "Review files" CTA that switches
// to the Files tab via `onRequestFiles`.
//
+// - bottom CTA: the happy and empty views render a static
+// "Comment on this pull request" bar under the
+// body (`PrCommentCta`) that pushes the
+// conversation-comment formSheet. The loading
+// skeleton and the four terminal/error states
+// render full-body with no bar.
+//
// - later-page error: a per-page refetch failure during a
// "Load more" tap. The current loaded
// items are kept and a small retry row
@@ -43,11 +50,13 @@
import { type FlashListRef } from '@shopify/flash-list';
import { MessageSquarePlus } from '@/components/ui/icons';
-import { useEffect, useRef, useState } from 'react';
+import { type Href, useIsFocused, useRouter } from 'expo-router';
+import { type ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform, View } from 'react-native';
import { PrReviewDiscussionList } from '@/components/pr-review/discussion/pr-review-discussion-list';
+import { PrCommentCta } from '@/components/pr-review/discussion/pr-comment-cta';
import { PrReviewReconnectNotice } from '@/components/pr-review/pr-review-reconnect-notice';
import { CenteredState } from '@/components/centered-state';
import { EmptyState } from '@/components/empty-state';
@@ -70,6 +79,7 @@ import {
toggleThreadExpanded,
} from '@/lib/pr-review/discussion/thread-expansion';
import { usePrReviewDiscussionThreads } from '@/lib/pr-review/discussion/use-pr-review-discussion-threads';
+import { useReplyFocusScroll } from '@/lib/pr-review/discussion/use-reply-focus-scroll';
import { selectDiscussionTabView } from '@/components/pr-review/pr-review-discussion-tab-view';
import { useDetailScreenBottomPadding } from '@/lib/screen-insets';
@@ -86,6 +96,9 @@ type PrReviewDiscussionTabProps = {
const SKELETON_ROW_COUNT = 4;
+const CONVERSATION_COMMENT_PATH =
+ '/(app)/pr-review/[owner]/[repo]/[number]/conversation-comment' as const;
+
export function PrReviewDiscussionTab({
owner,
repo,
@@ -100,6 +113,7 @@ export function PrReviewDiscussionTab({
});
const { t } = useTranslation();
+ const router = useRouter();
const [expansion, setExpansion] = useState>({});
const [suppressContentPosition, setSuppressContentPosition] = useState(false);
@@ -108,6 +122,22 @@ export function PrReviewDiscussionTab({
const settleGenerationRef = useRef(0);
const settleThreadIdRef = useRef(null);
const { scrollAnimated } = useMotionPolicy();
+ // Keyboard-open reply focus: scroll the focused thread row above the
+ // keyboard-lifted CTA bar so the reply input and its submit button stay
+ // usable (the CTA must stay visible and tappable above the keyboard). The
+ // scroll anchors on the list's viewport commit, not a guessed frame
+ // (useReplyFocusScroll).
+ const replyScroll = useReplyFocusScroll(listRef);
+ // Handler wiring for the list's focus-scroll props (the hook's own method
+ // names follow its protocol, the JSX handlers follow the handle* rule).
+ const handleReplyInputFocus = replyScroll.markFocus;
+ const handleViewportLayout = replyScroll.onViewportLayout;
+ // The CTA bar lifts on GLOBAL keyboard events; while another surface owns
+ // the keyboard (the conversation-comment formSheet) that lift only shrinks
+ // the list viewport behind the sheet and parks the last thread's reply
+ // field under the bar (uxs3 spot check, e4-confirm-discard). Lift only
+ // while this tab's screen is focused.
+ const isFocused = useIsFocused();
// Bottom clearance for the non-list chrome (loading, empty, and every
// first-page error state) so the last control clears the system bar.
const bottomPadding = useDetailScreenBottomPadding();
@@ -217,6 +247,24 @@ export function PrReviewDiscussionTab({
isEmpty,
});
+ const openConversationComment = () => {
+ const href: Href = {
+ pathname: CONVERSATION_COMMENT_PATH,
+ params: { owner, repo, number },
+ };
+ router.push(href);
+ };
+
+ // The bottom CTA bar is static chrome for the two content-bearing views
+ // only (happy list + empty). The loading skeleton and the four
+ // terminal/error states render exactly as before — full-body, no bar.
+ const withCommentCta = (body: ReactNode) => (
+
+ {body}
+
+
+ );
+
if (view.kind === 'permission') {
return (
@@ -274,7 +322,7 @@ export function PrReviewDiscussionTab({
// ── Empty (neither threads nor conversation comments) ──────────────
if (view.kind === 'empty') {
- return (
+ return withCommentCta(
{
+ invalidateSettle();
+ // A user drag wins over the keyboard-open reply park: drop the
+ // pending focus scroll (useReplyFocusScroll).
+ replyScroll.invalidate();
+ }}
hasNextPage={query.hasNextPage}
isFetchingNextPage={query.isFetchingNextPage}
laterPageError={laterPageError || retainedContentError}
@@ -319,6 +372,8 @@ export function PrReviewDiscussionTab({
onRetryLoadMore={() => {
void query.refetch();
}}
+ onReplyInputFocus={handleReplyInputFocus}
+ onViewportLayout={handleViewportLayout}
/>
);
}
diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx
index ae8f8c02c0..486b8a30b4 100644
--- a/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-screen.test.tsx
@@ -109,6 +109,17 @@ vi.mock('@/lib/pr-review/merge/merge-result-banner-store', () => ({
consumeMergePartialSuccess: () => null,
}));
+// The header's offline-banner reservation: the banner visibility and the
+// banner height are stubbed so the tree walk can assert the reserved
+// paddingTop per state.
+const offlineBanner = vi.hoisted(() => ({ isOffline: false }));
+vi.mock('@/lib/hooks/use-offline-banner-state', () => ({
+ useOfflineBannerState: () => offlineBanner.isOffline,
+}));
+vi.mock('@/components/offline-banner', () => ({
+ OFFLINE_BANNER_HEIGHT: 36,
+}));
+
vi.mock('@/lib/pr-review/recent-prs', () => ({
upsertRecentPr: vi.fn(),
}));
@@ -374,3 +385,37 @@ describe('PrReviewScreen Overview scrolling', () => {
expect((refresh.props as { onRefresh: unknown }).onRefresh).toEqual(expect.any(Function));
});
});
+
+describe('PrReviewScreen offline-banner header reservation (uxs2)', () => {
+ afterEach(() => {
+ offlineBanner.isOffline = false;
+ });
+
+ function findHeaderReservation(): React.ReactElement | null {
+ // eslint-disable-next-line new-cap
+ const element = PrReviewScreen({ owner: 'octocat', repo: 'hello', number: 7 });
+ return findElement({ node: element, type: 'View', prop: 'className', value: 'bg-background' });
+ }
+
+ it('reserves the banner height above the header while offline', () => {
+ offlineBanner.isOffline = true;
+ const reservation = findHeaderReservation();
+ if (!reservation) {
+ throw new Error('header reservation View not found');
+ }
+ expect((reservation.props as { style?: { paddingTop?: number } }).style).toEqual({
+ paddingTop: 36,
+ });
+ });
+
+ it('keeps the header flush while online', () => {
+ offlineBanner.isOffline = false;
+ const reservation = findHeaderReservation();
+ if (!reservation) {
+ throw new Error('header reservation View not found');
+ }
+ expect((reservation.props as { style?: { paddingTop?: number } }).style).toEqual({
+ paddingTop: 0,
+ });
+ });
+});
diff --git a/apps/mobile/src/components/pr-review/pr-review-screen.tsx b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
index 0e021f10ab..f9d736fd63 100644
--- a/apps/mobile/src/components/pr-review/pr-review-screen.tsx
+++ b/apps/mobile/src/components/pr-review/pr-review-screen.tsx
@@ -7,6 +7,8 @@ import { Pressable, Share, View } from 'react-native';
import { RefreshControl } from '@/components/ui/refresh-control';
import { PrMergePartialSuccessBanner } from '@/components/pr-review/merge/pr-merge-partial-success-banner';
+import { OFFLINE_BANNER_HEIGHT } from '@/components/offline-banner';
+import { useOfflineBannerState } from '@/lib/hooks/use-offline-banner-state';
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';
@@ -54,6 +56,12 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
const { t } = useTranslation();
const [tab, setTab] = useState('overview');
const [refreshing, setRefreshing] = useState(false);
+ // The app-wide offline banner is an absolute overlay at the safe-area top,
+ // so it paints over this screen's header title. Reserve its height above
+ // the header while it is visible (uxs2 spot check, e6-offline-hang).
+ const isOffline = useOfflineBannerState();
+ // 0 while online keeps the header's natural position (no reserved space).
+ const headerTopPadding = isOffline ? OFFLINE_BANNER_HEIGHT : 0;
// P1-F-46b: push the review-submit route with the same params the
// Files-tab `PrDiffFloatingActions` uses, so a clean PR (no queued
@@ -206,37 +214,39 @@ export function PrReviewScreen({ owner, repo, number }: PrReviewScreenProps) {
return (
-
-
-
-
- {/* P1-F-46b: the Submit-review affordance is reachable from the
- Overview tab (header right) and the Files tab (floating
- action bar). The Discussion tab is intentionally left without
- a submit affordance — comment threads there are read-only. */}
- {tab === 'overview' ? (
-
- ) : null}
-
- }
- />
+
+
+ {/* P1-F-46b: the Submit-review affordance is reachable from the
+ Overview tab (header right) and the Files tab (floating
+ action bar). The Discussion tab is intentionally left without
+ a submit affordance — comment threads there are read-only. */}
+ {tab === 'overview' ? (
+
+ ) : null}
+
+ }
+ />
+
{
});
});
+describe('PR-comment copy', () => {
+ const COMMENT_KEYS = [
+ 'prReview.discussion.addCommentCta',
+ 'prReview.discussion.commentBadRequest',
+ 'prReview.discussion.commentForbidden',
+ ] as const;
+
+ it('defines the English source strings', async () => {
+ await i18n.changeLanguage('en');
+ expect(i18n.t(COMMENT_KEYS[0])).toBe('Comment on this pull request');
+ expect(i18n.t(COMMENT_KEYS[1])).toBe(
+ "This comment can't be posted. The pull request may have changed."
+ );
+ expect(i18n.t(COMMENT_KEYS[2])).toBe(
+ "You don't have permission to comment on this pull request."
+ );
+ });
+
+ it.each(SUPPORTED_LANGUAGES)('ships translated PR-comment copy in %s', async tag => {
+ await i18n.changeLanguage(tag);
+ for (const key of COMMENT_KEYS) {
+ const value = i18n.t(key);
+ const english = i18n.t(key, { lng: 'en' });
+ expect(value, `${tag} ${key}`).toBeTruthy();
+ expect(value, `${tag} ${key}`).not.toContain('{{');
+ if (tag !== 'en') {
+ // A catalog without the key falls back to the English string here.
+ expect(value, `${tag} ${key}`).not.toBe(english);
+ }
+ }
+ });
+});
+
describe('plural forms', () => {
it('uses the translated Arabic singular and dual forms', async () => {
await i18n.changeLanguage('ar');
diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json
index 1ef9c7a489..f8ffe7cbad 100644
--- a/apps/mobile/src/i18n/locales/af.json
+++ b/apps/mobile/src/i18n/locales/af.json
@@ -448,6 +448,9 @@
"acceptTerms": "Aanvaar",
"replyBadRequest": "Hierdie antwoord kan nie geplaas word nie. Die draad het dalk verander.",
"replyForbidden": "Jy het nie toestemming om op hierdie trekversoek te antwoord nie.",
+ "addCommentCta": "Kommenteer op hierdie trekversoek",
+ "commentBadRequest": "Hierdie kommentaar kan nie geplaas word nie. Die trekversoek het dalk verander.",
+ "commentForbidden": "Jy het nie toestemming om op hierdie trekversoek kommentaar te lewer nie.",
"moderation": {
"reportContent": {
"terminal": "Hierdie kommentaar kan nie gerapporteer word nie.",
diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json
index 3a832accce..fdad640ba8 100644
--- a/apps/mobile/src/i18n/locales/am.json
+++ b/apps/mobile/src/i18n/locales/am.json
@@ -448,6 +448,9 @@
"acceptTerms": "ተቀበል",
"replyBadRequest": "ይህን ምላሽ መለጠፍ አይቻልም። የውይይት ሰንሰለቱ ተለውጦ ሊሆን ይችላል።",
"replyForbidden": "ለዚህ የማዋሃድ ጥያቄ ምላሽ ለመስጠት ፈቃድ የለህም።",
+ "addCommentCta": "በዚህ የማዋሃድ ጥያቄ ላይ አስተያየት ስጥ",
+ "commentBadRequest": "ይህ አስተያየት መለጠፍ አይቻልም። የማዋሃዱ ጥያቄ ተለውጦ ሊሆን ይችላል።",
+ "commentForbidden": "በዚህ የማዋሃድ ጥያቄ ላይ አስተያየት ለመስጠት ፈቃድ የለህም።",
"moderation": {
"reportContent": {
"terminal": "ይህ አስተያየት ሪፖርት ሊደረግ አይችልም።",
diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json
index 10a5704b88..928d5001c3 100644
--- a/apps/mobile/src/i18n/locales/ar.json
+++ b/apps/mobile/src/i18n/locales/ar.json
@@ -2464,6 +2464,9 @@
"replyBadRequest": "لا يمكن نشر هذا الرد. ربما تغيّرت سلسلة المناقشة.",
"replyBody": "نص الرد",
"replyForbidden": "ليست لديك صلاحية للرد على طلب السحب هذا.",
+ "addCommentCta": "التعليق على طلب السحب هذا",
+ "commentBadRequest": "لا يمكن نشر هذا التعليق. ربما تغيّر طلب السحب.",
+ "commentForbidden": "ليست لديك صلاحية التعليق على طلب السحب هذا.",
"replyPlaceholder": "الرد…",
"reportContent": "الإبلاغ عن المحتوى",
"reportUser": "الإبلاغ عن المستخدم",
diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json
index 0c06a787b1..1e9c0c6f91 100644
--- a/apps/mobile/src/i18n/locales/az.json
+++ b/apps/mobile/src/i18n/locales/az.json
@@ -448,6 +448,9 @@
"acceptTerms": "Qəbul et",
"replyBadRequest": "Bu cavabı göndərmək mümkün deyil. Müzakirə dəyişmiş ola bilər.",
"replyForbidden": "Bu birləşdirmə sorğusuna cavab vermək icazən yoxdur.",
+ "addCommentCta": "Bu birləşdirmə sorğusuna şərh yaz",
+ "commentBadRequest": "Bu şərhi göndərmək mümkün deyil. Birləşdirmə sorğusu dəyişmiş ola bilər.",
+ "commentForbidden": "Bu birləşdirmə sorğusuna şərh yazmaq icazən yoxdur.",
"moderation": {
"reportContent": {
"terminal": "Bu şərhdən şikayət etmək mümkün deyil.",
diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json
index e4f18b2c76..7d106c7853 100644
--- a/apps/mobile/src/i18n/locales/be.json
+++ b/apps/mobile/src/i18n/locales/be.json
@@ -452,6 +452,9 @@
"acceptTerms": "Прыняць",
"replyBadRequest": "Немагчыма апублікаваць гэты адказ. Магчыма, абмеркаванне змянілася.",
"replyForbidden": "У цябе няма дазволу адказваць у гэтым запыце на зліццё.",
+ "addCommentCta": "Каментаваць гэты запыт на зліццё",
+ "commentBadRequest": "Немагчыма апублікаваць гэты каментарый. Магчыма, запыт на зліццё змяніўся.",
+ "commentForbidden": "У цябе няма дазволу каментаваць гэты запыт на зліццё.",
"moderation": {
"reportContent": {
"terminal": "На гэты каментар немагчыма паскардзіцца.",
diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json
index 507daf6267..4780816823 100644
--- a/apps/mobile/src/i18n/locales/bg.json
+++ b/apps/mobile/src/i18n/locales/bg.json
@@ -448,6 +448,9 @@
"acceptTerms": "Приеми",
"replyBadRequest": "Този отговор не може да бъде публикуван. Нишката може да се е променила.",
"replyForbidden": "Нямаш права да отговаряш в тази заявка за обединяване.",
+ "addCommentCta": "Коментирай тази заявка за обединяване",
+ "commentBadRequest": "Този коментар не може да бъде публикуван. Заявката за обединяване може да се е променила.",
+ "commentForbidden": "Нямаш права да коментираш в тази заявка за обединяване.",
"moderation": {
"reportContent": {
"terminal": "Не можеш да подадеш сигнал за този коментар.",
diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json
index 6386fe7c61..0fbd619f26 100644
--- a/apps/mobile/src/i18n/locales/bn.json
+++ b/apps/mobile/src/i18n/locales/bn.json
@@ -448,6 +448,9 @@
"acceptTerms": "গ্রহণ করুন",
"replyBadRequest": "এই উত্তরটি পোস্ট করা যাচ্ছে না। থ্রেডটি বদলে গিয়ে থাকতে পারে।",
"replyForbidden": "এই পুল রিকোয়েস্টে উত্তর দেওয়ার অনুমতি আপনার নেই।",
+ "addCommentCta": "এই পুল রিকোয়েস্টে মন্তব্য করুন",
+ "commentBadRequest": "এই মন্তব্যটি পোস্ট করা যাচ্ছে না। পুল রিকোয়েস্টটি বদলে গিয়ে থাকতে পারে।",
+ "commentForbidden": "এই পুল রিকোয়েস্টে মন্তব্য করার অনুমতি আপনার নেই।",
"moderation": {
"reportContent": {
"terminal": "এই মন্তব্যটি রিপোর্ট করা যাবে না।",
diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json
index 5fc78947f9..127f36e224 100644
--- a/apps/mobile/src/i18n/locales/bs.json
+++ b/apps/mobile/src/i18n/locales/bs.json
@@ -450,6 +450,9 @@
"acceptTerms": "Prihvati",
"replyBadRequest": "Ovaj odgovor se ne može objaviti. Nit se možda promijenila.",
"replyForbidden": "Nemaš dozvolu da odgovoriš na ovaj zahtjev za spajanje.",
+ "addCommentCta": "Komentariši ovaj zahtjev za spajanje",
+ "commentBadRequest": "Ovaj komentar se ne može objaviti. Zahtjev za spajanje se možda promijenio.",
+ "commentForbidden": "Nemaš dozvolu da komentarišeš ovaj zahtjev za spajanje.",
"moderation": {
"reportContent": {
"terminal": "Ovaj komentar se ne može prijaviti.",
diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json
index 21132bd768..8cf7e91a04 100644
--- a/apps/mobile/src/i18n/locales/ca.json
+++ b/apps/mobile/src/i18n/locales/ca.json
@@ -450,6 +450,9 @@
"acceptTerms": "Accepta",
"replyBadRequest": "Aquesta resposta no es pot publicar. Pot ser que el fil hagi canviat.",
"replyForbidden": "No tens permís per respondre a aquesta sol·licitud d'integració.",
+ "addCommentCta": "Comenta aquesta sol·licitud d'integració",
+ "commentBadRequest": "Aquest comentari no es pot publicar. Pot ser que la sol·licitud d'integració hagi canviat.",
+ "commentForbidden": "No tens permís per comentar aquesta sol·licitud d'integració.",
"moderation": {
"reportContent": {
"terminal": "Aquest comentari no es pot denunciar.",
diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json
index 5bd018aa88..305fb9c751 100644
--- a/apps/mobile/src/i18n/locales/ckb.json
+++ b/apps/mobile/src/i18n/locales/ckb.json
@@ -448,6 +448,9 @@
"acceptTerms": "پەسند بکە",
"replyBadRequest": "ناتوانرێت ئەم وەڵامە بڵاو بکرێتەوە. ڕەنگە زنجیرەی گفتوگۆکە گۆڕابێت.",
"replyForbidden": "مۆڵەتی وەڵامدانەوەی ئەم داواکارییەی ڕاکێشانت نییە.",
+ "addCommentCta": "بۆچوون لەسەر ئەم داواکارییەی ڕاکێشان",
+ "commentBadRequest": "ناتوانرێت ئەم بۆچوونە بڵاو بکرێتەوە. ڕەنگە داواکارییە ڕاکێشراوەکە گۆڕابێت.",
+ "commentForbidden": "مۆڵەتی بۆچوونکردن لەسەر ئەم داواکارییەی ڕاکێشانت نییە.",
"moderation": {
"reportContent": {
"terminal": "ئەم بۆچوونە ناتوانرێت ڕاپۆرت بکرێت.",
diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json
index d6704ff3e3..024b89f67f 100644
--- a/apps/mobile/src/i18n/locales/cs.json
+++ b/apps/mobile/src/i18n/locales/cs.json
@@ -452,6 +452,9 @@
"acceptTerms": "Přijmout",
"replyBadRequest": "Tuto odpověď nelze odeslat. Vlákno se možná změnilo.",
"replyForbidden": "Nemáš oprávnění odpovídat v tomto PR.",
+ "addCommentCta": "Okomentovat tento PR",
+ "commentBadRequest": "Tento komentář nelze odeslat. PR se možná změnil.",
+ "commentForbidden": "Nemáš oprávnění komentovat tento PR.",
"moderation": {
"reportContent": {
"terminal": "Tento komentář nelze nahlásit.",
diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json
index 28b62dbbf0..d54e27b0cc 100644
--- a/apps/mobile/src/i18n/locales/cy.json
+++ b/apps/mobile/src/i18n/locales/cy.json
@@ -456,6 +456,9 @@
"acceptTerms": "Derbyn",
"replyBadRequest": "Ni ellir postio'r ateb hwn. Efallai fod yr edefyn wedi newid.",
"replyForbidden": "Nid oes gennych ganiatâd i ateb y cais tynnu hwn.",
+ "addCommentCta": "Sylwadu ar y cais tynnu hwn",
+ "commentBadRequest": "Ni ellir postio'r sylw hwn. Efallai fod y cais tynnu wedi newid.",
+ "commentForbidden": "Nid oes gennych ganiatâd i sylwadu ar y cais tynnu hwn.",
"moderation": {
"reportContent": {
"terminal": "Ni ellir adrodd am y sylw hwn.",
diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json
index cc3ca6a105..0f636bdd00 100644
--- a/apps/mobile/src/i18n/locales/da.json
+++ b/apps/mobile/src/i18n/locales/da.json
@@ -448,6 +448,9 @@
"acceptTerms": "Acceptér",
"replyBadRequest": "Dette svar kan ikke sendes. Tråden kan have ændret sig.",
"replyForbidden": "Du har ikke tilladelse til at svare på denne pull request.",
+ "addCommentCta": "Kommenter på denne pull request",
+ "commentBadRequest": "Denne kommentar kan ikke sendes. Pull requesten kan have ændret sig.",
+ "commentForbidden": "Du har ikke tilladelse til at kommentere på denne pull request.",
"moderation": {
"reportContent": {
"terminal": "Denne kommentar kan ikke anmeldes.",
diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json
index 99666e1455..e9190f5111 100644
--- a/apps/mobile/src/i18n/locales/de.json
+++ b/apps/mobile/src/i18n/locales/de.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "Diese Antwort kann nicht veröffentlicht werden. Der Thread hat sich möglicherweise geändert.",
"replyBody": "Antworttext",
"replyForbidden": "Du hast keine Berechtigung, auf diesen Pull-Request zu antworten.",
+ "addCommentCta": "Diesen Pull-Request kommentieren",
+ "commentBadRequest": "Dieser Kommentar kann nicht veröffentlicht werden. Der Pull-Request hat sich möglicherweise geändert.",
+ "commentForbidden": "Du hast keine Berechtigung, diesen Pull-Request zu kommentieren.",
"replyPlaceholder": "Deine Antwort …",
"reportContent": "Inhalt melden",
"reportUser": "Benutzer melden",
diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json
index 1eed7a9421..5442ce65ea 100644
--- a/apps/mobile/src/i18n/locales/el.json
+++ b/apps/mobile/src/i18n/locales/el.json
@@ -448,6 +448,9 @@
"acceptTerms": "Αποδοχή",
"replyBadRequest": "Αυτή η απάντηση δεν μπορεί να δημοσιευτεί. Το νήμα μπορεί να έχει αλλάξει.",
"replyForbidden": "Δεν έχεις δικαίωμα να απαντήσεις σε αυτό το αίτημα ενσωμάτωσης.",
+ "addCommentCta": "Σχολίασε αυτό το αίτημα ενσωμάτωσης",
+ "commentBadRequest": "Αυτό το σχόλιο δεν μπορεί να δημοσιευτεί. Το αίτημα ενσωμάτωσης μπορεί να έχει αλλάξει.",
+ "commentForbidden": "Δεν έχεις δικαίωμα να σχολιάσεις αυτό το αίτημα ενσωμάτωσης.",
"moderation": {
"reportContent": {
"terminal": "Δεν είναι δυνατή η αναφορά αυτού του σχολίου.",
diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json
index 80ec59435b..b4a557db87 100644
--- a/apps/mobile/src/i18n/locales/en.json
+++ b/apps/mobile/src/i18n/locales/en.json
@@ -448,6 +448,9 @@
"acceptTerms": "Accept",
"replyBadRequest": "This reply can't be posted. The thread may have changed.",
"replyForbidden": "You don't have permission to reply to this pull request.",
+ "addCommentCta": "Comment on this pull request",
+ "commentBadRequest": "This comment can't be posted. The pull request may have changed.",
+ "commentForbidden": "You don't have permission to comment on this pull request.",
"moderation": {
"reportContent": {
"terminal": "This comment can't be reported.",
diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json
index f556554aad..77efea6f49 100644
--- a/apps/mobile/src/i18n/locales/es.json
+++ b/apps/mobile/src/i18n/locales/es.json
@@ -2422,6 +2422,9 @@
"replyBadRequest": "No se puede publicar esta respuesta. Puede que el hilo haya cambiado.",
"replyBody": "Texto de la respuesta",
"replyForbidden": "No tienes permiso para responder a esta PR.",
+ "addCommentCta": "Comentar en esta PR",
+ "commentBadRequest": "No se puede publicar este comentario. Puede que la PR haya cambiado.",
+ "commentForbidden": "No tienes permiso para comentar en esta PR.",
"replyPlaceholder": "Tu respuesta…",
"reportContent": "Denunciar contenido",
"reportUser": "Denunciar al usuario",
diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json
index 165895b2d2..5e8378516f 100644
--- a/apps/mobile/src/i18n/locales/et.json
+++ b/apps/mobile/src/i18n/locales/et.json
@@ -448,6 +448,9 @@
"acceptTerms": "Nõustu",
"replyBadRequest": "Seda vastust ei saa postitada. Lõim võis muutuda.",
"replyForbidden": "Sul pole õigust sellele tõmbetaotlusele vastata.",
+ "addCommentCta": "Kommenteeri seda tõmbetaotlust",
+ "commentBadRequest": "Seda kommentaari ei saa postitada. Tõmbetaotlus võis muutuda.",
+ "commentForbidden": "Sul pole õigust seda tõmbetaotlust kommenteerida.",
"moderation": {
"reportContent": {
"terminal": "Sellest kommentaarist ei saa teatada.",
diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json
index 25e8c399ab..ae14f41b50 100644
--- a/apps/mobile/src/i18n/locales/eu.json
+++ b/apps/mobile/src/i18n/locales/eu.json
@@ -448,6 +448,9 @@
"acceptTerms": "Onartu",
"replyBadRequest": "Erantzun hau ezin da argitaratu. Baliteke haria aldatu izana.",
"replyForbidden": "Ez duzu baimenik bateratze-eskaera honi erantzuteko.",
+ "addCommentCta": "Egin iruzkina bateratze-eskaera honetan",
+ "commentBadRequest": "Iruzkin hau ezin da argitaratu. Baliteke bateratze-eskaera aldatu izana.",
+ "commentForbidden": "Ez duzu baimenik bateratze-eskaera honetan iruzkinik egiteko.",
"moderation": {
"reportContent": {
"terminal": "Iruzkin hau ezin da salatu.",
diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json
index fab53bb195..fecfad6e3e 100644
--- a/apps/mobile/src/i18n/locales/fa.json
+++ b/apps/mobile/src/i18n/locales/fa.json
@@ -448,6 +448,9 @@
"acceptTerms": "پذیرش",
"replyBadRequest": "این پاسخ قابل ارسال نیست. ممکن است گفتوگو تغییر کرده باشد.",
"replyForbidden": "اجازهٔ پاسخ دادن در این درخواست ادغام را ندارید.",
+ "addCommentCta": "نظر دادن در این درخواست ادغام",
+ "commentBadRequest": "این نظر قابل ارسال نیست. ممکن است درخواست ادغام تغییر کرده باشد.",
+ "commentForbidden": "اجازهٔ نظر دادن در این درخواست ادغام را ندارید.",
"moderation": {
"reportContent": {
"terminal": "این نظر قابل گزارش نیست.",
diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json
index 0ecb4870ee..47d454f795 100644
--- a/apps/mobile/src/i18n/locales/fi.json
+++ b/apps/mobile/src/i18n/locales/fi.json
@@ -448,6 +448,9 @@
"acceptTerms": "Hyväksy",
"replyBadRequest": "Tätä vastausta ei voi julkaista. Ketju on voinut muuttua.",
"replyForbidden": "Sinulla ei ole oikeutta vastata tähän muutospyyntöön.",
+ "addCommentCta": "Kommentoi tätä muutospyyntöä",
+ "commentBadRequest": "Tätä kommenttia ei voi julkaista. Muutospyyntö on voinut muuttua.",
+ "commentForbidden": "Sinulla ei ole oikeutta kommentoida tätä muutospyyntöä.",
"moderation": {
"reportContent": {
"terminal": "Tätä kommenttia ei voi ilmiantaa.",
diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json
index 3fa39706df..e9268d2689 100644
--- a/apps/mobile/src/i18n/locales/fil.json
+++ b/apps/mobile/src/i18n/locales/fil.json
@@ -448,6 +448,9 @@
"acceptTerms": "Tanggapin",
"replyBadRequest": "Hindi ma-post ang sagot na ito. Maaaring nagbago ang talakayan.",
"replyForbidden": "Wala kang pahintulot na sumagot sa pull request na ito.",
+ "addCommentCta": "Magkomento sa pull request na ito",
+ "commentBadRequest": "Hindi ma-post ang komentong ito. Maaaring nagbago ang pull request.",
+ "commentForbidden": "Wala kang pahintulot na magkomento sa pull request na ito.",
"moderation": {
"reportContent": {
"terminal": "Hindi maiulat ang komentong ito.",
diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json
index 21dcba8565..00a5e96e52 100644
--- a/apps/mobile/src/i18n/locales/fr.json
+++ b/apps/mobile/src/i18n/locales/fr.json
@@ -2422,6 +2422,9 @@
"replyBadRequest": "Cette réponse ne peut pas être publiée. Le fil a peut-être changé.",
"replyBody": "Texte de la réponse",
"replyForbidden": "Vous n'avez pas l'autorisation de répondre à cette demande de fusion.",
+ "addCommentCta": "Commenter cette demande de fusion",
+ "commentBadRequest": "Ce commentaire ne peut pas être publié. La demande de fusion a peut-être changé.",
+ "commentForbidden": "Vous n'avez pas l'autorisation de commenter cette demande de fusion.",
"replyPlaceholder": "Votre réponse…",
"reportContent": "Signaler le contenu",
"reportUser": "Signaler l'utilisateur",
diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json
index 90d46a4f63..4e7be647e7 100644
--- a/apps/mobile/src/i18n/locales/ga.json
+++ b/apps/mobile/src/i18n/locales/ga.json
@@ -454,6 +454,9 @@
"acceptTerms": "Glac",
"replyBadRequest": "Ní féidir an freagra seo a phostáil. Seans gur athraíodh an snáithe.",
"replyForbidden": "Níl cead agat freagra a thabhairt ar an iarratas tarraingthe seo.",
+ "addCommentCta": "Trácht ar an iarratas tarraingthe seo",
+ "commentBadRequest": "Ní féidir an trácht seo a phostáil. Seans gur athraíodh an t-iarratas tarraingthe.",
+ "commentForbidden": "Níl cead agat trácht a dhéanamh ar an iarratas tarraingthe seo.",
"moderation": {
"reportContent": {
"terminal": "Ní féidir an trácht seo a thuairisciú.",
diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json
index 9cfe739f96..4b883d6e27 100644
--- a/apps/mobile/src/i18n/locales/gl.json
+++ b/apps/mobile/src/i18n/locales/gl.json
@@ -448,6 +448,9 @@
"acceptTerms": "Aceptar",
"replyBadRequest": "Non se pode publicar esta resposta. Pode que o fío cambiase.",
"replyForbidden": "Non tes permiso para responder a esta solicitude de integración.",
+ "addCommentCta": "Comentar esta solicitude de integración",
+ "commentBadRequest": "Non se pode publicar este comentario. Pode que a solicitude de integración cambiase.",
+ "commentForbidden": "Non tes permiso para comentar nesta solicitude de integración.",
"moderation": {
"reportContent": {
"terminal": "Este comentario non se pode denunciar.",
diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json
index 59735b7e2e..f1a29bcc26 100644
--- a/apps/mobile/src/i18n/locales/gu.json
+++ b/apps/mobile/src/i18n/locales/gu.json
@@ -448,6 +448,9 @@
"acceptTerms": "સ્વીકારો",
"replyBadRequest": "આ જવાબ પોસ્ટ કરી શકાતો નથી. થ્રેડ બદલાયો હોઈ શકે છે.",
"replyForbidden": "તમને આ પુલ રિક્વેસ્ટ પર જવાબ આપવાની પરવાનગી નથી.",
+ "addCommentCta": "આ પુલ રિક્વેસ્ટ પર ટિપ્પણી કરો",
+ "commentBadRequest": "આ ટિપ્પણી પોસ્ટ કરી શકાતી નથી. પુલ રિક્વેસ્ટ બદલાયો હોઈ શકે છે.",
+ "commentForbidden": "તમને આ પુલ રિક્વેસ્ટ પર ટિપ્પણી કરવાની પરવાનગી નથી.",
"moderation": {
"reportContent": {
"terminal": "આ ટિપ્પણી વિશે ફરિયાદ કરી શકાતી નથી.",
diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json
index 5979dae811..e037ea7942 100644
--- a/apps/mobile/src/i18n/locales/ha.json
+++ b/apps/mobile/src/i18n/locales/ha.json
@@ -448,6 +448,9 @@
"acceptTerms": "Amince",
"replyBadRequest": "Ba za a iya wallafa wannan amsar ba. Wataƙila silsilar tattaunawar ta canza.",
"replyForbidden": "Ba ka da izinin amsa wannan buƙatar haɗawa.",
+ "addCommentCta": "Yi tsokaci a wannan buƙatar haɗawa",
+ "commentBadRequest": "Ba za a iya wallafa wannan tsokaci ba. Wataƙila buƙatar haɗawa ta canza.",
+ "commentForbidden": "Ba ka da izinin yin tsokaci a wannan buƙatar haɗawa.",
"moderation": {
"reportContent": {
"terminal": "Ba za a iya kai rahoton wannan tsokacin ba.",
diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json
index cfe8e909d4..11d6fc86af 100644
--- a/apps/mobile/src/i18n/locales/he.json
+++ b/apps/mobile/src/i18n/locales/he.json
@@ -2422,6 +2422,9 @@
"replyBadRequest": "לא ניתן לפרסם את התשובה הזו. ייתכן שהשרשור השתנה.",
"replyBody": "תוכן התשובה",
"replyForbidden": "אין לך הרשאה להשיב לבקשת המשיכה הזו.",
+ "addCommentCta": "הגב על בקשת המשיכה הזו",
+ "commentBadRequest": "לא ניתן לפרסם את התגובה הזו. ייתכן שבקשת המשיכה השתנתה.",
+ "commentForbidden": "אין לך הרשאה להגיב על בקשת המשיכה הזו.",
"replyPlaceholder": "תשובה…",
"reportContent": "דיווח על תוכן",
"reportUser": "דיווח על משתמש",
diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json
index 9cf7789685..8af5afe33a 100644
--- a/apps/mobile/src/i18n/locales/hi.json
+++ b/apps/mobile/src/i18n/locales/hi.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "यह उत्तर पोस्ट नहीं किया जा सकता। हो सकता है कि थ्रेड बदल गया हो।",
"replyBody": "उत्तर का टेक्स्ट",
"replyForbidden": "आपको इस पुल रिक्वेस्ट पर उत्तर देने की अनुमति नहीं है।",
+ "addCommentCta": "इस पुल रिक्वेस्ट पर टिप्पणी करें",
+ "commentBadRequest": "यह टिप्पणी पोस्ट नहीं की जा सकती। हो सकता है कि पुल रिक्वेस्ट बदल गया हो।",
+ "commentForbidden": "आपको इस पुल रिक्वेस्ट पर टिप्पणी करने की अनुमति नहीं है।",
"replyPlaceholder": "आपका उत्तर…",
"reportContent": "सामग्री की शिकायत करें",
"reportUser": "उपयोगकर्ता की शिकायत करें",
diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json
index 9c5af2ba6a..12a34cc0c4 100644
--- a/apps/mobile/src/i18n/locales/hr.json
+++ b/apps/mobile/src/i18n/locales/hr.json
@@ -450,6 +450,9 @@
"acceptTerms": "Prihvati",
"replyBadRequest": "Ovaj odgovor nije moguće objaviti. Nit se možda promijenila.",
"replyForbidden": "Nemaš dopuštenje za odgovaranje u ovom zahtjevu za spajanje.",
+ "addCommentCta": "Komentiraj ovaj zahtjev za spajanje",
+ "commentBadRequest": "Ovaj komentar nije moguće objaviti. Zahtjev za spajanje se možda promijenio.",
+ "commentForbidden": "Nemaš dopuštenje za komentiranje u ovom zahtjevu za spajanje.",
"moderation": {
"reportContent": {
"terminal": "Ovaj komentar nije moguće prijaviti.",
diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json
index bfcac2b355..d53853f56a 100644
--- a/apps/mobile/src/i18n/locales/ht.json
+++ b/apps/mobile/src/i18n/locales/ht.json
@@ -448,6 +448,9 @@
"acceptTerms": "Aksepte",
"replyBadRequest": "Pa ka pibliye repons sa a. Petèt fil diskisyon an chanje.",
"replyForbidden": "Ou pa gen pèmisyon pou reponn demann fizyon sa a.",
+ "addCommentCta": "Fè yon kòmantè sou demann fizyon sa a",
+ "commentBadRequest": "Pa ka pibliye kòmantè sa a. Petèt demann fizyon an chanje.",
+ "commentForbidden": "Ou pa gen pèmisyon pou fè yon kòmantè sou demann fizyon sa a.",
"moderation": {
"reportContent": {
"terminal": "Pa ka rapòte kòmantè sa a.",
diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json
index f35b75a638..db8e18ad7f 100644
--- a/apps/mobile/src/i18n/locales/hu.json
+++ b/apps/mobile/src/i18n/locales/hu.json
@@ -448,6 +448,9 @@
"acceptTerms": "Elfogadás",
"replyBadRequest": "Ez a válasz nem küldhető el. Lehet, hogy a szál megváltozott.",
"replyForbidden": "Nincs jogosultságod válaszolni erre a módosítási kérelemre.",
+ "addCommentCta": "Hozzászólás ehhez a módosítási kérelemhez",
+ "commentBadRequest": "Ez a hozzászólás nem küldhető el. Lehet, hogy a módosítási kérelem megváltozott.",
+ "commentForbidden": "Nincs jogosultságod hozzászólni ehhez a módosítási kérelemhez.",
"moderation": {
"reportContent": {
"terminal": "Ez a hozzászólás nem jelenthető.",
diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json
index 6eb8dc289b..b9bda03796 100644
--- a/apps/mobile/src/i18n/locales/hy.json
+++ b/apps/mobile/src/i18n/locales/hy.json
@@ -448,6 +448,9 @@
"acceptTerms": "Ընդունել",
"replyBadRequest": "Այս պատասխանը հնարավոր չէ հրապարակել։ Հնարավոր է՝ քննարկման շղթան փոխվել է։",
"replyForbidden": "Այս միավորման հարցմանը պատասխանելու թույլտվություն չունես։",
+ "addCommentCta": "Մեկնաբանել այս միավորման հարցումը",
+ "commentBadRequest": "Այս մեկնաբանությունը հնարավոր չէ հրապարակել։ Հնարավոր է՝ միավորման հարցումը փոխվել է։",
+ "commentForbidden": "Այս միավորման հարցման վրա մեկնաբանություն գրելու թույլտվություն չունես։",
"moderation": {
"reportContent": {
"terminal": "Այս մեկնաբանությունից հնարավոր չէ բողոքել։",
diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json
index 0de111f4b5..ab830febf6 100644
--- a/apps/mobile/src/i18n/locales/id.json
+++ b/apps/mobile/src/i18n/locales/id.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "Balasan ini tidak dapat dikirim. Utasnya mungkin sudah berubah.",
"replyBody": "Isi balasan",
"replyForbidden": "Kamu tidak memiliki izin untuk membalas PR ini.",
+ "addCommentCta": "Komentari PR ini",
+ "commentBadRequest": "Komentar ini tidak dapat dikirim. PR mungkin sudah berubah.",
+ "commentForbidden": "Kamu tidak memiliki izin untuk berkomentar di PR ini.",
"replyPlaceholder": "Balasan…",
"reportContent": "Laporkan konten",
"reportUser": "Laporkan pengguna",
diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json
index e3c301ce29..2104dfbfe7 100644
--- a/apps/mobile/src/i18n/locales/ig.json
+++ b/apps/mobile/src/i18n/locales/ig.json
@@ -448,6 +448,9 @@
"acceptTerms": "Nakwere",
"replyBadRequest": "Enweghị ike ibipụta azịza a. O nwere ike ịbụ na usoro mkparịta ụka ahụ agbanweela.",
"replyForbidden": "Ị nweghị ikike ịzaghachi arịrịọ njikọta a.",
+ "addCommentCta": "Nye nkwupụta na arịrịọ njikọta a",
+ "commentBadRequest": "Enweghị ike ibipụta nkwupụta a. O nwere ike ịbụ na arịrịọ njikọta a agbanweela.",
+ "commentForbidden": "Ị nweghị ikike inye nkwupụta na arịrịọ njikọta a.",
"moderation": {
"reportContent": {
"terminal": "Enweghị ike ikpesa okwu a.",
diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json
index 23e32fa717..3f74740d62 100644
--- a/apps/mobile/src/i18n/locales/is.json
+++ b/apps/mobile/src/i18n/locales/is.json
@@ -448,6 +448,9 @@
"acceptTerms": "Samþykkja",
"replyBadRequest": "Ekki er hægt að birta þetta svar. Þráðurinn gæti hafa breyst.",
"replyForbidden": "Þú hefur ekki heimild til að svara þessari sameiningarbeiðni.",
+ "addCommentCta": "Gera athugasemd við þessa sameiningarbeiðni",
+ "commentBadRequest": "Ekki er hægt að birta þessa athugasemd. Sameiningarbeiðnin gæti hafa breyst.",
+ "commentForbidden": "Þú hefur ekki heimild til að gera athugasemd við þessa sameiningarbeiðni.",
"moderation": {
"reportContent": {
"terminal": "Ekki er hægt að tilkynna þessa athugasemd.",
diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json
index 94567943a2..00c137e35d 100644
--- a/apps/mobile/src/i18n/locales/it.json
+++ b/apps/mobile/src/i18n/locales/it.json
@@ -2422,6 +2422,9 @@
"replyBadRequest": "Impossibile pubblicare questa risposta. La discussione potrebbe essere stata modificata.",
"replyBody": "Testo della risposta",
"replyForbidden": "Non hai il permesso di rispondere a questa pull request.",
+ "addCommentCta": "Commenta questa pull request",
+ "commentBadRequest": "Impossibile pubblicare questo commento. La pull request potrebbe essere stata modificata.",
+ "commentForbidden": "Non hai il permesso di commentare questa pull request.",
"replyPlaceholder": "La tua risposta…",
"reportContent": "Segnala il contenuto",
"reportUser": "Segnala l'utente",
diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json
index c868fefbcf..e1176c9a3c 100644
--- a/apps/mobile/src/i18n/locales/ja.json
+++ b/apps/mobile/src/i18n/locales/ja.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "この返信は投稿できません。スレッドが変更された可能性があります。",
"replyBody": "返信の本文",
"replyForbidden": "このプルリクエストに返信する権限がありません。",
+ "addCommentCta": "このプルリクエストにコメントする",
+ "commentBadRequest": "このコメントは投稿できません。プルリクエストが変更された可能性があります。",
+ "commentForbidden": "このプルリクエストにコメントする権限がありません。",
"replyPlaceholder": "返信…",
"reportContent": "コンテンツを報告",
"reportUser": "ユーザーを報告",
diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json
index c6679bf6c6..f8440193f6 100644
--- a/apps/mobile/src/i18n/locales/ka.json
+++ b/apps/mobile/src/i18n/locales/ka.json
@@ -448,6 +448,9 @@
"acceptTerms": "თანხმობა",
"replyBadRequest": "ამ პასუხის გამოქვეყნება შეუძლებელია. შესაძლოა, თემა შეიცვალა.",
"replyForbidden": "ამ შერწყმის მოთხოვნაზე პასუხის გაცემის უფლება არ გაქვს.",
+ "addCommentCta": "კომენტარი ამ შერწყმის მოთხოვნაზე",
+ "commentBadRequest": "ამ კომენტარის გამოქვეყნება შეუძლებელია. შესაძლოა, შერწყმის მოთხოვნა შეიცვალა.",
+ "commentForbidden": "ამ შერწყმის მოთხოვნაზე კომენტარის დამატების უფლება არ გაქვს.",
"moderation": {
"reportContent": {
"terminal": "ამ კომენტარის გასაჩივრება შეუძლებელია.",
diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json
index 09b2afc422..22e64fe4f5 100644
--- a/apps/mobile/src/i18n/locales/kk.json
+++ b/apps/mobile/src/i18n/locales/kk.json
@@ -448,6 +448,9 @@
"acceptTerms": "Қабылдау",
"replyBadRequest": "Бұл жауапты жариялау мүмкін емес. Талқылау тармағы өзгерген болуы мүмкін.",
"replyForbidden": "Бұл біріктіру сұрауына жауап беруге рұқсатыңыз жоқ.",
+ "addCommentCta": "Бұл біріктіру сұрауына пікір жазу",
+ "commentBadRequest": "Бұл пікірді жариялау мүмкін емес. Біріктіру сұрауы өзгерген болуы мүмкін.",
+ "commentForbidden": "Бұл біріктіру сұрауына пікір жазуға рұқсатыңыз жоқ.",
"moderation": {
"reportContent": {
"terminal": "Бұл пікірге шағымдану мүмкін емес.",
diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json
index 25a37c293e..682a448bd0 100644
--- a/apps/mobile/src/i18n/locales/km.json
+++ b/apps/mobile/src/i18n/locales/km.json
@@ -448,6 +448,9 @@
"acceptTerms": "ទទួលយក",
"replyBadRequest": "មិនអាចបង្ហោះការឆ្លើយតបនេះបានទេ។ ការពិភាក្សាអាចបានផ្លាស់ប្តូរ។",
"replyForbidden": "អ្នកគ្មានសិទ្ធិឆ្លើយតបលើសំណើបញ្ចូលកូដនេះទេ។",
+ "addCommentCta": "បញ្ចេញមតិលើសំណើបញ្ចូលកូដនេះ",
+ "commentBadRequest": "មិនអាចបង្ហោះមតិយោបល់នេះបានទេ។ សំណើបញ្ចូលកូដអាចបានផ្លាស់ប្តូរ។",
+ "commentForbidden": "អ្នកគ្មានសិទ្ធិបញ្ចេញមតិលើសំណើបញ្ចូលកូដនេះទេ។",
"moderation": {
"reportContent": {
"terminal": "មិនអាចរាយការណ៍មតិយោបល់នេះបានទេ។",
diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json
index 0a5af43a41..06a79e020b 100644
--- a/apps/mobile/src/i18n/locales/kn.json
+++ b/apps/mobile/src/i18n/locales/kn.json
@@ -448,6 +448,9 @@
"acceptTerms": "ಒಪ್ಪಿಕೊಳ್ಳಿ",
"replyBadRequest": "ಈ ಉತ್ತರವನ್ನು ಪೋಸ್ಟ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಚರ್ಚಾ ಸರಣಿ ಬದಲಾಗಿರಬಹುದು.",
"replyForbidden": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ಗೆ ಉತ್ತರಿಸಲು ನಿಮಗೆ ಅನುಮತಿ ಇಲ್ಲ.",
+ "addCommentCta": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ಗೆ ಟಿಪ್ಪಣಿ ಮಾಡಿ",
+ "commentBadRequest": "ಈ ಟಿಪ್ಪಣಿಯನ್ನು ಪೋಸ್ಟ್ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ. ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ ಬದಲಾಗಿರಬಹುದು.",
+ "commentForbidden": "ಈ ಪುಲ್ ರಿಕ್ವೆಸ್ಟ್ಗೆ ಟಿಪ್ಪಣಿ ಮಾಡಲು ನಿಮಗೆ ಅನುಮತಿ ಇಲ್ಲ.",
"moderation": {
"reportContent": {
"terminal": "ಈ ಕಾಮೆಂಟ್ ಬಗ್ಗೆ ವರದಿ ಮಾಡಲು ಸಾಧ್ಯವಿಲ್ಲ.",
diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json
index 5dab46198a..17c7b44ec9 100644
--- a/apps/mobile/src/i18n/locales/ko.json
+++ b/apps/mobile/src/i18n/locales/ko.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "이 답글을 게시할 수 없습니다. 스레드가 변경되었을 수 있습니다.",
"replyBody": "답글 내용",
"replyForbidden": "이 풀 리퀘스트에 답글을 달 권한이 없습니다.",
+ "addCommentCta": "이 풀 리퀘스트에 댓글 달기",
+ "commentBadRequest": "이 댓글을 게시할 수 없습니다. 풀 리퀘스트가 변경되었을 수 있습니다.",
+ "commentForbidden": "이 풀 리퀘스트에 댓글을 달 권한이 없습니다.",
"replyPlaceholder": "답글…",
"reportContent": "콘텐츠 신고",
"reportUser": "사용자 신고",
diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json
index 0d904ba6ba..a792135d40 100644
--- a/apps/mobile/src/i18n/locales/lo.json
+++ b/apps/mobile/src/i18n/locales/lo.json
@@ -448,6 +448,9 @@
"acceptTerms": "ຍອມຮັບ",
"replyBadRequest": "ບໍ່ສາມາດໂພດຄຳຕອບນີ້ໄດ້. ກະທູ້ອາດມີການປ່ຽນແປງແລ້ວ.",
"replyForbidden": "ທ່ານບໍ່ມີສິດຕອບໃນຄຳຂໍລວມໂຄດນີ້.",
+ "addCommentCta": "ສະແດງຄຳເຫັນໃນຄຳຂໍລວມໂຄດນີ້",
+ "commentBadRequest": "ບໍ່ສາມາດໂພດຄຳເຫັນນີ້ໄດ້. ຄຳຂໍລວມໂຄດອາດມີການປ່ຽນແປງແລ້ວ.",
+ "commentForbidden": "ທ່ານບໍ່ມີສິດສະແດງຄຳເຫັນໃນຄຳຂໍລວມໂຄດນີ້.",
"moderation": {
"reportContent": {
"terminal": "ບໍ່ສາມາດລາຍງານຄຳເຫັນນີ້ໄດ້.",
diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json
index c3888de30d..7b9c369eaa 100644
--- a/apps/mobile/src/i18n/locales/lt.json
+++ b/apps/mobile/src/i18n/locales/lt.json
@@ -452,6 +452,9 @@
"acceptTerms": "Sutikti",
"replyBadRequest": "Šio atsakymo negalima paskelbti. Gija galėjo pasikeisti.",
"replyForbidden": "Neturi teisės atsakyti šios pakeitimų užklausos diskusijoje.",
+ "addCommentCta": "Komentuoti šią pakeitimų užklausą",
+ "commentBadRequest": "Šio komentaro negalima paskelbti. Pakeitimų užklausa galėjo pasikeisti.",
+ "commentForbidden": "Neturi teisės komentuoti šios pakeitimų užklausos.",
"moderation": {
"reportContent": {
"terminal": "Apie šį komentarą negalima pranešti.",
diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json
index 59ebe163ec..972b340555 100644
--- a/apps/mobile/src/i18n/locales/lv.json
+++ b/apps/mobile/src/i18n/locales/lv.json
@@ -450,6 +450,9 @@
"acceptTerms": "Pieņemt",
"replyBadRequest": "Šo atbildi nevar publicēt. Pavediens, iespējams, ir mainījies.",
"replyForbidden": "Tev nav atļaujas atbildēt šajā izmaiņu pieprasījumā.",
+ "addCommentCta": "Komentēt šo izmaiņu pieprasījumu",
+ "commentBadRequest": "Šo komentāru nevar publicēt. Iespējams, izmaiņu pieprasījums ir mainījies.",
+ "commentForbidden": "Tev nav atļaujas komentēt šajā izmaiņu pieprasījumā.",
"moderation": {
"reportContent": {
"terminal": "Par šo komentāru nevar ziņot.",
diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json
index 12b9fbab8b..38c4936511 100644
--- a/apps/mobile/src/i18n/locales/mg.json
+++ b/apps/mobile/src/i18n/locales/mg.json
@@ -448,6 +448,9 @@
"acceptTerms": "Ekeo",
"replyBadRequest": "Tsy azo avoaka ity valiny ity. Mety niova ny loha-dresaka.",
"replyForbidden": "Tsy manana alalana hamaly ity fangatahana fampiraisana ity ianao.",
+ "addCommentCta": "Manao fanehoan-kevitra momba ity fangatahana fampiraisana ity",
+ "commentBadRequest": "Tsy azo avoaka ity fanehoan-kevitra ity. Mety niova ny fangatahana fampiraisana.",
+ "commentForbidden": "Tsy manana alalana hanao fanehoan-kevitra momba ity fangatahana fampiraisana ity ianao.",
"moderation": {
"reportContent": {
"terminal": "Tsy azo taterina ity hevitra ity.",
diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json
index 5a2b99de01..1f758c2dc5 100644
--- a/apps/mobile/src/i18n/locales/mi.json
+++ b/apps/mobile/src/i18n/locales/mi.json
@@ -448,6 +448,9 @@
"acceptTerms": "Whakaae",
"replyBadRequest": "Kāore e taea te tuku i tēnei whakautu. Kua rerekē pea te aho kōrero.",
"replyForbidden": "Kāore koe e whakaaetia kia whakautu ki tēnei tono kume.",
+ "addCommentCta": "Tāpiri kōrero ki tēnei tono kume",
+ "commentBadRequest": "Kāore e taea te tuku i tēnei kōrero. Kua rerekē pea te tono kume.",
+ "commentForbidden": "Kāore koe e whakaaetia kia tāpiri kōrero ki tēnei tono kume.",
"moderation": {
"reportContent": {
"terminal": "Kāore e taea te pūrongo i tēnei kōrero.",
diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json
index a70da89397..7a2c7beede 100644
--- a/apps/mobile/src/i18n/locales/mk.json
+++ b/apps/mobile/src/i18n/locales/mk.json
@@ -448,6 +448,9 @@
"acceptTerms": "Прифати",
"replyBadRequest": "Овој одговор не може да се објави. Можеби нишката е променета.",
"replyForbidden": "Немаш дозвола да одговориш на ова барање за спојување.",
+ "addCommentCta": "Коментирај ова барање за спојување",
+ "commentBadRequest": "Овој коментар не може да се објави. Можеби барањето за спојување е променето.",
+ "commentForbidden": "Немаш дозвола да коментираш ова барање за спојување.",
"moderation": {
"reportContent": {
"terminal": "Овој коментар не може да се пријави.",
diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json
index 98a40ca807..cdd3f0f749 100644
--- a/apps/mobile/src/i18n/locales/ml.json
+++ b/apps/mobile/src/i18n/locales/ml.json
@@ -448,6 +448,9 @@
"acceptTerms": "അംഗീകരിക്കുക",
"replyBadRequest": "ഈ മറുപടി പോസ്റ്റ് ചെയ്യാൻ കഴിയില്ല. ത്രെഡ് മാറിയിരിക്കാം.",
"replyForbidden": "ഈ പുൾ റിക്വസ്റ്റിന് മറുപടി നൽകാൻ നിങ്ങൾക്ക് അനുമതിയില്ല.",
+ "addCommentCta": "ഈ പുൾ റിക്വസ്റ്റിൽ അഭിപ്രായം രേഖപ്പെടുത്തുക",
+ "commentBadRequest": "ഈ അഭിപ്രായം പോസ്റ്റ് ചെയ്യാൻ കഴിയില്ല. പുൾ റിക്വസ്റ്റ് മാറിയിരിക്കാം.",
+ "commentForbidden": "ഈ പുൾ റിക്വസ്റ്റിൽ അഭിപ്രായം രേഖപ്പെടുത്താൻ നിങ്ങൾക്ക് അനുമതിയില്ല.",
"moderation": {
"reportContent": {
"terminal": "ഈ അഭിപ്രായം റിപ്പോർട്ട് ചെയ്യാൻ കഴിയില്ല.",
diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json
index 91376a31e1..4e33b272a1 100644
--- a/apps/mobile/src/i18n/locales/mn.json
+++ b/apps/mobile/src/i18n/locales/mn.json
@@ -448,6 +448,9 @@
"acceptTerms": "Зөвшөөрөх",
"replyBadRequest": "Энэ хариуг нийтлэх боломжгүй. Сэдэв өөрчлөгдсөн байж болзошгүй.",
"replyForbidden": "Танд энэ нэгтгэх хүсэлтэд хариу бичих эрх байхгүй.",
+ "addCommentCta": "Энэ нэгтгэх хүсэлтэд сэтгэгдэл бичих",
+ "commentBadRequest": "Энэ сэтгэгдлийг нийтлэх боломжгүй. Нэгтгэх хүсэлт өөрчлөгдсөн байж болзошгүй.",
+ "commentForbidden": "Танд энэ нэгтгэх хүсэлтэд сэтгэгдэл бичих эрх байхгүй.",
"moderation": {
"reportContent": {
"terminal": "Энэ сэтгэгдлийн талаар гомдол гаргах боломжгүй.",
diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json
index d73a5814c8..a5f70b4442 100644
--- a/apps/mobile/src/i18n/locales/mr.json
+++ b/apps/mobile/src/i18n/locales/mr.json
@@ -448,6 +448,9 @@
"acceptTerms": "स्वीकारा",
"replyBadRequest": "हे उत्तर पोस्ट करता येत नाही. चर्चेत बदल झाला असेल.",
"replyForbidden": "तुम्हाला या पुल रिक्वेस्टला उत्तर देण्याची परवानगी नाही.",
+ "addCommentCta": "या पुल रिक्वेस्टवर टिप्पणी करा",
+ "commentBadRequest": "ही टिप्पणी पोस्ट करता येत नाही. पुल रिक्वेस्टमध्ये बदल झाला असेल.",
+ "commentForbidden": "तुम्हाला या पुल रिक्वेस्टवर टिप्पणी करण्याची परवानगी नाही.",
"moderation": {
"reportContent": {
"terminal": "या टिप्पणीची तक्रार करता येत नाही.",
diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json
index 8379b30ae8..1fe7ee806b 100644
--- a/apps/mobile/src/i18n/locales/ms.json
+++ b/apps/mobile/src/i18n/locales/ms.json
@@ -448,6 +448,9 @@
"acceptTerms": "Terima",
"replyBadRequest": "Balasan ini tidak dapat disiarkan. Rantaian perbincangan ini mungkin telah berubah.",
"replyForbidden": "Anda tidak mempunyai kebenaran untuk membalas permintaan tarik ini.",
+ "addCommentCta": "Beri komen pada permintaan tarik ini",
+ "commentBadRequest": "Komen ini tidak dapat disiarkan. Permintaan tarik ini mungkin telah berubah.",
+ "commentForbidden": "Anda tidak mempunyai kebenaran untuk memberi komen pada permintaan tarik ini.",
"moderation": {
"reportContent": {
"terminal": "Komen ini tidak boleh dilaporkan.",
diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json
index 0b7e238d00..79272c0028 100644
--- a/apps/mobile/src/i18n/locales/mt.json
+++ b/apps/mobile/src/i18n/locales/mt.json
@@ -454,6 +454,9 @@
"acceptTerms": "Aċċetta",
"replyBadRequest": "Din ir-risposta ma tistax tiġi ppubblikata. Is-sensiela ta' kummenti setgħet inbidlet.",
"replyForbidden": "M'għandekx permess biex tirrispondi għal din it-talba għall-għaqda.",
+ "addCommentCta": "Ikkummenta fuq din it-talba għall-għaqda",
+ "commentBadRequest": "Dan il-kumment ma jistax jiġi ppubblikat. Jista' jkun li t-talba għall-għaqda nbidlet.",
+ "commentForbidden": "M'għandekx permess biex tikkummenta fuq din it-talba għall-għaqda.",
"moderation": {
"reportContent": {
"terminal": "Dan il-kumment ma jistax jiġi rrapportat.",
diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json
index d9cf73a6f8..13541bd320 100644
--- a/apps/mobile/src/i18n/locales/my.json
+++ b/apps/mobile/src/i18n/locales/my.json
@@ -448,6 +448,9 @@
"acceptTerms": "လက်ခံပါ",
"replyBadRequest": "ဒီအကြောင်းပြန်စာကို တင်၍မရပါ။ ဆွေးနွေးချက်တွဲ ပြောင်းလဲသွားခြင်း ဖြစ်နိုင်ပါသည်။",
"replyForbidden": "ဒီ PR တွင် အကြောင်းပြန်ခွင့် မရှိပါ။",
+ "addCommentCta": "ဒီ PR တွင် မှတ်ချက် ရေးသားပါ",
+ "commentBadRequest": "ဒီမှတ်ချက်ကို တင်၍မရပါ။ PR ပြောင်းလဲသွားခြင်း ဖြစ်နိုင်ပါသည်။",
+ "commentForbidden": "ဒီ PR တွင် မှတ်ချက်ရေးသားခွင့် မရှိပါ။",
"moderation": {
"reportContent": {
"terminal": "ဒီမှတ်ချက်ကို တိုင်ကြား၍မရပါ။",
diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json
index da969c8522..ecbc82b8bb 100644
--- a/apps/mobile/src/i18n/locales/nb.json
+++ b/apps/mobile/src/i18n/locales/nb.json
@@ -448,6 +448,9 @@
"acceptTerms": "Godta",
"replyBadRequest": "Dette svaret kan ikke publiseres. Tråden kan ha endret seg.",
"replyForbidden": "Du har ikke tillatelse til å svare på denne PR-en.",
+ "addCommentCta": "Kommenter denne PR-en",
+ "commentBadRequest": "Denne kommentaren kan ikke publiseres. PR-en kan ha endret seg.",
+ "commentForbidden": "Du har ikke tillatelse til å kommentere denne PR-en.",
"moderation": {
"reportContent": {
"terminal": "Denne kommentaren kan ikke rapporteres.",
diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json
index 472255545f..d25ed2056a 100644
--- a/apps/mobile/src/i18n/locales/ne.json
+++ b/apps/mobile/src/i18n/locales/ne.json
@@ -448,6 +448,9 @@
"acceptTerms": "स्वीकार गर्नुहोस्",
"replyBadRequest": "यो जवाफ पोस्ट गर्न सकिँदैन। थ्रेड परिवर्तन भएको हुन सक्छ।",
"replyForbidden": "तपाईंसँग यो पुल रिक्वेस्टमा जवाफ दिने अनुमति छैन।",
+ "addCommentCta": "यो पुल रिक्वेस्टमा टिप्पणी गर्नुहोस्",
+ "commentBadRequest": "यो टिप्पणी पोस्ट गर्न सकिँदैन। पुल रिक्वेस्ट परिवर्तन भएको हुन सक्छ।",
+ "commentForbidden": "तपाईंसँग यो पुल रिक्वेस्टमा टिप्पणी गर्ने अनुमति छैन।",
"moderation": {
"reportContent": {
"terminal": "यो टिप्पणी रिपोर्ट गर्न सकिँदैन।",
diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json
index 89078fe409..7a7e9e3dd5 100644
--- a/apps/mobile/src/i18n/locales/nl.json
+++ b/apps/mobile/src/i18n/locales/nl.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "Dit antwoord kan niet worden geplaatst. De discussie is mogelijk gewijzigd.",
"replyBody": "Antwoordtekst",
"replyForbidden": "Je hebt geen toestemming om op deze PR te reageren.",
+ "addCommentCta": "Een opmerking op deze PR plaatsen",
+ "commentBadRequest": "Deze opmerking kan niet worden geplaatst. De PR is mogelijk gewijzigd.",
+ "commentForbidden": "Je hebt geen toestemming om een opmerking op deze PR te plaatsen.",
"replyPlaceholder": "Je antwoord…",
"reportContent": "Inhoud melden",
"reportUser": "Gebruiker melden",
diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json
index 45400af3f5..b4c75be814 100644
--- a/apps/mobile/src/i18n/locales/om.json
+++ b/apps/mobile/src/i18n/locales/om.json
@@ -448,6 +448,9 @@
"acceptTerms": "Fudhadhu",
"replyBadRequest": "Deebiin kun maxxanfamuu hin danda'u. Dameen marii jijjiirameera ta'a.",
"replyForbidden": "Gaaffii walitti makuu kanaaf deebii kennuuf hayyama hin qabdu.",
+ "addCommentCta": "Gaaffii walitti makuu kanaa irratti yaada kenni",
+ "commentBadRequest": "Yaadni kun maxxanfamuu hin danda'u. Gaaffiin walitti makuu jijjiirameera ta'a.",
+ "commentForbidden": "Gaaffii walitti makuu kanaa irratti yaada kennuuf hayyama hin qabdu.",
"moderation": {
"reportContent": {
"terminal": "Yaadni kun gabaafamuu hin danda'u.",
diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json
index c3b36b9dfc..2381e9af27 100644
--- a/apps/mobile/src/i18n/locales/or.json
+++ b/apps/mobile/src/i18n/locales/or.json
@@ -448,6 +448,9 @@
"acceptTerms": "ଗ୍ରହଣ କରନ୍ତୁ",
"replyBadRequest": "ଏହି ଉତ୍ତର ପୋଷ୍ଟ କରିହେବ ନାହିଁ। ଥ୍ରେଡ୍ ବଦଳିଥାଇପାରେ।",
"replyForbidden": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ଉତ୍ତର ଦେବାକୁ ଆପଣଙ୍କର ଅନୁମତି ନାହିଁ।",
+ "addCommentCta": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ମନ୍ତବ୍ୟ ଦିଅନ୍ତୁ",
+ "commentBadRequest": "ଏହି ମନ୍ତବ୍ୟ ପୋଷ୍ଟ କରିହେବ ନାହିଁ। ପୁଲ୍ ରିକ୍ୱେଷ୍ଟ ବଦଳିଥାଇପାରେ।",
+ "commentForbidden": "ଏହି ପୁଲ୍ ରିକ୍ୱେଷ୍ଟରେ ମନ୍ତବ୍ୟ ଦେବାକୁ ଆପଣଙ୍କର ଅନୁମତି ନାହିଁ।",
"moderation": {
"reportContent": {
"terminal": "ଏହି ମନ୍ତବ୍ୟ ବିଷୟରେ ରିପୋର୍ଟ କରିହେବ ନାହିଁ।",
diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json
index 0a341d00aa..35840c2be5 100644
--- a/apps/mobile/src/i18n/locales/pa.json
+++ b/apps/mobile/src/i18n/locales/pa.json
@@ -448,6 +448,9 @@
"acceptTerms": "ਸਵੀਕਾਰ ਕਰੋ",
"replyBadRequest": "ਇਹ ਜਵਾਬ ਪੋਸਟ ਨਹੀਂ ਕੀਤਾ ਜਾ ਸਕਦਾ। ਥ੍ਰੈਡ ਬਦਲਿਆ ਹੋ ਸਕਦਾ ਹੈ।",
"replyForbidden": "ਤੁਹਾਨੂੰ ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਜਵਾਬ ਦੇਣ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ।",
+ "addCommentCta": "ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਟਿੱਪਣੀ ਕਰੋ",
+ "commentBadRequest": "ਇਹ ਟਿੱਪਣੀ ਪੋਸਟ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ। ਪੁੱਲ ਰਿਕਵੈਸਟ ਬਦਲਿਆ ਹੋ ਸਕਦਾ ਹੈ।",
+ "commentForbidden": "ਤੁਹਾਨੂੰ ਇਸ ਪੁੱਲ ਰਿਕਵੈਸਟ 'ਤੇ ਟਿੱਪਣੀ ਕਰਨ ਦੀ ਇਜਾਜ਼ਤ ਨਹੀਂ ਹੈ।",
"moderation": {
"reportContent": {
"terminal": "ਇਸ ਟਿੱਪਣੀ ਦੀ ਰਿਪੋਰਟ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ।",
diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json
index 87356d2d37..1c011a1765 100644
--- a/apps/mobile/src/i18n/locales/pl.json
+++ b/apps/mobile/src/i18n/locales/pl.json
@@ -2436,6 +2436,9 @@
"replyBadRequest": "Tej odpowiedzi nie można opublikować. Wątek mógł się zmienić.",
"replyBody": "Treść odpowiedzi",
"replyForbidden": "Nie masz uprawnień do odpowiadania w tym PR.",
+ "addCommentCta": "Skomentuj ten PR",
+ "commentBadRequest": "Tego komentarza nie można opublikować. PR mógł się zmienić.",
+ "commentForbidden": "Nie masz uprawnień do komentowania w tym PR.",
"replyPlaceholder": "Odpowiedź…",
"reportContent": "Zgłoś treść",
"reportUser": "Zgłoś użytkownika",
diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json
index d64785c373..b13b1f9506 100644
--- a/apps/mobile/src/i18n/locales/ps.json
+++ b/apps/mobile/src/i18n/locales/ps.json
@@ -448,6 +448,9 @@
"acceptTerms": "ومنئ",
"replyBadRequest": "دا ځواب نه شي خپرېدای. کېدای شي د بحث لړۍ بدله شوې وي.",
"replyForbidden": "تاسو دې پل ریکویسټ ته د ځواب ورکولو اجازه نه لرئ.",
+ "addCommentCta": "په دې پل ریکویسټ باندې تبصره وکړئ",
+ "commentBadRequest": "دا تبصره نه شي خپرېدای. کېدای شي پل ریکویسټ بدل شوی وي.",
+ "commentForbidden": "تاسو دې پل ریکویسټ ته د تبصرې کولو اجازه نه لرئ.",
"moderation": {
"reportContent": {
"terminal": "د دې تبصرې راپور نه شي ورکول کېدای.",
diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json
index 43f110a475..d8fb168da1 100644
--- a/apps/mobile/src/i18n/locales/pt-BR.json
+++ b/apps/mobile/src/i18n/locales/pt-BR.json
@@ -2422,6 +2422,9 @@
"replyBadRequest": "Não é possível publicar esta resposta. A conversa pode ter sido alterada.",
"replyBody": "Texto da resposta",
"replyForbidden": "Você não tem permissão para responder a este pull request.",
+ "addCommentCta": "Comentar neste pull request",
+ "commentBadRequest": "Não é possível publicar este comentário. O pull request pode ter sido alterado.",
+ "commentForbidden": "Você não tem permissão para comentar neste pull request.",
"replyPlaceholder": "Sua resposta…",
"reportContent": "Denunciar conteúdo",
"reportUser": "Denunciar usuário",
diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json
index 59ab25801c..38c5a7703b 100644
--- a/apps/mobile/src/i18n/locales/pt.json
+++ b/apps/mobile/src/i18n/locales/pt.json
@@ -450,6 +450,9 @@
"acceptTerms": "Aceitar",
"replyBadRequest": "Esta resposta não pode ser publicada. A conversa pode ter mudado.",
"replyForbidden": "Não tens permissão para responder a este PR.",
+ "addCommentCta": "Comentar neste PR",
+ "commentBadRequest": "Este comentário não pode ser publicado. O PR pode ter mudado.",
+ "commentForbidden": "Não tens permissão para comentar neste PR.",
"moderation": {
"reportContent": {
"terminal": "Este comentário não pode ser denunciado.",
diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json
index 9a38ff7015..48a749237f 100644
--- a/apps/mobile/src/i18n/locales/ro.json
+++ b/apps/mobile/src/i18n/locales/ro.json
@@ -450,6 +450,9 @@
"acceptTerms": "Acceptă",
"replyBadRequest": "Acest răspuns nu poate fi publicat. Este posibil ca firul de discuție să se fi modificat.",
"replyForbidden": "Nu ai permisiunea să răspunzi la acest PR.",
+ "addCommentCta": "Comentează acest PR",
+ "commentBadRequest": "Acest comentariu nu poate fi publicat. Este posibil ca PR-ul să se fi modificat.",
+ "commentForbidden": "Nu ai permisiunea să comentezi acest PR.",
"moderation": {
"reportContent": {
"terminal": "Acest comentariu nu poate fi raportat.",
diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json
index af380fdfed..757bfe6b34 100644
--- a/apps/mobile/src/i18n/locales/ru.json
+++ b/apps/mobile/src/i18n/locales/ru.json
@@ -2436,6 +2436,9 @@
"replyBadRequest": "Не удаётся опубликовать ответ. Возможно, обсуждение изменилось.",
"replyBody": "Текст ответа",
"replyForbidden": "У вас нет прав на ответы в этом запросе на слияние.",
+ "addCommentCta": "Комментировать этот запрос на слияние",
+ "commentBadRequest": "Не удаётся опубликовать этот комментарий. Возможно, запрос на слияние изменился.",
+ "commentForbidden": "У вас нет прав на комментарии в этом запросе на слияние.",
"replyPlaceholder": "Ваш ответ…",
"reportContent": "Пожаловаться на комментарий",
"reportUser": "Пожаловаться на пользователя",
diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json
index 86f16cf64e..f72ff553eb 100644
--- a/apps/mobile/src/i18n/locales/si.json
+++ b/apps/mobile/src/i18n/locales/si.json
@@ -448,6 +448,9 @@
"acceptTerms": "පිළිගන්න",
"replyBadRequest": "මෙම පිළිතුර පළ කළ නොහැක. සාකච්ඡා පෙළ වෙනස් වී තිබිය හැක.",
"replyForbidden": "මෙම පුල් ඉල්ලීමට පිළිතුරු දීමට ඔබට අවසර නොමැත.",
+ "addCommentCta": "මෙම පුල් ඉල්ලීම පිළිබඳ අදහස් දක්වන්න",
+ "commentBadRequest": "මෙම අදහස පළ කළ නොහැක. පුල් ඉල්ලීම වෙනස් වී තිබිය හැක.",
+ "commentForbidden": "මෙම පුල් ඉල්ලීම පිළිබඳ අදහස් දැක්වීමට ඔබට අවසර නොමැත.",
"moderation": {
"reportContent": {
"terminal": "මෙම අදහස වාර්තා කළ නොහැක.",
diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json
index e8db648d2a..9716424c8c 100644
--- a/apps/mobile/src/i18n/locales/sk.json
+++ b/apps/mobile/src/i18n/locales/sk.json
@@ -452,6 +452,9 @@
"acceptTerms": "Prijať",
"replyBadRequest": "Túto odpoveď nemožno odoslať. Vlákno sa mohlo zmeniť.",
"replyForbidden": "Nemáš oprávnenie odpovedať na túto žiadosť o zlúčenie.",
+ "addCommentCta": "Skomentovať túto žiadosť o zlúčenie",
+ "commentBadRequest": "Tento komentár nemožno odoslať. Žiadosť o zlúčenie sa mohla zmeniť.",
+ "commentForbidden": "Nemáš oprávnenie komentovať túto žiadosť o zlúčenie.",
"moderation": {
"reportContent": {
"terminal": "Tento komentár sa nedá nahlásiť.",
diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json
index 02394374f7..819d9e8188 100644
--- a/apps/mobile/src/i18n/locales/sl.json
+++ b/apps/mobile/src/i18n/locales/sl.json
@@ -452,6 +452,9 @@
"acceptTerms": "Sprejmi",
"replyBadRequest": "Tega odgovora ni mogoče objaviti. Nit se je morda spremenila.",
"replyForbidden": "Nimaš dovoljenja za odgovarjanje na ta zahtevek za združitev.",
+ "addCommentCta": "Komentiraj ta zahtevek za združitev",
+ "commentBadRequest": "Tega komentarja ni mogoče objaviti. Zahtevek za združitev se je morda spremenil.",
+ "commentForbidden": "Nimaš dovoljenja za komentiranje tega zahtevka za združitev.",
"moderation": {
"reportContent": {
"terminal": "Tega komentarja ni mogoče prijaviti.",
diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json
index 5f795b3f73..ac8dd5177a 100644
--- a/apps/mobile/src/i18n/locales/so.json
+++ b/apps/mobile/src/i18n/locales/so.json
@@ -448,6 +448,9 @@
"acceptTerms": "Aqbal",
"replyBadRequest": "Jawaabtan lama daabici karo. Waxaa laga yaabaa in dooddu isbeddeshay.",
"replyForbidden": "Ma haysatid oggolaansho aad uga jawaabto codsigan isku-darka.",
+ "addCommentCta": "Faallo ku dari codsigan isku-darka",
+ "commentBadRequest": "Faalladan lama daabici karo. Waxaa laga yaabaa in codsiga isku-darku isbeddelay.",
+ "commentForbidden": "Ma haysatid oggolaansho aad faallo ku darto codsigan isku-darka.",
"moderation": {
"reportContent": {
"terminal": "Faalladan lama soo sheegi karo.",
diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json
index 48dba9f42b..f9e9556dc4 100644
--- a/apps/mobile/src/i18n/locales/sq.json
+++ b/apps/mobile/src/i18n/locales/sq.json
@@ -448,6 +448,9 @@
"acceptTerms": "Prano",
"replyBadRequest": "Kjo përgjigje nuk mund të postohet. Diskutimi mund të ketë ndryshuar.",
"replyForbidden": "Nuk ke leje të përgjigjesh në këtë kërkesë për bashkim.",
+ "addCommentCta": "Komento në këtë kërkesë për bashkim",
+ "commentBadRequest": "Ky koment nuk mund të postohet. Kërkesa për bashkim mund të ketë ndryshuar.",
+ "commentForbidden": "Nuk ke leje të komentosh në këtë kërkesë për bashkim.",
"moderation": {
"reportContent": {
"terminal": "Ky koment nuk mund të raportohet.",
diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json
index 3d6ce21f60..0831be6001 100644
--- a/apps/mobile/src/i18n/locales/sr.json
+++ b/apps/mobile/src/i18n/locales/sr.json
@@ -450,6 +450,9 @@
"acceptTerms": "Prihvati",
"replyBadRequest": "Ovaj odgovor se ne može objaviti. Nit se možda promenila.",
"replyForbidden": "Nemaš dozvolu da odgovoriš na ovaj zahtev za spajanje.",
+ "addCommentCta": "Komentariši ovaj zahtev za spajanje",
+ "commentBadRequest": "Ovaj komentar se ne može objaviti. Zahtev za spajanje se možda promenio.",
+ "commentForbidden": "Nemaš dozvolu da komentarišeš ovaj zahtev za spajanje.",
"moderation": {
"reportContent": {
"terminal": "Ovaj komentar se ne može prijaviti.",
diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json
index 03a0873657..8aeb9bae98 100644
--- a/apps/mobile/src/i18n/locales/sv.json
+++ b/apps/mobile/src/i18n/locales/sv.json
@@ -448,6 +448,9 @@
"acceptTerms": "Godkänn",
"replyBadRequest": "Det här svaret kan inte publiceras. Tråden kan ha ändrats.",
"replyForbidden": "Du har inte behörighet att svara på den här PR:en.",
+ "addCommentCta": "Kommentera den här PR:en",
+ "commentBadRequest": "Den här kommentaren kan inte publiceras. PR:en kan ha ändrats.",
+ "commentForbidden": "Du har inte behörighet att kommentera den här PR:en.",
"moderation": {
"reportContent": {
"terminal": "Den här kommentaren kan inte anmälas.",
diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json
index aed358cfc5..2577449e27 100644
--- a/apps/mobile/src/i18n/locales/sw.json
+++ b/apps/mobile/src/i18n/locales/sw.json
@@ -448,6 +448,9 @@
"acceptTerms": "Kubali",
"replyBadRequest": "Jibu hili haliwezi kuchapishwa. Mada huenda imebadilika.",
"replyForbidden": "Huna ruhusa ya kujibu ombi hili la kuunganisha.",
+ "addCommentCta": "Toa maoni kwenye ombi hili la kuunganisha",
+ "commentBadRequest": "Maoni haya haliwezi kuchapishwa. Huenda ombi hili la kuunganisha likabadilika.",
+ "commentForbidden": "Huna ruhusa ya kutoa maoni kwenye ombi hili la kuunganisha.",
"moderation": {
"reportContent": {
"terminal": "Maoni haya hayawezi kuripotiwa.",
diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json
index fe7b0f818e..9e8e01f075 100644
--- a/apps/mobile/src/i18n/locales/ta.json
+++ b/apps/mobile/src/i18n/locales/ta.json
@@ -448,6 +448,9 @@
"acceptTerms": "ஏற்கவும்",
"replyBadRequest": "இந்தப் பதிலை இடுகையிட முடியாது. கருத்துத் தொடர் மாறியிருக்கலாம்.",
"replyForbidden": "இந்த இணைப்புக் கோரிக்கைக்குப் பதிலளிக்க உங்களுக்கு அனுமதி இல்லை.",
+ "addCommentCta": "இந்த இணைப்புக் கோரிக்கையில் கருத்துத் தெரிவிக்கவும்",
+ "commentBadRequest": "இந்தக் கருத்தை இடுகையிட முடியாது. இணைப்புக் கோரிக்கை மாறியிருக்கலாம்.",
+ "commentForbidden": "இந்த இணைப்புக் கோரிக்கையில் கருத்துத் தெரிவிக்க உங்களுக்கு அனுமதி இல்லை.",
"moderation": {
"reportContent": {
"terminal": "இந்தக் கருத்து குறித்துப் புகாரளிக்க முடியாது.",
diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json
index 03920a5af7..5def0623bd 100644
--- a/apps/mobile/src/i18n/locales/te.json
+++ b/apps/mobile/src/i18n/locales/te.json
@@ -448,6 +448,9 @@
"acceptTerms": "అంగీకరించండి",
"replyBadRequest": "ఈ ప్రత్యుత్తరాన్ని పోస్ట్ చేయలేము. చర్చలో మార్పులు జరిగి ఉండవచ్చు.",
"replyForbidden": "ఈ పుల్ రిక్వెస్ట్కు ప్రత్యుత్తరం ఇవ్వడానికి మీకు అనుమతి లేదు.",
+ "addCommentCta": "ఈ పుల్ రిక్వెస్ట్పై వ్యాఖ్యానించండి",
+ "commentBadRequest": "ఈ వ్యాఖ్యను పోస్ట్ చేయలేము. పుల్ రిక్వెస్ట్ మారి ఉండవచ్చు.",
+ "commentForbidden": "ఈ పుల్ రిక్వెస్ట్పై వ్యాఖ్యానించడానికి మీకు అనుమతి లేదు.",
"moderation": {
"reportContent": {
"terminal": "ఈ వ్యాఖ్యపై ఫిర్యాదు చేయలేము.",
diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json
index 18e056e780..30707fe6ef 100644
--- a/apps/mobile/src/i18n/locales/th.json
+++ b/apps/mobile/src/i18n/locales/th.json
@@ -448,6 +448,9 @@
"acceptTerms": "ยอมรับ",
"replyBadRequest": "ไม่สามารถโพสต์การตอบกลับนี้ได้ เธรดอาจมีการเปลี่ยนแปลง",
"replyForbidden": "คุณไม่มีสิทธิ์ตอบกลับใน PR นี้",
+ "addCommentCta": "แสดงความคิดเห็นใน PR นี้",
+ "commentBadRequest": "ไม่สามารถโพสต์ความคิดเห็นนี้ได้ PR อาจมีการเปลี่ยนแปลง",
+ "commentForbidden": "คุณไม่มีสิทธิ์แสดงความคิดเห็นใน PR นี้",
"moderation": {
"reportContent": {
"terminal": "ไม่สามารถรายงานความคิดเห็นนี้ได้",
diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json
index 3b1bf98e65..b6f8c64ae3 100644
--- a/apps/mobile/src/i18n/locales/tr.json
+++ b/apps/mobile/src/i18n/locales/tr.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "Bu yanıt gönderilemiyor. Yorum dizisi değişmiş olabilir.",
"replyBody": "Yanıt metni",
"replyForbidden": "Bu çekme isteğine yanıt verme iznin yok.",
+ "addCommentCta": "Bu çekme isteğine yorum yap",
+ "commentBadRequest": "Bu yorum gönderilemiyor. Çekme isteği değişmiş olabilir.",
+ "commentForbidden": "Bu çekme isteğine yorum yapma iznin yok.",
"replyPlaceholder": "Yanıtın…",
"reportContent": "İçeriği bildir",
"reportUser": "Kullanıcıyı bildir",
diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json
index 71d8316cca..de30cc28ff 100644
--- a/apps/mobile/src/i18n/locales/uk.json
+++ b/apps/mobile/src/i18n/locales/uk.json
@@ -2436,6 +2436,9 @@
"replyBadRequest": "Цю відповідь неможливо опублікувати. Можливо, обговорення змінилося.",
"replyBody": "Текст відповіді",
"replyForbidden": "У вас немає дозволу відповідати в цьому запиті на злиття.",
+ "addCommentCta": "Коментувати цей запит на злиття",
+ "commentBadRequest": "Цей коментар неможливо опублікувати. Можливо, запит на злиття змінився.",
+ "commentForbidden": "У вас немає дозволу коментувати в цьому запиті на злиття.",
"replyPlaceholder": "Ваша відповідь…",
"reportContent": "Поскаржитися на вміст",
"reportUser": "Поскаржитися на користувача",
diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json
index 442cf52909..8d9848007f 100644
--- a/apps/mobile/src/i18n/locales/ur.json
+++ b/apps/mobile/src/i18n/locales/ur.json
@@ -448,6 +448,9 @@
"acceptTerms": "قبول کریں",
"replyBadRequest": "یہ جواب پوسٹ نہیں ہو سکتا۔ شاید تھریڈ تبدیل ہو گیا ہے۔",
"replyForbidden": "آپ کو اس پل ریکویسٹ پر جواب دینے کی اجازت نہیں ہے۔",
+ "addCommentCta": "اس پل ریکویسٹ پر تبصرہ کریں",
+ "commentBadRequest": "یہ تبصرہ پوسٹ نہیں ہو سکتا۔ شاید پل ریکویسٹ تبدیل ہو گیا ہے۔",
+ "commentForbidden": "آپ کو اس پل ریکویسٹ پر تبصرہ کرنے کی اجازت نہیں ہے۔",
"moderation": {
"reportContent": {
"terminal": "اس تبصرے کی شکایت نہیں کی جا سکتی۔",
diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json
index a74b8b622f..202403b1df 100644
--- a/apps/mobile/src/i18n/locales/uz.json
+++ b/apps/mobile/src/i18n/locales/uz.json
@@ -448,6 +448,9 @@
"acceptTerms": "Qabul qilish",
"replyBadRequest": "Bu javobni joylab bo'lmaydi. Izohlar zanjiri o'zgargan bo'lishi mumkin.",
"replyForbidden": "Bu PRga javob yozishga ruxsatingiz yo'q.",
+ "addCommentCta": "Bu PRga izoh yozish",
+ "commentBadRequest": "Bu izohni joylab bo'lmaydi. PR o'zgargan bo'lishi mumkin.",
+ "commentForbidden": "Bu PRga izoh yozishga ruxsatingiz yo'q.",
"moderation": {
"reportContent": {
"terminal": "Bu izoh ustidan shikoyat qilib bo'lmaydi.",
diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json
index 48be1532c7..f20953e3cf 100644
--- a/apps/mobile/src/i18n/locales/vi.json
+++ b/apps/mobile/src/i18n/locales/vi.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "Không thể đăng câu trả lời này. Chuỗi thảo luận có thể đã thay đổi.",
"replyBody": "Nội dung trả lời",
"replyForbidden": "Bạn không có quyền trả lời trong PR này.",
+ "addCommentCta": "Bình luận trong PR này",
+ "commentBadRequest": "Không thể đăng bình luận này. PR có thể đã thay đổi.",
+ "commentForbidden": "Bạn không có quyền bình luận trong PR này.",
"replyPlaceholder": "Trả lời…",
"reportContent": "Báo cáo nội dung",
"reportUser": "Báo cáo người dùng",
diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json
index c7b5838518..e403eda157 100644
--- a/apps/mobile/src/i18n/locales/yo.json
+++ b/apps/mobile/src/i18n/locales/yo.json
@@ -448,6 +448,9 @@
"acceptTerms": "Gba",
"replyBadRequest": "A kò lè fi ìdáhùn yìí ránṣẹ́. Ọ̀wọ́ ìjíròrò náà lè ti yí padà.",
"replyForbidden": "O kò ní àṣẹ láti dáhùn sí ìbéèrè ìṣọ̀kan yìí.",
+ "addCommentCta": "Fi àsọyé sí ìbéèrè ìṣọ̀kan yìí",
+ "commentBadRequest": "A kò lè fi àsọyé yìí ránṣẹ́. Ìbéèrè ìṣọ̀kan náà lè ti yí padà.",
+ "commentForbidden": "O kò ní àṣẹ láti fi àsọyé sí ìbéèrè ìṣọ̀kan yìí.",
"moderation": {
"reportContent": {
"terminal": "A kò lè fi ẹ̀sùn àsọyé yìí hàn.",
diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json
index 1249eff697..3c1a6ad47e 100644
--- a/apps/mobile/src/i18n/locales/zh-Hans.json
+++ b/apps/mobile/src/i18n/locales/zh-Hans.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "无法发布此回复。讨论串可能已发生变化。",
"replyBody": "回复正文",
"replyForbidden": "你无权回复此拉取请求。",
+ "addCommentCta": "评论此拉取请求",
+ "commentBadRequest": "无法发布此评论。此拉取请求可能已发生变化。",
+ "commentForbidden": "你无权评论此拉取请求。",
"replyPlaceholder": "回复…",
"reportContent": "举报内容",
"reportUser": "举报用户",
diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json
index 69204fa5aa..a079d54e27 100644
--- a/apps/mobile/src/i18n/locales/zh-Hant.json
+++ b/apps/mobile/src/i18n/locales/zh-Hant.json
@@ -2408,6 +2408,9 @@
"replyBadRequest": "無法發佈此回覆。討論串可能已變更。",
"replyBody": "回覆內容",
"replyForbidden": "你沒有權限回覆此提取請求。",
+ "addCommentCta": "對此提取請求留言",
+ "commentBadRequest": "無法發佈此留言。此提取請求可能已變更。",
+ "commentForbidden": "你沒有權限在此提取請求留言。",
"replyPlaceholder": "回覆…",
"reportContent": "檢舉內容",
"reportUser": "檢舉使用者",
diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json
index e7d45422bc..758642990a 100644
--- a/apps/mobile/src/i18n/locales/zu.json
+++ b/apps/mobile/src/i18n/locales/zu.json
@@ -448,6 +448,9 @@
"acceptTerms": "Yamukela",
"replyBadRequest": "Le mpendulo ayikwazi ukuthunyelwa. Kungenzeka uchungechunge lwengxoxo lushintshile.",
"replyForbidden": "Awunayo imvume yokuphendula kulesi sicelo sokuhlanganisa.",
+ "addCommentCta": "Phawula kulesi sicelo sokuhlanganisa",
+ "commentBadRequest": "Le phawula ayikwazi ukuthunyelwa. Kungenzeka lesi sicelo sokuhlanganisa sishintshile.",
+ "commentForbidden": "Awunayo imvume yokuphawula kulesi sicelo sokuhlanganisa.",
"moderation": {
"reportContent": {
"terminal": "La mazwana awakwazi ukubikwa.",
diff --git a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts
index a9399a4cd2..53da1682ca 100644
--- a/apps/mobile/src/lib/hooks/use-offline-banner-state.ts
+++ b/apps/mobile/src/lib/hooks/use-offline-banner-state.ts
@@ -53,3 +53,14 @@ export function useOfflineBannerState(): boolean {
export function useCommittedConnectivityStatus(): BannerState {
return useSyncExternalStore(getStore().subscribe, getStore().state);
}
+
+/**
+ * Non-hook snapshot of the committed connectivity state, for submit gates
+ * that must not start a network write while the app has CONFIRMED offline:
+ * a blocked request would pin the composer on a spinner until the UI
+ * deadline (uxs3 spot check, e6-offline-hang). `unknown` never blocks —
+ * only the same confirmed-offline state the banner paints does.
+ */
+export function getCommittedConnectivityStatus(): BannerState {
+ return getStore().state();
+}
diff --git a/apps/mobile/src/lib/offline-banner-state.test-helpers.ts b/apps/mobile/src/lib/offline-banner-state.test-helpers.ts
new file mode 100644
index 0000000000..1af99bb5bc
--- /dev/null
+++ b/apps/mobile/src/lib/offline-banner-state.test-helpers.ts
@@ -0,0 +1,114 @@
+// Shared fakes for offline-banner-state.test.ts (extracted for max-lines):
+// a ConnectivitySource driven by hand, a manual timer, and a store wired to
+// both with a recording probe.
+
+import { vi } from 'vitest';
+
+import { type ConnectivityState } from '@/lib/connectivity-online';
+import {
+ type BannerState,
+ type ConnectivitySource,
+ createOfflineBannerStore,
+ type OfflineBannerTimer,
+} from '@/lib/offline-banner-state';
+
+export const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false };
+export const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true };
+export const unknownState: ConnectivityState = { isConnected: null, isInternetReachable: null };
+// The radio-back-without-reachability case (uxs3 spot check, e6-after-net:
+// airplane mode → 3G while NetInfo's external probe never answers).
+export const radioUpUnknownState: ConnectivityState = {
+ isConnected: true,
+ isInternetReachable: null,
+};
+export const outcomes = ['online', 'offline', 'reject'] as const;
+type Outcome = (typeof outcomes)[number];
+
+function createFakeSource() {
+ const listeners = new Set<(state: ConnectivityState) => void>();
+ const unsubscribe = vi.fn(() => undefined);
+ const source: ConnectivitySource = {
+ subscribe: listener => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ unsubscribe();
+ };
+ },
+ };
+ return {
+ source,
+ emit(state: ConnectivityState): void {
+ for (const listener of listeners) {
+ listener(state);
+ }
+ },
+ unsubscribe,
+ };
+}
+
+function createFakeTimer() {
+ let now = 0;
+ const scheduled: { callback: () => void; at: number; cancelled: boolean }[] = [];
+ const timer: OfflineBannerTimer = {
+ // oxlint-disable-next-line promise/prefer-await-to-callbacks -- manually controlled timer callbacks
+ set(callback, delayMs) {
+ const entry = { callback, at: now + delayMs, cancelled: false };
+ scheduled.push(entry);
+ return {
+ cancel() {
+ entry.cancelled = true;
+ },
+ };
+ },
+ };
+ return {
+ timer,
+ scheduled,
+ advanceBy(ms: number): void {
+ now += ms;
+ for (const entry of scheduled) {
+ if (!entry.cancelled && entry.at <= now) {
+ entry.cancelled = true;
+ entry.callback();
+ }
+ }
+ },
+ };
+}
+
+export function createStore() {
+ const source = createFakeSource();
+ const timer = createFakeTimer();
+ const attempts: ReturnType>[] = [];
+ const probe = vi.fn(async () => {
+ const attempt = Promise.withResolvers();
+ attempts.push(attempt);
+ const result = await attempt.promise;
+ return result;
+ });
+ const store = createOfflineBannerStore({ source: source.source, timer: timer.timer, probe });
+ const changes: BannerState[] = [];
+ store.subscribe(() => {
+ changes.push(store.state());
+ });
+ return {
+ store,
+ source,
+ timer,
+ probe,
+ changes,
+ settle: async (index: number, outcome: Outcome) => {
+ const attempt = attempts[index];
+ if (!attempt) {
+ throw new Error(`Missing probe ${index}`);
+ }
+ if (outcome === 'reject') {
+ attempt.reject(new Error('Transport failed'));
+ } else {
+ attempt.resolve(outcome === 'online');
+ }
+ await Promise.allSettled([attempt.promise]);
+ },
+ };
+}
diff --git a/apps/mobile/src/lib/offline-banner-state.test.ts b/apps/mobile/src/lib/offline-banner-state.test.ts
index d3cae5cf66..39336b2c60 100644
--- a/apps/mobile/src/lib/offline-banner-state.test.ts
+++ b/apps/mobile/src/lib/offline-banner-state.test.ts
@@ -1,107 +1,13 @@
import { describe, expect, it, vi } from 'vitest';
-import { type ConnectivityState } from '@/lib/connectivity-online';
import {
- type BannerState,
- type ConnectivitySource,
- createOfflineBannerStore,
- type OfflineBannerTimer,
-} from '@/lib/offline-banner-state';
-
-const offlineState: ConnectivityState = { isConnected: true, isInternetReachable: false };
-const onlineState: ConnectivityState = { isConnected: true, isInternetReachable: true };
-const unknownState: ConnectivityState = { isConnected: null, isInternetReachable: null };
-const outcomes = ['online', 'offline', 'reject'] as const;
-type Outcome = (typeof outcomes)[number];
-
-function createFakeSource() {
- const listeners = new Set<(state: ConnectivityState) => void>();
- const unsubscribe = vi.fn(() => undefined);
- const source: ConnectivitySource = {
- subscribe: listener => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- unsubscribe();
- };
- },
- };
- return {
- source,
- emit(state: ConnectivityState): void {
- for (const listener of listeners) {
- listener(state);
- }
- },
- unsubscribe,
- };
-}
-
-function createFakeTimer() {
- let now = 0;
- const scheduled: { callback: () => void; at: number; cancelled: boolean }[] = [];
- const timer: OfflineBannerTimer = {
- // oxlint-disable-next-line promise/prefer-await-to-callbacks -- manually controlled timer callbacks
- set(callback, delayMs) {
- const entry = { callback, at: now + delayMs, cancelled: false };
- scheduled.push(entry);
- return {
- cancel() {
- entry.cancelled = true;
- },
- };
- },
- };
- return {
- timer,
- scheduled,
- advanceBy(ms: number): void {
- now += ms;
- for (const entry of scheduled) {
- if (!entry.cancelled && entry.at <= now) {
- entry.cancelled = true;
- entry.callback();
- }
- }
- },
- };
-}
-
-function createStore() {
- const source = createFakeSource();
- const timer = createFakeTimer();
- const attempts: ReturnType>[] = [];
- const probe = vi.fn(async () => {
- const attempt = Promise.withResolvers();
- attempts.push(attempt);
- const result = await attempt.promise;
- return result;
- });
- const store = createOfflineBannerStore({ source: source.source, timer: timer.timer, probe });
- const changes: BannerState[] = [];
- store.subscribe(() => {
- changes.push(store.state());
- });
- return {
- store,
- source,
- timer,
- probe,
- changes,
- settle: async (index: number, outcome: Outcome) => {
- const attempt = attempts[index];
- if (!attempt) {
- throw new Error(`Missing probe ${index}`);
- }
- if (outcome === 'reject') {
- attempt.reject(new Error('Transport failed'));
- } else {
- attempt.resolve(outcome === 'online');
- }
- await Promise.allSettled([attempt.promise]);
- },
- };
-}
+ createStore,
+ offlineState,
+ onlineState,
+ outcomes,
+ radioUpUnknownState,
+ unknownState,
+} from './offline-banner-state.test-helpers';
describe('createOfflineBannerStore', () => {
it('starts unknown and stays hidden without probing unknown connectivity', () => {
@@ -253,6 +159,65 @@ describe('createOfflineBannerStore', () => {
expect(changes).toEqual(['offline']);
});
+ // The radio-back-without-reachability case (uxs3 spot check, e6-after-net:
+ // airplane mode → 3G while NetInfo's external probe never answers). The
+ // committed offline must not be preserved forever: the app's own probe is
+ // the decider, fired immediately without the five-second delay. The
+ // radioUpUnknownState fixture lives in the test helpers.
+
+ it('probes immediately on unknown with the radio up while committed offline, and clears on a reachable probe', async () => {
+ const { store, source, timer, probe, changes, settle } = createStore();
+ source.emit(offlineState);
+ timer.advanceBy(5000);
+ await settle(0, 'offline');
+ expect(store.isOffline()).toBe(true);
+
+ source.emit(radioUpUnknownState);
+ // No timer wait: the probe fired on the event itself.
+ expect(probe).toHaveBeenCalledTimes(2);
+ await settle(1, 'online');
+ expect(store.state()).toBe('online');
+ expect(store.isOffline()).toBe(false);
+ expect(changes).toEqual(['offline', 'online']);
+ });
+
+ it('keeps the offline commit when the radio-up probe fails, without a duplicate notification', async () => {
+ const { store, source, timer, probe, changes, settle } = createStore();
+ source.emit(offlineState);
+ timer.advanceBy(5000);
+ await settle(0, 'offline');
+
+ source.emit(radioUpUnknownState);
+ expect(probe).toHaveBeenCalledTimes(2);
+ await settle(1, 'offline');
+ expect(store.state()).toBe('offline');
+ expect(store.isOffline()).toBe(true);
+ expect(changes).toEqual(['offline']);
+ });
+
+ it('does not probe on unknown with the radio state itself unknown while committed offline', async () => {
+ // isConnected null means NetInfo has not settled the radio either — no
+ // new information to chase; the last committed state stands (the
+ // pre-existing preserve rule).
+ const { store, source, timer, probe, settle } = createStore();
+ source.emit(offlineState);
+ timer.advanceBy(5000);
+ await settle(0, 'offline');
+
+ source.emit(unknownState);
+ expect(probe).toHaveBeenCalledTimes(1);
+ expect(store.state()).toBe('offline');
+ });
+
+ it('does not probe on unknown while committed online (no offline to un-stick)', () => {
+ const { store, source, probe, changes } = createStore();
+ source.emit(onlineState);
+ source.emit(radioUpUnknownState);
+ expect(probe).not.toHaveBeenCalled();
+ expect(store.state()).toBe('online');
+ expect(changes).toEqual(['online']);
+ });
+
it('destroy cancels the timer, unsubscribes, and ignores a queued timer callback', () => {
const { store, source, timer, probe, changes } = createStore();
source.emit(offlineState);
diff --git a/apps/mobile/src/lib/offline-banner-state.ts b/apps/mobile/src/lib/offline-banner-state.ts
index a6a5fab234..61035fec4b 100644
--- a/apps/mobile/src/lib/offline-banner-state.ts
+++ b/apps/mobile/src/lib/offline-banner-state.ts
@@ -74,7 +74,16 @@ export function createOfflineBannerStore(options: {
}
const status = connectivityStatus(sourceState);
if (status === 'unknown') {
- // Unknown cancels confirmation but preserves the last committed state.
+ // Unknown cancels confirmation but preserves the last committed state —
+ // except committed-offline with the radio back up (e.g. airplane mode
+ // switched to 3G while NetInfo's external reachability probe never
+ // answers). Preserving offline there leaves the banner stale forever,
+ // because no further event may ever arrive; the app's own probe (its
+ // backend, not an external URL) is the decider instead (uxs3 spot
+ // check, e6-after-net). A failed probe re-commits offline unchanged.
+ if (state === 'offline' && sourceState.isConnected === true) {
+ void confirmConnectivity(attempt);
+ }
return;
}
if (status === 'online') {
diff --git a/apps/mobile/src/lib/persist/drafts.test.ts b/apps/mobile/src/lib/persist/drafts.test.ts
index 9dffaadd52..5487a1ef8a 100644
--- a/apps/mobile/src/lib/persist/drafts.test.ts
+++ b/apps/mobile/src/lib/persist/drafts.test.ts
@@ -36,6 +36,7 @@ import {
loadDraft,
NEW_SESSION_DRAFT_KEY,
prCommentDraftKey,
+ prConversationCommentDraftKey,
prMergeDraftKey,
prReplyDraftKey,
prReviewDraftKey,
@@ -137,6 +138,12 @@ describe('draft scope and entity keys', () => {
);
});
+ it('builds the per-PR conversation comment draft entity key', () => {
+ expect(prConversationCommentDraftKey('acme', 'kilo', 42)).toBe(
+ 'pr-conversation-comment:acme/kilo#42'
+ );
+ });
+
it('isMergeDraft accepts a title+message object and rejects other shapes', () => {
expect(isMergeDraft({ title: 'T', message: 'M' })).toBe(true);
expect(isMergeDraft({ title: '', message: '' })).toBe(true);
@@ -267,24 +274,46 @@ describe('merge, reply, and comment keys restore per account and destination', (
).resolves.toBeNull();
});
+ it('saves and restores a conversation comment draft under the same account and PR', async () => {
+ const key = prConversationCommentDraftKey('acme', 'kilo', 42);
+ saveDraft('u1', key, 'a conversation comment');
+ await flushDraft('u1', key);
+ await expect(loadDraft('u1', key, isStringDraft)).resolves.toBe('a conversation comment');
+ });
+
+ it('does not restore a conversation comment draft under a different account or PR', async () => {
+ const key = prConversationCommentDraftKey('acme', 'kilo', 42);
+ saveDraft('u1', key, 'a conversation comment');
+ await flushDraft('u1', key);
+ await expect(loadDraft('u2', key, isStringDraft)).resolves.toBeNull();
+ await expect(
+ loadDraft('u1', prConversationCommentDraftKey('acme', 'kilo', 43), isStringDraft)
+ ).resolves.toBeNull();
+ });
+
it('clear removes the merge, reply, and comment entries', async () => {
const mergeKey = prMergeDraftKey('acme', 'kilo', 42);
const replyKey = prReplyDraftKey('acme', 'kilo', 42, 7);
const commentKey = prCommentDraftKey('acme', 'kilo', 42, 'src/a.ts', 'RIGHT', 10);
+ const conversationKey = prConversationCommentDraftKey('acme', 'kilo', 42);
saveDraft('u1', mergeKey, { title: 'T', message: 'M' });
saveDraft('u1', replyKey, 'a reply');
saveDraft('u1', commentKey, 'a comment');
+ saveDraft('u1', conversationKey, 'a conversation comment');
await Promise.all([
flushDraft('u1', mergeKey),
flushDraft('u1', replyKey),
flushDraft('u1', commentKey),
+ flushDraft('u1', conversationKey),
]);
await clearDraft('u1', mergeKey);
await clearDraft('u1', replyKey);
await clearDraft('u1', commentKey);
+ await clearDraft('u1', conversationKey);
await expect(loadDraft('u1', mergeKey, isMergeDraft)).resolves.toBeNull();
await expect(loadDraft('u1', replyKey, isStringDraft)).resolves.toBeNull();
await expect(loadDraft('u1', commentKey, isStringDraft)).resolves.toBeNull();
+ await expect(loadDraft('u1', conversationKey, isStringDraft)).resolves.toBeNull();
});
});
diff --git a/apps/mobile/src/lib/persist/drafts.ts b/apps/mobile/src/lib/persist/drafts.ts
index 5666381c07..35a1e95a53 100644
--- a/apps/mobile/src/lib/persist/drafts.ts
+++ b/apps/mobile/src/lib/persist/drafts.ts
@@ -98,6 +98,11 @@ export function prReplyDraftKey(
return `pr-reply:${owner}/${repo}#${number}:${commentId}`;
}
+/** Regular PR conversation (issue) comment draft entity key, unique per pull request. */
+export function prConversationCommentDraftKey(owner: string, repo: string, number: number): string {
+ return `pr-conversation-comment:${owner}/${repo}#${number}`;
+}
+
/** Inline review-comment draft entity key, unique per diff position. */
// eslint-disable-next-line eslint/max-params -- the key encodes the full diff position
export function prCommentDraftKey(
diff --git a/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts
new file mode 100644
index 0000000000..32298d4b8a
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.test.ts
@@ -0,0 +1,247 @@
+// Unit coverage for the keyboard-open reply-focus scroll: the focused thread
+// row must be scrolled above the keyboard-lifted bottom CTA bar exactly once
+// per focus — against the COMMITTED viewport, never a guessed frame — and a
+// user drag must win over the parked scroll.
+//
+// The CTA bar's keyboard lift lands asynchronously and SHRINKS the list
+// viewport. The earlier one-frame guess after `keyboardDidShow` parked the
+// row against the pre-lift viewport when the lift committed later, leaving
+// the submit button behind the bar (uxs3 spot check, e7-typed). Platforms
+// commit in opposite orders (iOS: lift before didShow; Android: lift after),
+// so the suite drives both orders through the viewport-layout channel.
+//
+// The hook is mounted by calling it as a plain function with stubbed React
+// primitives (the same pattern as pr-conversation-comment-composer.test.tsx):
+// one ref/effect/callback slot per hook slot, effects run immediately and
+// collect their cleanups. The node environment has no rAF, so a synchronous
+// requestAnimationFrame stub stands in for the one-frame defer.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useReplyFocusScroll } from './use-reply-focus-scroll';
+
+const keyboardSubscribers = vi.hoisted(() => ({
+ show: null as (() => void) | null,
+ hide: null as (() => void) | null,
+}));
+
+// No Platform in the react-native mock: the hook has one implementation for
+// both platforms (the did-events fire everywhere), so any platform fork
+// would crash here instead of passing silently on the mocked OS.
+vi.mock('react-native', () => ({
+ Keyboard: {
+ addListener: vi.fn((event: string, listener: () => void) => {
+ const remove = (): void => {
+ if (event === 'keyboardDidShow') {
+ keyboardSubscribers.show = null;
+ }
+ if (event === 'keyboardDidHide') {
+ keyboardSubscribers.hide = null;
+ }
+ };
+ if (event === 'keyboardDidShow') {
+ keyboardSubscribers.show = listener;
+ }
+ if (event === 'keyboardDidHide') {
+ keyboardSubscribers.hide = listener;
+ }
+ return { remove };
+ }),
+ },
+}));
+
+type ScrollToIndex = (params: { index: number; viewPosition: number; animated: boolean }) => void;
+
+// The React-primitive slots are generic over the hook's actual call order
+// (four useRef, one useEffect, four useCallback); the mock hands out slots on
+// demand, so a hook refactor that reorders refs does not silently misalign.
+const slots = {
+ refs: [] as { current: unknown }[],
+ refCursor: 0,
+ cleanups: [] as (() => void)[],
+};
+
+vi.mock('react', () => ({
+ useRef: (initial: unknown) => {
+ if (slots.refs.length <= slots.refCursor) {
+ slots.refs.push({ current: initial });
+ }
+ const slot = slots.refs[slots.refCursor];
+ slots.refCursor += 1;
+ return slot;
+ },
+ useEffect: (effect: () => unknown) => {
+ const cleanup = effect();
+ if (typeof cleanup === 'function') {
+ slots.cleanups.push(cleanup as () => void);
+ }
+ },
+ useCallback: unknown>(factory: T): T => factory,
+}));
+
+type Mounted = {
+ markFocus: (index: number) => void;
+ onViewportLayout: (height: number) => void;
+ invalidate: () => void;
+ scrollToIndex: ReturnType;
+ unmount: () => void;
+};
+
+function mountHook(): Mounted {
+ slots.refs = [];
+ slots.refCursor = 0;
+ slots.cleanups = [];
+ const scrollToIndex = vi.fn();
+ const listRef = { current: { scrollToIndex } };
+ // Property container, not a bare `let`: the hook's return is assigned
+ // inside Harness, and control-flow narrowing of a bare variable would
+ // type it as the initial `undefined` at the spread below.
+ const produced: { current: ReturnType | undefined } = {
+ current: undefined,
+ };
+ function Harness(): null {
+ produced.current = useReplyFocusScroll(
+ listRef as unknown as Parameters[0]
+ );
+ return null;
+ }
+ // eslint-disable-next-line new-cap -- plain-function mount of the hook harness
+ Harness();
+ if (!produced.current) {
+ throw new Error('hook produced no surface');
+ }
+ return {
+ ...produced.current,
+ scrollToIndex,
+ unmount: () => {
+ for (const cleanup of slots.cleanups.splice(0)) {
+ cleanup();
+ }
+ },
+ };
+}
+
+describe('useReplyFocusScroll', () => {
+ beforeEach(() => {
+ keyboardSubscribers.show = null;
+ keyboardSubscribers.hide = null;
+ vi.stubGlobal('requestAnimationFrame', (onFrame: FrameRequestCallback) => {
+ onFrame(0);
+ return 0;
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('arms the keyboard show and hide listeners', () => {
+ const { unmount } = mountHook();
+ expect(keyboardSubscribers.show).toBeTypeOf('function');
+ expect(keyboardSubscribers.hide).toBeTypeOf('function');
+ unmount();
+ expect(keyboardSubscribers.show).toBeNull();
+ expect(keyboardSubscribers.hide).toBeNull();
+ });
+
+ it('Android order: scrolls on the post-show viewport commit, not on the show event itself', () => {
+ const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook();
+ // The unlifted baseline the list reports at mount.
+ onViewportLayout(600);
+ markFocus(4);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+
+ // The Android lift commits AFTER keyboardDidShow: the show event alone
+ // must not scroll (that is the e7-typed defect — parking against the
+ // pre-lift viewport leaves the submit button behind the lifted CTA).
+ keyboardSubscribers.show?.();
+ expect(scrollToIndex).not.toHaveBeenCalled();
+
+ onViewportLayout(300);
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
+ expect(scrollToIndex).toHaveBeenCalledWith({ index: 4, viewPosition: 1, animated: false });
+
+ // One focus arms exactly one scroll: later events are inert.
+ keyboardSubscribers.show?.();
+ onViewportLayout(280);
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('iOS order: scrolls on keyboardDidShow when the lift already committed', () => {
+ const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ markFocus(4);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+
+ // The iOS lift (keyboardWillShow padding) commits while the keyboard is
+ // still animating in: the commit arms the scroll, didShow runs it.
+ onViewportLayout(300);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+
+ keyboardSubscribers.show?.();
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
+ expect(scrollToIndex).toHaveBeenCalledWith({ index: 4, viewPosition: 1, animated: false });
+ unmount();
+ });
+
+ it('scrolls immediately for a focus that lands while the keyboard is already open', () => {
+ const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ keyboardSubscribers.show?.();
+ onViewportLayout(300);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+
+ // The viewport is committed; a second reply field needs no event.
+ markFocus(2);
+ expect(scrollToIndex).toHaveBeenCalledWith({ index: 2, viewPosition: 1, animated: false });
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('ignores viewport commits and show events when no reply is focused', () => {
+ const { onViewportLayout, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ keyboardSubscribers.show?.();
+ onViewportLayout(300);
+ keyboardSubscribers.show?.();
+ expect(scrollToIndex).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('treats a same-height re-layout as no viewport change', () => {
+ const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ markFocus(4);
+ keyboardSubscribers.show?.();
+ // Same height: a re-layout, not the lift — the committed viewport is
+ // unchanged, so there is nothing new to anchor against.
+ onViewportLayout(600);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+ onViewportLayout(300);
+ expect(scrollToIndex).toHaveBeenCalledTimes(1);
+ unmount();
+ });
+
+ it('drops the parked scroll when the user grabs the list (invalidate)', () => {
+ const { markFocus, onViewportLayout, invalidate, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ markFocus(4);
+ invalidate();
+ keyboardSubscribers.show?.();
+ onViewportLayout(300);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+ unmount();
+ });
+
+ it('drops the parked scroll when the keyboard hides before the commit lands', () => {
+ const { markFocus, onViewportLayout, scrollToIndex, unmount } = mountHook();
+ onViewportLayout(600);
+ markFocus(4);
+ keyboardSubscribers.hide?.();
+ keyboardSubscribers.show?.();
+ onViewportLayout(300);
+ expect(scrollToIndex).not.toHaveBeenCalled();
+ unmount();
+ });
+});
diff --git a/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts
new file mode 100644
index 0000000000..6a239129ec
--- /dev/null
+++ b/apps/mobile/src/lib/pr-review/discussion/use-reply-focus-scroll.ts
@@ -0,0 +1,140 @@
+import { type RefObject, useCallback, useEffect, useRef } from 'react';
+import { Keyboard } from 'react-native';
+
+import { type FlashListRef } from '@shopify/flash-list';
+
+import { type DiscussionListItem } from './review-discussion-types';
+
+export type ReplyFocusScroll = {
+ /** Record the focused thread row (its list index) and scroll it when safe. */
+ markFocus: (index: number) => void;
+ /**
+ * Feed the list's viewport layout commits into the hook (wire to the
+ * FlashList `onLayout`). The focused row is anchored against the COMMITTED
+ * viewport, never a guessed frame.
+ */
+ onViewportLayout: (height: number) => void;
+ /**
+ * The user grabbed the list: their scroll intent wins, drop any parked
+ * focus scroll (wire to the FlashList `onScrollBeginDrag`).
+ */
+ invalidate: () => void;
+};
+
+/**
+ * Keeps the focused inline reply above the keyboard-lifted bottom Comment CTA
+ * bar while the keyboard is open.
+ *
+ * Android is the exposed case: with edge-to-edge, `adjustResize` no longer
+ * resizes the window and `automaticallyAdjustKeyboardInsets` is iOS-only, so
+ * the keyboard-open lift of the CTA bar (AppAwareKeyboardPaddingView) just
+ * shrinks the list viewport under the focused row — the reply input and its
+ * submit button end up behind the lifted CTA with nothing scrolling them back
+ * into view. The fix scrolls the focused thread row so its BOTTOM (the reply
+ * input + submit button) aligns with the viewport bottom, just above the CTA.
+ *
+ * The scroll must run against the COMMITTED viewport. The earlier one-frame
+ * guess after `keyboardDidShow` parked the row against the pre-lift viewport
+ * when the CTA's padding layout landed later, and the submit button stayed
+ * behind the lifted CTA (uxs3 spot check, e7-typed). Platforms commit in
+ * opposite orders: the Android lift (on `keyboardDidShow`) lands AFTER the
+ * show event, the iOS lift (on `keyboardWillShow`) lands BEFORE it. So the
+ * hook scrolls once the viewport is known settled: on `keyboardDidShow` when
+ * a height-change commit already landed since the focus (iOS), or on the
+ * first height-change commit after the show (Android). A focus that arrives
+ * while the keyboard is up scrolls immediately — that viewport is committed.
+ * Exactly one scroll per focus; a user drag wins over the park.
+ */
+export function useReplyFocusScroll(
+ listRef: RefObject | null>
+): ReplyFocusScroll {
+ // The focused row awaiting its scroll (null = none pending).
+ const pendingIndexRef = useRef(null);
+ const keyboardVisibleRef = useRef(false);
+ // A viewport height-change landed while a focus was pending.
+ const viewportCommittedRef = useRef(false);
+ const lastHeightRef = useRef(0);
+
+ const scrollRowToViewportBottom = useCallback(
+ (index: number) => {
+ pendingIndexRef.current = null;
+ viewportCommittedRef.current = false;
+ // One frame out: the layout commit that landed this path has applied,
+ // but FlashList's own window-size bookkeeping updates in the same
+ // pass — the scroll reads it on the next frame.
+ requestAnimationFrame(() => {
+ void listRef.current?.scrollToIndex({ index, viewPosition: 1, animated: false });
+ });
+ },
+ [listRef]
+ );
+
+ useEffect(() => {
+ // The did-events fire on both platforms (iOS additionally emits the
+ // will-events; Android only the did-events), and the scroll must run
+ // after the keyboard is fully up anyway — the CTA bar's lift is still
+ // animating on willShow — so one did-event listener serves both.
+ const show = Keyboard.addListener('keyboardDidShow', () => {
+ keyboardVisibleRef.current = true;
+ // iOS: the lift already committed before this event — the viewport is
+ // settled, scroll now. Android: the lift commits after it — wait for
+ // the commit in onViewportLayout instead.
+ if (pendingIndexRef.current !== null && viewportCommittedRef.current) {
+ scrollRowToViewportBottom(pendingIndexRef.current);
+ }
+ });
+ const hide = Keyboard.addListener('keyboardDidHide', () => {
+ keyboardVisibleRef.current = false;
+ viewportCommittedRef.current = false;
+ pendingIndexRef.current = null;
+ });
+ return () => {
+ show.remove();
+ hide.remove();
+ };
+ }, [scrollRowToViewportBottom]);
+
+ const markFocus = useCallback(
+ (index: number) => {
+ pendingIndexRef.current = index;
+ viewportCommittedRef.current = false;
+ if (!keyboardVisibleRef.current) {
+ return;
+ }
+ // Focus landed while the keyboard was already up (tap into a second
+ // reply field): the viewport is committed, so scroll now.
+ scrollRowToViewportBottom(index);
+ },
+ [scrollRowToViewportBottom]
+ );
+
+ const onViewportLayout = useCallback(
+ (height: number) => {
+ const previous = lastHeightRef.current;
+ lastHeightRef.current = height;
+ // The CTA lift shrinks the list frame: the height CHANGING commit is
+ // the viewport to anchor against. A same-height event is a re-layout,
+ // and the very first layout is the unlifted baseline.
+ const heightChanged = previous !== 0 && previous !== height;
+ if (!heightChanged || pendingIndexRef.current === null) {
+ return;
+ }
+ if (keyboardVisibleRef.current) {
+ // Android: the lift just committed on top of the open keyboard.
+ scrollRowToViewportBottom(pendingIndexRef.current);
+ } else {
+ // iOS: the lift commits while the keyboard is still animating in;
+ // `keyboardDidShow` fires next and scrolls against this commit.
+ viewportCommittedRef.current = true;
+ }
+ },
+ [scrollRowToViewportBottom]
+ );
+
+ const invalidate = useCallback(() => {
+ pendingIndexRef.current = null;
+ viewportCommittedRef.current = false;
+ }, []);
+
+ return { markFocus, onViewportLayout, invalidate };
+}
diff --git a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts
index dc26f01ad4..2f89140a58 100644
--- a/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts
+++ b/apps/mobile/src/lib/pr-review/discussion/use-review-discussion-mutations.test.ts
@@ -1,4 +1,5 @@
-// P1-A-08c wiring tests for `useReplyToCommentMutation`.
+// P1-A-08c wiring tests for `useReplyToCommentMutation` and the regular
+// PR conversation comment (`useAddPrCommentMutation`).
//
// Replies are NOT optimistic (per the S7b contract): the comment is
// appended only after the server confirms. These tests assert the HOOK
@@ -9,14 +10,16 @@
// `mutationFn`. Only `useHoistedOperationKey` is mocked (it holds React
// ref state that needs a mounted renderer, covered by
// `operation-key.mounted.test.tsx`).
-/* eslint-disable max-lines -- one file for the reply wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */
+/* eslint-disable max-lines -- one file for the reply/add-comment wiring, the resolve/unresolve/reaction generation guard + chainSave/scope serialization, and the real-MutationCache scope.id serialization suites */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type * as OperationKeyModule from '@/lib/operation-key';
import type * as ReactQuery from '@tanstack/react-query';
import { prIntentFingerprint } from '@kilocode/app-shared/pr-review';
+import { announceForA11y } from '@/lib/a11y/announce';
import {
+ useAddPrCommentMutation,
useAddReactionMutation,
useRemoveReactionMutation,
useReplyToCommentMutation,
@@ -29,6 +32,10 @@ const hoistedKeys = vi.hoisted(() => ({
rotateKey: vi.fn(),
}));
+const hoistedAnnounce = vi.hoisted(() => ({
+ announceForA11y: vi.fn(),
+}));
+
vi.mock('expo-crypto', () => ({
randomUUID: () => 'not-used',
}));
@@ -38,9 +45,15 @@ vi.mock('@/lib/operation-key', async importOriginal => {
return { ...actual, useHoistedOperationKey: () => hoistedKeys };
});
+// `useAddPrCommentMutation` announces success through `announceForA11y`,
+// whose real module imports react-native (which does not parse under the
+// pure project). Mock the single import surface instead.
+vi.mock('@/lib/a11y/announce', () => ({ announceForA11y: hoistedAnnounce.announceForA11y }));
+
type MutationOptions = {
mutationFn?: (vars: unknown) => Promise;
onMutate?: (vars: unknown) => Promise | unknown;
+ onSuccess?: () => void;
onError?: (error: unknown, vars?: unknown, context?: unknown) => void;
onSettled?: (data?: unknown, error?: unknown, vars?: unknown) => Promise | void;
scope?: { id: string };
@@ -58,6 +71,7 @@ function captureOptions(run: () => unknown): MutationOptions {
let lastCapturedOptions: MutationOptions | null = null;
const replyMutateMock = vi.fn();
+const addCommentMutateMock = vi.fn();
const resolveMutateMock = vi.fn();
const unresolveMutateMock = vi.fn();
const invalidateQueriesMock = vi.fn();
@@ -73,9 +87,7 @@ vi.mock('@tanstack/react-query', () => ({
return { mutateAsync: vi.fn(), mutate: vi.fn() };
},
useQueryClient: () => ({
- invalidateQueries: (...args: unknown[]) => {
- invalidateQueriesMock(...args);
- },
+ invalidateQueries: (...args: unknown[]) => invalidateQueriesMock(...args),
cancelQueries: (...args: unknown[]) => {
cancelQueriesMock(...args);
},
@@ -98,6 +110,8 @@ vi.mock('@/lib/trpc', () => ({
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
replyToComment: { mutate: (vars: unknown) => replyMutateMock(vars) },
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
+ addIssueComment: { mutate: (vars: unknown) => addCommentMutateMock(vars) },
+ // eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
resolveThread: { mutate: (vars: unknown) => resolveMutateMock(vars) },
// eslint-disable-next-line typescript-eslint/promise-function-async -- conflicting require-await rule
unresolveThread: { mutate: (vars: unknown) => unresolveMutateMock(vars) },
@@ -117,6 +131,13 @@ const REPLY_INPUT = {
body: 'good point',
};
+const ADD_COMMENT_INPUT = {
+ owner: 'octocat',
+ repo: 'hello',
+ number: 1,
+ body: 'a regular comment',
+};
+
describe('useReplyToCommentMutation (P1-A-08c wiring)', () => {
beforeEach(() => {
lastCapturedOptions = null;
@@ -208,10 +229,12 @@ describe('useReplyToCommentMutation (P1-A-08c wiring)', () => {
expect(toastErrorMock).toHaveBeenCalledWith("Couldn't confirm — check the PR before retrying.");
});
- it('onError still toasts the message (so the retryable inline error surfaces)', () => {
+ it('toasts the retryable reply copy for a generic failure, never the raw provider message', () => {
+ // The raw GitHub access/install text is actionable to nobody; the toast
+ // mirrors the inline retryable copy (uxs3 spot check, e6-offline-banner).
useReplyToCommentMutation();
lastCapturedOptions?.onError?.(new Error('boom'));
- expect(toastErrorMock).toHaveBeenCalledWith('boom');
+ expect(toastErrorMock).toHaveBeenCalledWith('Could not reply.');
});
it('onSettled invalidates the listReviewThreads cache', async () => {
@@ -242,6 +265,179 @@ describe('reply_comment fingerprint (P1-A-08c changed-input)', () => {
});
});
+describe('useAddPrCommentMutation (regular PR conversation comment wiring)', () => {
+ beforeEach(() => {
+ lastCapturedOptions = null;
+ addCommentMutateMock.mockReset();
+ invalidateQueriesMock.mockReset();
+ toastErrorMock.mockReset();
+ hoistedAnnounce.announceForA11y.mockClear();
+ hoistedKeys.getKey.mockClear();
+ hoistedKeys.rotateKey.mockClear();
+ });
+
+ it('delegates the input to addIssueComment.mutate and resolves the comment', async () => {
+ const comment = { id: 99, htmlUrl: 'https://example.com' };
+ addCommentMutateMock.mockResolvedValueOnce(comment);
+ useAddPrCommentMutation();
+
+ await expect(lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT)).resolves.toEqual(comment);
+ expect(addCommentMutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ owner: 'octocat',
+ repo: 'hello',
+ number: 1,
+ body: 'a regular comment',
+ })
+ );
+ });
+
+ it('sends the hoisted operation key derived from the add_pr_comment fingerprint', async () => {
+ addCommentMutateMock.mockResolvedValueOnce({ id: 99 });
+ useAddPrCommentMutation();
+
+ await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT);
+
+ // The fingerprint is the dedupe identity the server hashes into
+ // `resource_key` for 30 days. Pin the exact bytes: a drift in the shared
+ // field list must fail here instead of silently rotating in-flight keys.
+ expect(hoistedKeys.getKey).toHaveBeenCalledWith(
+ '{"resource":["octocat","hello",1],"body":"a regular comment"}'
+ );
+ expect(addCommentMutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({ operationKey: 'hoisted-op-key' })
+ );
+ });
+
+ it('regenerates the key after a successful post (fresh intent next)', async () => {
+ addCommentMutateMock.mockResolvedValueOnce({ id: 99 });
+ useAddPrCommentMutation();
+
+ await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT);
+
+ expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the key on an in-progress CONFLICT and toasts the pr-comment surface copy', async () => {
+ addCommentMutateMock.mockRejectedValueOnce(new Error('operation_in_progress'));
+ useAddPrCommentMutation();
+
+ let thrown: unknown = null;
+ try {
+ await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT);
+ } catch (error) {
+ thrown = error;
+ }
+ expect(thrown).toMatchObject({ message: 'Could not post comment.' });
+ // The key stays stable so the ledger dedupes the same-key retry.
+ expect(hoistedKeys.rotateKey).not.toHaveBeenCalled();
+
+ lastCapturedOptions?.onError?.(thrown);
+ expect(toastErrorMock).toHaveBeenCalledWith('Could not post comment.');
+ });
+
+ it('regenerates the key on a non-retryable failure (bad-request ends the intent)', async () => {
+ const badRequest = new Error('Comment body is too long');
+ Object.assign(badRequest, { data: { code: 'BAD_REQUEST' } });
+ addCommentMutateMock.mockRejectedValueOnce(badRequest);
+ useAddPrCommentMutation();
+
+ await expect(lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT)).rejects.toMatchObject({
+ message: 'Comment body is too long',
+ });
+ expect(hoistedKeys.rotateKey).toHaveBeenCalledTimes(1);
+ });
+
+ it('settles a hung post on the UI deadline with the retryable taking-longer copy', async () => {
+ // e6-offline-hang: a blocked/offline request must not leave the composer
+ // on an endless spinner — the mutation itself has to settle.
+ vi.useFakeTimers();
+ try {
+ addCommentMutateMock.mockReturnValue(new Promise(() => undefined));
+ useAddPrCommentMutation();
+
+ const settled = lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT);
+ let thrown: unknown = null;
+ const recordRejection = async (): Promise => {
+ try {
+ await settled;
+ } catch (error) {
+ thrown = error;
+ }
+ };
+ // The rejection handler must be attached before the deadline timer
+ // fires, so record and advance concurrently.
+ await Promise.all([recordRejection(), vi.advanceTimersByTimeAsync(15_000)]);
+
+ expect(thrown).toMatchObject({
+ message: 'This is taking longer than expected. You can close this and check again.',
+ });
+ // The timeout is retryable: the key stays so a same-key retry is
+ // ledger-deduped if the original write eventually lands.
+ expect(hoistedKeys.rotateKey).not.toHaveBeenCalled();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it('onSettled never gates the mutation settle on the invalidation (offline-hang root)', () => {
+ // Root cause of e6-offline-hang beyond the UI deadline: v5 dispatches a
+ // mutation's terminal state only AFTER `onSettled` resolves, and a
+ // blocked/offline network hangs the refetch `invalidateQueries` triggers.
+ // The settle invalidation must therefore be fire-and-forget, or the
+ // composer sits on an endless spinner with no inline error even after the
+ // deadline rejects the write.
+ invalidateQueriesMock.mockReturnValue(new Promise(() => undefined));
+ useAddPrCommentMutation();
+
+ const settled = lastCapturedOptions?.onSettled?.();
+
+ // Returns synchronously — nothing for the mutation to await — while the
+ // invalidation still fires in the background.
+ expect(settled).toBeUndefined();
+ expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']);
+ });
+
+ it('success announces the posted-comment copy for a11y', async () => {
+ addCommentMutateMock.mockResolvedValueOnce({ id: 99 });
+ useAddPrCommentMutation();
+
+ await lastCapturedOptions?.mutationFn?.(ADD_COMMENT_INPUT);
+ lastCapturedOptions?.onSuccess?.();
+
+ expect(announceForA11y).toHaveBeenCalledWith('Comment posted');
+ });
+
+ it('toasts the retryable comment copy for a generic failure, never the raw provider message', () => {
+ useAddPrCommentMutation();
+ lastCapturedOptions?.onError?.(
+ new Error('You do not have access to this repository. Install the Kilo GitHub App.')
+ );
+ expect(toastErrorMock).toHaveBeenCalledWith('Could not post comment.');
+ });
+
+ it('onSettled invalidates the listReviewThreads cache (the conversation comments query)', async () => {
+ useAddPrCommentMutation();
+
+ await lastCapturedOptions?.onSettled?.();
+
+ expect(invalidateQueriesMock).toHaveBeenCalledWith(['githubPrReview', 'listReviewThreads']);
+ });
+});
+
+describe('add_pr_comment fingerprint (changed-input)', () => {
+ it('stays stable for a retry of the same comment and rotates when the body changes', () => {
+ const original = prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT);
+ expect(prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT)).toBe(original);
+
+ const editedBody = prIntentFingerprint('add_pr_comment', {
+ ...ADD_COMMENT_INPUT,
+ body: 'a regular comment, edited',
+ });
+ expect(editedBody).not.toBe(original);
+ });
+});
+
describe('useResolveThreadMutation (generation guard + chainSave)', () => {
beforeEach(() => {
lastCapturedOptions = null;
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
index 647d88ccd5..980194ea05 100644
--- 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
@@ -9,6 +9,16 @@
// the inline reply input keeps its own
// error state so the user can retry.
//
+// - `addPrComment` — the regular PR conversation (issue)
+// comment. Same non-optimistic contract
+// as replies: the comment is appended
+// only after the server confirms, and
+// the settle invalidation of
+// `listReviewThreads` (which fetches the
+// conversation comments) makes the
+// posted comment appear on the next
+// render. Success announces for a11y.
+//
// - `resolveThread` /
// `unresolveThread` — OPTIMISTIC. The reducer flips the
// thread's `isResolved` in the cached
@@ -38,6 +48,9 @@ import { toast } from 'sonner-native';
import { prIntentFingerprint } from '@kilocode/app-shared/pr-review';
+import { i18n } from '@/i18n';
+import { announceForA11y } from '@/lib/a11y/announce';
+import { classifyPrReviewMutationError } from '@/lib/pr-review/classify-pr-review-query-state';
import {
isLatestMutationGeneration,
nextMutationGeneration,
@@ -50,6 +63,7 @@ import {
mapPrOperationError,
prOperationToastMessage,
} from '@/lib/pr-review/merge/pr-operation-ledger';
+import { withUiDeadline } from '@/lib/ui-deadline';
import {
applyReactionToggle,
@@ -70,11 +84,46 @@ function useDiscussionKeys() {
// key guards all rollbacks across resolve/unresolve/reaction writes.
const LIST_REVIEW_THREADS_GENERATION_KEY = 'githubPrReview.listReviewThreads';
-async function invalidateDiscussionCaches(
+// Fire-and-forget ON PURPOSE: v5 dispatches a mutation's terminal state only
+// after `onSettled` resolves, and a blocked/offline network hangs the refetch
+// `invalidateQueries` triggers — an awaited invalidation would pin the
+// composer/reply UI on an endless spinner with no inline error even after the
+// UI deadline settles the write (uxs2 spot check, e6-offline-hang). The
+// invalidation still marks the cache stale and reconciles in the background
+// once the network returns; the posted comment renders on that next fetch.
+function invalidateDiscussionCaches(
queryClient: ReturnType,
keys: ReturnType
-): Promise {
- await queryClient.invalidateQueries(keys.listReviewThreadsPath);
+): void {
+ void queryClient.invalidateQueries(keys.listReviewThreadsPath);
+}
+
+// The surfaces that own a discussion composer/reply input.
+type DiscussionErrorSurface = 'reply' | 'pr-comment';
+
+// The retryable copy per surface — the same catalog keys the inline boxes
+// show, so the toast and the inline error never disagree (uxs3 spot check,
+// e6-offline-banner: the raw GitHub access/install text is actionable to
+// nobody).
+const DISCUSSION_RETRYABLE_COPY = {
+ reply: 'prReview.operation.couldNotReply',
+ 'pr-comment': 'prReview.mutationError.couldNotPostComment',
+} satisfies Record;
+
+/**
+ * Toast copy for a discussion mutation failure. The ledger markers and the
+ * code-classified rejections keep the existing display mapping; a GENERIC
+ * retryable failure must not surface the raw provider message — the toast
+ * mirrors the inline retryable copy instead.
+ */
+function discussionErrorToastMessage(error: unknown, surface: DiscussionErrorSurface): string {
+ if (mapPrOperationError(error, surface) !== error) {
+ return prOperationToastMessage(error, surface);
+ }
+ if (classifyPrReviewMutationError(error).kind === 'retryable') {
+ return i18n.t(DISCUSSION_RETRYABLE_COPY[surface]);
+ }
+ return prOperationToastMessage(error, surface);
}
// ── Reply (not optimistic) ────────────────────────────────────────────
@@ -109,10 +158,64 @@ export function useReplyToCommentMutation() {
}
},
onError: (error: { message: string }) => {
- toast.error(prOperationToastMessage(error, 'reply'));
+ toast.error(discussionErrorToastMessage(error, 'reply'));
+ },
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
+ },
+ });
+}
+
+// ── Regular PR conversation (issue) comment (not optimistic) ─────────
+
+export type AddPrCommentInput = {
+ owner: string;
+ repo: string;
+ number: number;
+ body: string;
+};
+
+// A blocked/offline request can hang the underlying fetch indefinitely; the
+// composer must never sit on a disabled Cancel + endless spinner (uxs2 spot
+// check, e6-offline-hang). Bound the wait like the rename modal does
+// (SAVE_UI_DEADLINE_MS there): past the deadline the mutation settles with
+// the retryable "taking longer" copy, the draft stays intact, and a same-key
+// retry is ledger-deduped if the original write eventually lands.
+const PR_COMMENT_UI_DEADLINE_MS = 15_000;
+
+export function useAddPrCommentMutation() {
+ const queryClient = useQueryClient();
+ const keys = useDiscussionKeys();
+ const { getKey, rotateKey } = useHoistedOperationKey();
+
+ return useMutation({
+ mutationFn: async (input: AddPrCommentInput) => {
+ try {
+ const result = await withUiDeadline(
+ trpcClient.githubPrReview.addIssueComment.mutate({
+ ...input,
+ operationKey: getKey(prIntentFingerprint('add_pr_comment', input)),
+ }),
+ PR_COMMENT_UI_DEADLINE_MS
+ );
+ rotateKey();
+ return result;
+ } catch (error) {
+ if (!isPrMutationRetryable(error)) {
+ rotateKey();
+ }
+ throw mapPrOperationError(error, 'pr-comment');
+ }
+ },
+ onSuccess: () => {
+ // Bare success announcement, mirroring useCreateReviewCommentMutation.
+ announceForA11y(i18n.t('prReview.announce.commentPosted'));
+ },
+ onError: (error: { message: string }) => {
+ toast.error(discussionErrorToastMessage(error, 'pr-comment'));
},
- onSettled: async () => {
- await invalidateDiscussionCaches(queryClient, keys);
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
},
});
}
@@ -153,8 +256,8 @@ export function useResolveThreadMutation() {
}
toast.error(error.message);
},
- onSettled: async () => {
- await invalidateDiscussionCaches(queryClient, keys);
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
},
});
}
@@ -193,8 +296,8 @@ export function useUnresolveThreadMutation() {
}
toast.error(error.message);
},
- onSettled: async () => {
- await invalidateDiscussionCaches(queryClient, keys);
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
},
});
}
@@ -242,8 +345,8 @@ export function useAddReactionMutation(threadId: string) {
}
toast.error(error.message);
},
- onSettled: async () => {
- await invalidateDiscussionCaches(queryClient, keys);
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
},
// The reaction DTO carries only {commentNodeId, content}; the owning
// threadId comes from the hook closure, so scope.id serializes network
@@ -290,8 +393,8 @@ export function useRemoveReactionMutation(threadId: string) {
}
toast.error(error.message);
},
- onSettled: async () => {
- await invalidateDiscussionCaches(queryClient, keys);
+ onSettled: () => {
+ invalidateDiscussionCaches(queryClient, keys);
},
// The reaction DTO carries only {commentNodeId, content}; the owning
// threadId comes from the hook closure, so scope.id serializes network
diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts
index 2a65bf5dd7..d325ae2a81 100644
--- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts
+++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.test.ts
@@ -48,6 +48,7 @@ describe('mapPrOperationError', () => {
['submit-review', 'Could not submit review. Check your connection and try again.'],
['reply', 'Could not reply.'],
['merge', 'Could not merge pull request.'],
+ ['pr-comment', 'Could not post comment.'],
] as const)(
'maps operation_in_progress onto the existing %s retryable copy',
(surface, expected) => {
@@ -57,7 +58,7 @@ describe('mapPrOperationError', () => {
}
);
- it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)(
+ it.each(['create-comment', 'submit-review', 'reply', 'merge', 'pr-comment'] as const)(
'maps the ambiguous outcome onto the verify-before-retrying copy for %s',
surface => {
const mapped = mapPrOperationError(AMBIGUOUS, surface);
@@ -66,7 +67,7 @@ describe('mapPrOperationError', () => {
}
);
- it.each(['create-comment', 'submit-review', 'reply', 'merge'] as const)(
+ it.each(['create-comment', 'submit-review', 'reply', 'merge', 'pr-comment'] as const)(
'maps the persistence-failure marker onto the terminal could-not-record copy for %s',
surface => {
const mapped = mapPrOperationError(PERSISTENCE_FAILED, surface);
diff --git a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts
index a846e37525..3999633c1e 100644
--- a/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts
+++ b/apps/mobile/src/lib/pr-review/merge/pr-operation-ledger.ts
@@ -14,8 +14,13 @@ export const PR_OPERATION_AMBIGUOUS_MESSAGE = "Couldn't confirm — check the PR
export const PR_OPERATION_PERSISTENCE_FAILED_MESSAGE =
'We could not record this action. Please try again later.';
-/** The four PR mutation surfaces; each has its own existing retryable copy. */
-export type PrMutationSurface = 'create-comment' | 'submit-review' | 'reply' | 'merge';
+/** The PR mutation surfaces; each has its own existing retryable copy. */
+export type PrMutationSurface =
+ | 'create-comment'
+ | 'submit-review'
+ | 'reply'
+ | 'merge'
+ | 'pr-comment';
// Existing retryable fallback copy per surface (mirrors the sheet/composer
// defaults so an in-progress duplicate reads like a normal retryable failure).
@@ -25,6 +30,7 @@ const PR_SURFACE_RETRYABLE_COPY = {
'submit-review': 'prReview.mutationError.couldNotSubmitReview',
reply: 'prReview.operation.couldNotReply',
merge: 'prReview.merge.couldNotMerge',
+ 'pr-comment': 'prReview.mutationError.couldNotPostComment',
} satisfies Record;
/**
diff --git a/apps/web/src/routers/github-pr-review-router.test.ts b/apps/web/src/routers/github-pr-review-router.test.ts
index 693d2d4c98..bd317959d3 100644
--- a/apps/web/src/routers/github-pr-review-router.test.ts
+++ b/apps/web/src/routers/github-pr-review-router.test.ts
@@ -66,6 +66,10 @@ type OctokitMock = {
get: jest.Mock;
};
git: { deleteRef: jest.Mock };
+ issues: {
+ createComment: jest.Mock;
+ getComment: jest.Mock;
+ };
repos: {
get: jest.Mock;
listCommitStatusesForRef: jest.Mock;
@@ -117,6 +121,10 @@ function buildOctokit(token: string): OctokitMock {
get: jest.fn(),
},
git: { deleteRef: jest.fn() },
+ issues: {
+ createComment: jest.fn(),
+ getComment: jest.fn(),
+ },
repos: {
get: jest.fn(),
listCommitStatusesForRef: jest.fn(),
@@ -1585,6 +1593,15 @@ const reviewResourceKey = prLedgerResourceKey('submit_review', {
comments: [{ path: 'src/foo.ts', line: 5, side: 'RIGHT', body: 'fix me' }],
});
+const ledgerAddCommentInput = {
+ owner: 'octocat',
+ repo: 'hello',
+ number: 1,
+ body: 'conversation comment body',
+ operationKey: 'key-add-comment-1',
+};
+const addCommentResourceKey = prLedgerResourceKey('add_pr_comment', ledgerAddCommentInput);
+
// The stored `resource_key` is the dedupe identity for up to 30 days. Every
// other ledger test builds both sides with `prLedgerResourceKey`, so a change
// to the fingerprint would pass unnoticed while rotating every in-flight key.
@@ -1696,6 +1713,101 @@ describe('githubPrReviewRouter PR operation ledger (P1-A-08c)', () => {
);
});
+ it('admits addIssueComment under domain pr, posts an issue comment, and settles completed', async () => {
+ getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1));
+ mockAdmitOperation.mockResolvedValueOnce({
+ admission: 'admitted',
+ row: ledgerRow({ intent: 'add_pr_comment', resource_key: addCommentResourceKey }),
+ });
+ const caller = createCaller({ user: { id: 'user-1' } as User });
+ const t1Octokit = buildOctokit('t1');
+ t1Octokit.issues.createComment.mockResolvedValueOnce({
+ data: { id: 77, node_id: 'N_77' },
+ });
+
+ const result = await caller.addIssueComment(ledgerAddCommentInput);
+
+ expect(result).toEqual({ commentId: 77, nodeId: 'N_77' });
+ // A REGULAR PR conversation comment targets the issue endpoint, not the
+ // review-comment endpoints.
+ expect(t1Octokit.issues.createComment).toHaveBeenCalledWith(
+ expect.objectContaining({
+ owner: 'octocat',
+ repo: 'hello',
+ issue_number: 1,
+ body: 'conversation comment body',
+ })
+ );
+ expect(mockAdmitOperation).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ userId: 'user-1',
+ domain: 'pr',
+ intent: 'add_pr_comment',
+ operationKey: 'key-add-comment-1',
+ resourceKey: addCommentResourceKey,
+ taxonomy: 'reconcile-first',
+ leaseSeconds: 120,
+ })
+ );
+ expect(mockSettleOperation).toHaveBeenCalledWith(
+ expect.anything(),
+ expect.objectContaining({
+ rowId: 'ledger-row-1',
+ status: 'completed',
+ outcomeCode: 'ok',
+ canonicalResult: { commentId: 77, nodeId: 'N_77' },
+ })
+ );
+ // The comment body never enters the outbox event (enum-only properties).
+ const settleCall = mockSettleOperation.mock.calls[0][1] as {
+ outboxEvent: { eventName: string; properties: Record };
+ };
+ expect(settleCall.outboxEvent.eventName).toBe('pr_operation_settled');
+ expect(settleCall.outboxEvent.properties).toEqual(
+ expect.objectContaining({
+ intent: 'add_pr_comment',
+ outcome: 'completed',
+ })
+ );
+ expect(JSON.stringify(settleCall.outboxEvent.properties)).not.toContain(
+ 'conversation comment body'
+ );
+ });
+
+ it('replays the canonical addIssueComment result on a same-key retry with a single GitHub write', async () => {
+ // Two submissions under one operationKey = one GitHub write: the retry is
+ // served from the ledger's canonical result, not by posting again.
+ getGitHubUserAccessToken.mockResolvedValue(connected('t1', 'auth_1', 1));
+ mockAdmitOperation
+ .mockResolvedValueOnce({
+ admission: 'admitted',
+ row: ledgerRow({ intent: 'add_pr_comment', resource_key: addCommentResourceKey }),
+ })
+ .mockResolvedValueOnce({
+ admission: 'duplicate_settled',
+ row: ledgerRow({
+ intent: 'add_pr_comment',
+ resource_key: addCommentResourceKey,
+ status: 'completed',
+ canonical_result: { commentId: 77, nodeId: 'N_77' },
+ }),
+ });
+ const caller = createCaller({ user: { id: 'user-1' } as User });
+ const t1Octokit = buildOctokit('t1');
+ t1Octokit.issues.createComment.mockResolvedValueOnce({
+ data: { id: 77, node_id: 'N_77' },
+ });
+
+ const first = await caller.addIssueComment(ledgerAddCommentInput);
+ const second = await caller.addIssueComment(ledgerAddCommentInput);
+
+ expect(first).toEqual({ commentId: 77, nodeId: 'N_77' });
+ expect(second).toEqual({ commentId: 77, nodeId: 'N_77', replayed: true });
+ expect(t1Octokit.issues.createComment).toHaveBeenCalledTimes(1);
+ expect(mockSettleOperation).toHaveBeenCalledTimes(1);
+ });
+
it('admits and settles submitReview and keeps free text out of the canonical result', async () => {
getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1));
mockAdmitOperation.mockResolvedValueOnce({
@@ -2470,4 +2582,18 @@ describe('githubPrReviewRouter UGC terms gate', () => {
expect(mockAdmitOperation).not.toHaveBeenCalled();
expect(t1Octokit.pulls.createReviewComment).not.toHaveBeenCalled();
});
+
+ it('rejects addIssueComment with PRECONDITION_FAILED terms_required before any ledger row or GitHub write', async () => {
+ getGitHubUserAccessToken.mockResolvedValueOnce(connected('t1', 'auth_1', 1));
+ mockTermsLookup.mockResolvedValueOnce([]);
+ const caller = createCaller({ user: { id: 'user-1' } as User });
+ const t1Octokit = buildOctokit('t1');
+
+ await expect(caller.addIssueComment(ledgerAddCommentInput)).rejects.toMatchObject({
+ code: 'PRECONDITION_FAILED',
+ message: 'terms_required',
+ });
+ expect(mockAdmitOperation).not.toHaveBeenCalled();
+ expect(t1Octokit.issues.createComment).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/web/src/routers/github-pr-review-router.ts b/apps/web/src/routers/github-pr-review-router.ts
index a061ce89cd..63bbcf049d 100644
--- a/apps/web/src/routers/github-pr-review-router.ts
+++ b/apps/web/src/routers/github-pr-review-router.ts
@@ -154,6 +154,14 @@ const ReplyToCommentInput = ownerRepoSchema
})
.strict();
+const AddIssueCommentInput = ownerRepoSchema
+ .extend({
+ number: prNumberSchema,
+ body: z.string().min(1).max(65_535),
+ operationKey: operationKeySchema,
+ })
+ .strict();
+
const SubmitReviewInput = ownerRepoSchema
.extend({
number: prNumberSchema,
@@ -1812,6 +1820,38 @@ export const githubPrReviewRouter = createTRPCRouter({
});
}),
+ // Post a REGULAR PR conversation (issue) comment — not a review-thread
+ // reply, not a review. Ledger dedupe (same operationKey replays the
+ // canonical result) is the duplicate-submission guard for the GitHub write.
+ addIssueComment: baseProcedure.input(AddIssueCommentInput).mutation(async ({ ctx, input }) => {
+ await assertTermsAccepted(ctx.user.id);
+ return runPrCommentMutation({
+ userId: ctx.user.id,
+ distinctId: ctx.user.google_user_email ?? ctx.user.id,
+ intent: 'add_pr_comment',
+ input,
+ operationKey: input.operationKey,
+ providerRefKey: 'commentId',
+ write: async octokit => {
+ const response = await octokit.issues.createComment({
+ owner: input.owner,
+ repo: input.repo,
+ issue_number: input.number,
+ body: input.body,
+ });
+ return { commentId: response.data.id, nodeId: response.data.node_id };
+ },
+ readRef: async (octokit, commentId) => {
+ const response = await octokit.issues.getComment({
+ owner: input.owner,
+ repo: input.repo,
+ comment_id: commentId,
+ });
+ return { commentId: response.data.id, nodeId: response.data.node_id };
+ },
+ });
+ }),
+
// Submit a pending review with an optional batch of inline comments and
// an overall event (APPROVE / REQUEST_CHANGES / COMMENT). The confirmed
// `state` is a bounded GitHub enum (not free text), so it is carried in the
diff --git a/packages/app-shared/src/analytics/event-map.ts b/packages/app-shared/src/analytics/event-map.ts
index 31ed86da94..3f003beaff 100644
--- a/packages/app-shared/src/analytics/event-map.ts
+++ b/packages/app-shared/src/analytics/event-map.ts
@@ -81,6 +81,7 @@ export const PR_INTENTS = [
'submit_review',
'create_review_comment',
'reply_comment',
+ 'add_pr_comment',
] as const;
export const SECURITY_INTENTS = [
'manual_sync',
diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts
index 30f1c72775..405e40e7c7 100644
--- a/packages/app-shared/src/pr-review/intent-fingerprint.test.ts
+++ b/packages/app-shared/src/pr-review/intent-fingerprint.test.ts
@@ -42,6 +42,13 @@ const REPLY_INPUT = {
body: 'good point',
};
+const ADD_COMMENT_INPUT = {
+ owner: 'octocat',
+ repo: 'hello',
+ number: 1,
+ body: 'ship it',
+};
+
// The web router hashes this string into the stored `resource_key` and the
// mobile hooks derive the operation key from it, so the bytes are the dedupe
// identity for the ledger's 30-day retention window. Pin them: a reordered or
@@ -60,6 +67,9 @@ describe('prIntentFingerprint', () => {
expect(prIntentFingerprint('reply_comment', REPLY_INPUT)).toBe(
'{"resource":["octocat","hello",1],"commentId":42,"body":"good point"}'
);
+ expect(prIntentFingerprint('add_pr_comment', ADD_COMMENT_INPUT)).toBe(
+ '{"resource":["octocat","hello",1],"body":"ship it"}'
+ );
});
it('ignores the caller insertion order', () => {
diff --git a/packages/app-shared/src/pr-review/intent-fingerprint.ts b/packages/app-shared/src/pr-review/intent-fingerprint.ts
index 6f11a3d74d..2aef194976 100644
--- a/packages/app-shared/src/pr-review/intent-fingerprint.ts
+++ b/packages/app-shared/src/pr-review/intent-fingerprint.ts
@@ -9,7 +9,12 @@
* `operation_key_reuse_mismatch`.
*/
-export type PrLedgerIntent = 'merge' | 'submit_review' | 'create_review_comment' | 'reply_comment';
+export type PrLedgerIntent =
+ | 'merge'
+ | 'submit_review'
+ | 'create_review_comment'
+ | 'reply_comment'
+ | 'add_pr_comment';
/**
* The intent inputs folded into the ledger fingerprint. Any change to one
@@ -21,6 +26,7 @@ export type PrLedgerIntent = 'merge' | 'submit_review' | 'create_review_comment'
const PR_FINGERPRINT_FIELDS: Record = {
create_review_comment: ['body', 'path', 'line', 'side', 'startLine', 'startSide', 'commitSha'],
reply_comment: ['commentId', 'body'],
+ add_pr_comment: ['body'],
submit_review: ['event', 'body', 'commitSha', 'comments'],
merge: ['method', 'commitTitle', 'commitMessage', 'deleteBranch', 'expectedHeadSha'],
};