diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html
index 0a64d75ec..4e8035ade 100644
--- a/mobile/mock-source-control-hub.html
+++ b/mobile/mock-source-control-hub.html
@@ -1165,7 +1165,7 @@
Changes
-
+
diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx
index 5ee7ca6b4..3200dcc5d 100644
--- a/mobile/src/components/MobileMarkdown.tsx
+++ b/mobile/src/components/MobileMarkdown.tsx
@@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8
function openMarkdownUrl(url: string): void {
const trimmed = url.trim()
if (/^(https?:|mailto:)/i.test(trimmed)) {
- void Linking.openURL(trimmed)
+ void Linking.openURL(trimmed).catch(() => {})
}
}
diff --git a/mobile/src/components/MobilePRSidebar.tsx b/mobile/src/components/MobilePRSidebar.tsx
index 932f72e26..42829df03 100644
--- a/mobile/src/components/MobilePRSidebar.tsx
+++ b/mobile/src/components/MobilePRSidebar.tsx
@@ -39,6 +39,8 @@ type Props = {
gitStatus: MobileGitStatusResult | null
headSha: string | null
bottomInset?: number
+ // Hub chrome already shows open-on-web; hide the in-body icon there.
+ showOpenOnWeb?: boolean
}
// Mutation hooks run unconditionally here and gate internally until a PR is ready.
@@ -52,17 +54,15 @@ export function MobilePRSidebar({
gitBranch,
gitStatus,
headSha,
- bottomInset = 0
+ bottomInset = 0,
+ showOpenOnWeb = true
}: Props) {
const branch = prSidebarRenderBranch(state)
// prNumber is 0 until ready; the hook gates on `ready` so it never fires early.
const prNumber = state.kind === 'ready' ? state.data.pr.number : 0
- const prRepo =
- state.kind === 'ready'
- ? state.data.pr.prRepo
- ? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo }
- : null
- : null
+ // Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render
+ // reallocates and thrash-updates the mutation/comment/title hooks.
+ const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null
const actions = useMobilePrActions({
client,
connState,
@@ -95,6 +95,9 @@ export function MobilePRSidebar({
style={{ flex: 1 }}
contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]}
keyboardShouldPersistTaps="handled"
+ // Why: root-comment / reply composers sit at the bottom of this scroll
+ // area; without keyboard insets the focused field stays under the keyboard.
+ automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
)
@@ -129,7 +133,8 @@ function PrSidebarContent({
actions,
commentActions,
titleAction,
- triage
+ triage,
+ showOpenOnWeb
}: {
branch: ReturnType
state: PrSidebarState
@@ -144,6 +149,7 @@ function PrSidebarContent({
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
+ showOpenOnWeb: boolean
}) {
if (branch === 'loading') {
return (
@@ -209,6 +215,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
+ showOpenOnWeb={showOpenOnWeb}
/>
)
}
@@ -223,7 +230,8 @@ function PrSidebarSections({
commentActions,
titleAction,
triage,
- refetch
+ refetch,
+ showOpenOnWeb
}: {
data: Extract['data']
client: RpcClient | null
@@ -233,6 +241,7 @@ function PrSidebarSections({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
refetch: () => void
+ showOpenOnWeb: boolean
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -262,19 +271,30 @@ function PrSidebarSections({
isBusy: triage.isBusy('resolve-conflicts'),
error: triage.error
}
+ // One card for identity + actions so the ready PR isn't a stack of thin
+ // duplicate blocks (badge row, title, branches, then another action band).
return (
<>
-
- {/* Conflicting-files section mirrors desktop order: directly below the header,
- before actions/checks. Renders only when the PR has merge conflicts. */}
+
+
+
+
+
+
+ {/* Own titled section when present; null otherwise (no empty chrome). */}
-
{})
}
diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
index 5eb17e243..f587074e1 100644
--- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
+++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
@@ -62,6 +62,8 @@ export function MobilePrViewPanelBody({
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
+ // Hub header already hosts open-on-web while this segment is active.
+ showOpenOnWeb={false}
/>
)
diff --git a/mobile/src/components/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx
index 86b910a08..5fae68038 100644
--- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx
+++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx
@@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
import { ConfirmModal } from '../ConfirmModal'
-import { PRSection } from './PRSection'
import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability'
import { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state'
import { prActionsStyles as styles } from './pr-actions-styles'
@@ -25,12 +24,13 @@ type Confirm =
| { kind: 'merge'; method: GitHubPRMergeMethod }
| { kind: 'state'; state: 'open' | 'closed' }
-// Merge, auto-merge toggle, and close/reopen. Destructive actions route through
-// ConfirmModal first (R5). The firing row shows a spinner in place of its icon
-// and disables; other rows stay interactive (uniform visual).
+// Merge primary; Close/Reopen + Unlink share one secondary row. No section title —
+// button labels are self-explanatory and a header wasted a full row on mobile.
export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) {
const [confirm, setConfirm] = useState(null)
const [unlinking, setUnlinking] = useState(false)
+ // Local unlink errors — unlink is not routed through the actions engine.
+ const [unlinkError, setUnlinkError] = useState(null)
// Mobile keeps merge one-tap: use the repo default instead of surfacing a
// desktop-style method picker in the narrow PR action stack.
@@ -41,23 +41,31 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const mergeBusy = actions.isBusy({ kind: 'merge' })
const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' })
const stateBusy = actions.isBusy({ kind: 'state' })
+ const unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy
const showAutoMerge =
avail.canAutoMerge &&
canShowMobilePRAutoMergeControl({
...pr,
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
})
+ const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink
+ const actionError = unlinkError ?? actions.error
const unlink = useCallback(async (): Promise => {
if (!client || unlinking) {
return
}
setUnlinking(true)
+ setUnlinkError(null)
try {
const outcome = await unlinkMobilePr(client, worktreeId)
if (outcome.ok) {
onUnlinked()
+ } else {
+ setUnlinkError(outcome.error)
}
+ } catch (err) {
+ setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.')
} finally {
setUnlinking(false)
}
@@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
if (!confirm) {
return
}
+ // Engine errors take over the shared error line after this; drop unlink text.
+ setUnlinkError(null)
if (confirm.kind === 'merge') {
actions.merge(confirm.method)
} else {
@@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const copy = confirmCopy()
return (
-
- {/* Merge controls only while the PR can still be merged (open/draft). */}
+
{avail.canMerge ? (
setConfirm({ kind: 'merge', method: effectiveMethod })}
+ onPress={() => {
+ setUnlinkError(null)
+ setConfirm({ kind: 'merge', method: effectiveMethod })
+ }}
disabled={mergeBusy}
accessibilityRole="button"
accessibilityLabel="Merge pull request"
@@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
) : null}
- {/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{showAutoMerge ? (
Auto-merge when ready
actions.setAutoMerge(!autoMerge, effectiveMethod)}
+ onPress={() => {
+ setUnlinkError(null)
+ actions.setAutoMerge(!autoMerge, effectiveMethod)
+ }}
disabled={autoMergeBusy}
accessibilityRole="switch"
accessibilityState={{ checked: autoMerge }}
@@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
) : null}
- {/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */}
- {avail.canClose || avail.canReopen ? (
- setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })}
- disabled={stateBusy}
- accessibilityRole="button"
- accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
- >
- {stateBusy ? : null}
-
- {avail.canClose ? 'Close' : 'Reopen'}
-
-
+ {showSecondary ? (
+
+ {avail.canClose || avail.canReopen ? (
+ {
+ setUnlinkError(null)
+ setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })
+ }}
+ disabled={stateBusy}
+ accessibilityRole="button"
+ accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
+ >
+ {stateBusy ? : null}
+
+ {avail.canClose ? 'Close' : 'Reopen'}
+
+
+ ) : null}
+ {avail.canUnlink ? (
+ void unlink()}
+ disabled={unlinkBusy}
+ accessibilityRole="button"
+ accessibilityLabel="Unlink pull request"
+ >
+ {unlinking ? (
+
+ ) : (
+
+ )}
+ Unlink
+
+ ) : null}
+
) : null}
- {/* Unlink the PR from this worktree. Disabled while another PR mutation is in
- flight so clearing the link can't race a merge/close refetch. */}
- {avail.canUnlink ? (
- void unlink()}
- disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy}
- accessibilityRole="button"
- accessibilityLabel="Unlink pull request"
- >
- {unlinking ? (
-
- ) : (
-
- )}
- Unlink
-
- ) : null}
+ {actionError ? {actionError} : null}
- {actions.error ? {actions.error} : null}
-
- {/* A Modal is taken out of the flex flow, so it adds no body gap here. */}
setConfirm(null)}
/>
-
+
)
}
diff --git a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
index 051f1392e..b6c0a1009 100644
--- a/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
+++ b/mobile/src/components/pr-sidebar/PRCommentsSection.tsx
@@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
import { colors } from '../../theme/mobile-theme'
import { canAddRootComment } from '../../session/pr-comment-actions'
+import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions'
import { PRSection } from './PRSection'
import { CommentMarkdown } from './CommentMarkdown'
@@ -50,10 +51,15 @@ const COMMENT_PAGE = 12
// review comments, reactions, and collapsible resolved threads.
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
+ // A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
+ const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const body = details?.body ?? ''
- const comments = useMemo(() => details?.comments ?? [], [details])
- const isPr = details?.item.type === 'pr'
+ const comments = useMemo(
+ () => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []),
+ [details]
+ )
+ const isPr = details != null && !detailsFailed && details.item.type === 'pr'
// Per-card action bundle (stable callbacks from the hook) — built once so the
// memo'd cards don't re-render on unrelated timeline changes.
@@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
{loadingDetails ? (
+ ) : detailsFailed ? (
+
+ Could not load description. Tap refresh to try again.
+
) : body.trim() ? (
) : (
@@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
>
{loadingDetails ? (
+ ) : detailsFailed ? (
+ Could not load comments. Tap refresh to try again.
) : (
{comments.length === 0 ? (
diff --git a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx
index ad16241c2..bc09ecd35 100644
--- a/mobile/src/components/pr-sidebar/PRReviewersSection.tsx
+++ b/mobile/src/components/pr-sidebar/PRReviewersSection.tsx
@@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
+import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import { getPRReviewerRows } from './pr-checks-presentation'
import { ReviewerPickerDrawer } from './ReviewerPickerDrawer'
import { PRSection } from './PRSection'
@@ -20,8 +21,17 @@ type Props = {
// Requested reviewers + their latest review status, with a picker to request /
// remove (optimistic add/remove via the actions hook).
export function PRReviewersSection({ details, actions, client, worktreeId }: Props) {
+ // details === null means phase 2 (work-item payload) is still in flight — same
+ // signal Comments uses. Do not treat that as "no reviewers" or the section goes
+ // blank while checks (phase 1) already paint. A synthetic placeholder means
+ // phase 2 failed (no body/comments/reviews landed).
+ const loadingDetails = details === null
+ const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const authoritativeRows = useMemo(
- () => (details?.item ? getPRReviewerRows(details.item) : []),
+ () =>
+ details?.item && !isPrSidebarDetailsPlaceholder(details)
+ ? getPRReviewerRows(details.item)
+ : [],
[details]
)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro
return author ? [author, ...logins] : logins
}, [authoritativeRows, details])
- return (
- setPickerOpen(true)}
- accessibilityRole="button"
- accessibilityLabel="Add or remove reviewers"
- >
-
-
- }
+ const addButton = (
+ setPickerOpen(true)}
+ accessibilityRole="button"
+ accessibilityLabel="Add or remove reviewers"
>
- {rows.length === 0 ? (
+
+
+ )
+
+ return (
+
+ {loadingDetails ? (
+
+
+ Loading reviewers…
+
+ ) : detailsFailed ? (
+ Could not load reviewers. Tap refresh to try again.
+ ) : rows.length === 0 ? (
No reviewers requested
) : (
rows.map((row) => {
diff --git a/mobile/src/components/pr-sidebar/PRSection.tsx b/mobile/src/components/pr-sidebar/PRSection.tsx
index a573258d4..0ed5ce904 100644
--- a/mobile/src/components/pr-sidebar/PRSection.tsx
+++ b/mobile/src/components/pr-sidebar/PRSection.tsx
@@ -3,22 +3,28 @@ import { Text, View } from 'react-native'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
type Props = {
- title: string
+ // Optional: omit for self-explanatory sections (e.g. action buttons) so the
+ // header row doesn't waste vertical space on mobile.
+ title?: string
// Optional trailing control(s) in the header row (e.g. add-reviewer, checks
// summary + rerun). Rendered right-aligned opposite the title.
trailing?: ReactNode
children: ReactNode
}
-// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors
-// the desktop PR page's card-with-header-divider so the sections read consistently.
+// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the
+// desktop PR page's card-with-header-divider so the sections read consistently.
+// Header is omitted when neither title nor trailing is provided.
export function PRSection({ title, trailing, children }: Props) {
+ const showHeader = Boolean(title) || trailing != null
return (
-
- {title}
- {trailing ? {trailing} : null}
-
+ {showHeader ? (
+
+ {title ? {title} : null}
+ {trailing ? {trailing} : null}
+
+ ) : null}
{children}
)
diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
index bf0833031..8a399babe 100644
--- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
+++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
@@ -16,11 +16,21 @@ type Props = {
details: GitHubWorkItemDetails | null
// Inline title-edit action; the pencil affordance only shows when the PR is editable.
titleAction: MobilePrTitleAction
+ // Hub chrome already surfaces open-on-web; hide the duplicate icon in that case.
+ showOpenOnWeb?: boolean
+ // When true, render without section chrome so identity can share a card with actions.
+ bare?: boolean
}
-// Header: state badge (incl. draft — display-only), title, author, head->base.
-// The title is inline-editable on an open/draft PR (desktop parity).
-export function PRSidebarHeader({ pr, details, titleAction }: Props) {
+// Compact identity: state + # + author on one meta row, title, head→base.
+// # lives only in the meta row (not also after the title) to avoid repetition.
+export function PRSidebarHeader({
+ pr,
+ details,
+ titleAction,
+ showOpenOnWeb = true,
+ bare = false
+}: Props) {
const item = details?.item
const badge = prStateBadge(pr.state)
const badgeColor = statusColor(badge.token)
@@ -29,14 +39,12 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
const baseRef = item?.baseRefName ?? null
const headRef = item?.branchName ?? null
const editable = canEditPRTitle(pr.state)
- // Badge, #number, and the flush-right external-link control all open pr.url
- // (canonical host web URL) in the phone browser.
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
- return (
-
-
-
+ const body = (
+ <>
+
+
{badge.label}
- {openPr ? (
- [styles.iconButton, pressed && { opacity: 0.6 }]}
- >
-
-
- ) : null}
+
+ #{pr.number}
+
+ {author ? · {author} : null}
-
- {author ? by {author} : null}
- {baseRef && headRef ? (
- // head -> base reads in merge direction (desktop ChecksPanel parity).
-
- {headRef}
-
- {baseRef}
-
+ {showOpenOnWeb && openPr ? (
+ [styles.iconButton, pressed && { opacity: 0.6 }]}
+ >
+
+
) : null}
+
+ {baseRef && headRef ? (
+
+
+ {headRef}
+
+
+
+ {baseRef}
+
+
+ ) : null}
+ >
+ )
+
+ if (bare) {
+ return {body}
+ }
+ return (
+
+ {body}
)
}
function PRTitle({
title,
- number,
editable,
- openPr,
titleAction
}: {
title: string
- number: number
editable: boolean
- openPr: (() => void) | undefined
titleAction: MobilePrTitleAction
}) {
const [editing, setEditing] = useState(false)
@@ -119,7 +137,7 @@ function PRTitle({
if (editing) {
return (
-
+
-
- {title}{' '}
-
- #{number}
-
-
+ {title}
{editable ? (
diff --git a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx
index 97d89a34b..8a3a161f3 100644
--- a/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx
+++ b/mobile/src/components/pr-sidebar/ReviewerPickerDrawer.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
-import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native'
+import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubAssignableUser } from '../../../../src/shared/types'
@@ -26,9 +26,10 @@ type LoadState =
| { status: 'error'; message: string }
| { status: 'loaded'; users: GitHubAssignableUser[] }
-// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new
-// primitive). Seeded with already-requested/latest reviewers + author at the top.
-// Optimistic add/remove via onToggle.
+// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList):
+// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in
+// that ScrollView throws and can leave the Reviewers section blank.
+// Seeded reviewers + author sort first for quick un-request.
export function ReviewerPickerDrawer({
visible,
onClose,
@@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({
}
let cancelled = false
setLoad({ status: 'loading' })
- void fetchAssignableUsers(client, worktreeId).then((outcome) => {
- if (cancelled) {
- return
- }
- setLoad(
- outcome.ok
- ? { status: 'loaded', users: outcome.result }
- : { status: 'error', message: outcome.error }
- )
- })
+ void fetchAssignableUsers(client, worktreeId)
+ .then((outcome) => {
+ if (cancelled) {
+ return
+ }
+ setLoad(
+ outcome.ok
+ ? { status: 'loaded', users: outcome.result }
+ : { status: 'error', message: outcome.error }
+ )
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setLoad({ status: 'error', message: 'Failed to load people' })
+ }
+ })
return () => {
cancelled = true
}
@@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({
No matching people
) : (
- u.login}
- keyboardShouldPersistTaps="handled"
- renderItem={({ item }) => {
+
+ {ordered.map((item) => {
const requested = isRequested(item.login)
return (
onToggle(item.login)}
accessibilityRole="button"
@@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({
)
- }}
- />
+ })}
+
)}
)
diff --git a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts
index 1c66ed7a4..edf308350 100644
--- a/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts
+++ b/mobile/src/components/pr-sidebar/mobile-pr-sidebar-styles.ts
@@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
section: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle,
- overflow: 'hidden'
+ borderBottomColor: colors.borderSubtle
},
// Section header row: title + optional trailing control, divided from the body
// by a hairline border (desktop `h-10 border-b px-3`).
@@ -55,14 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '600'
},
- // Header section: state badge, title, author, base<-head branches.
- // Badge + trailing open-on-web icon (hub chromeless has no panel chrome for it).
- badgeRow: {
+ // Identity + actions share one section card (hub redesign): no per-block chrome.
+ identityBlock: {
+ gap: spacing.sm
+ },
+ // State badge + #number + author on one row; open-on-web flush right when shown.
+ metaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
+ metaLeft: {
+ flex: 1,
+ minWidth: 0,
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: spacing.xs
+ },
badge: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
@@ -76,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontWeight: '700'
},
prTitle: {
+ flex: 1,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '700',
@@ -85,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
+ // #number in the meta row — stronger than author so the identity scans first.
+ prMetaStrong: {
+ color: colors.textPrimary,
+ fontSize: typography.metaSize,
+ fontWeight: '600'
+ },
// Title row: tappable area pairing the title with a trailing edit affordance.
titleRow: {
flexDirection: 'row',
@@ -104,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({
gap: spacing.xs
},
branchPill: {
+ flexShrink: 1,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
- backgroundColor: colors.bgPanel,
+ backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: radii.button
@@ -146,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
+ // Loading / empty status row inside Reviewers (and similar) section bodies.
+ reviewersStatus: {
+ minHeight: 44,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.sm
+ },
summaryLabel: {
fontSize: typography.bodySize,
fontWeight: '700'
@@ -274,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: typography.bodySize,
marginBottom: spacing.sm
},
+ // No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we
+ // never nest a VirtualizedList inside the PR page ScrollView.
pickerList: {
- maxHeight: 320
+ gap: 0
},
pickerRow: {
minHeight: 44,
diff --git a/mobile/src/components/pr-sidebar/pr-actions-styles.ts b/mobile/src/components/pr-sidebar/pr-actions-styles.ts
index 865516e34..6d89e75bf 100644
--- a/mobile/src/components/pr-sidebar/pr-actions-styles.ts
+++ b/mobile/src/components/pr-sidebar/pr-actions-styles.ts
@@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// line). Split out of mobile-pr-sidebar-styles to keep that file under the
// 300-line cap.
export const prActionsStyles = StyleSheet.create({
+ // Bare block when identity + actions share one section card.
+ actionsBlock: {
+ gap: spacing.sm
+ },
+ // Close/Reopen + Unlink share a row so secondary actions don't stack full-width.
+ secondaryRow: {
+ flexDirection: 'row',
+ alignItems: 'stretch',
+ gap: spacing.sm
+ },
+ secondaryButton: {
+ flex: 1
+ },
// Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add).
actionButton: {
minHeight: 44,
diff --git a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts
index 3d44a9e0c..e6cb4966c 100644
--- a/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts
+++ b/mobile/src/components/pr-sidebar/pr-comment-composer-styles.ts
@@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// match the PR comment timeline; split out to keep PRCommentComposer focused.
export const prCommentComposerStyles = StyleSheet.create({
container: {
- gap: spacing.sm
+ // Input → Cancel/Save needs clear separation (title edit was flush without this).
+ gap: spacing.md
},
input: {
minHeight: 64,
diff --git a/mobile/src/session/github-pr-rpc.test.ts b/mobile/src/session/github-pr-rpc.test.ts
index 1adb72ec8..56904e482 100644
--- a/mobile/src/session/github-pr-rpc.test.ts
+++ b/mobile/src/session/github-pr-rpc.test.ts
@@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => {
expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }])
})
+ it('accepts raw gh latestReviews author.login nesting', () => {
+ const parsed = readWorkItemDetails({
+ item: {
+ id: 'n',
+ type: 'pr',
+ number: 1,
+ state: 'open',
+ latestReviews: [
+ { author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' }
+ ]
+ }
+ })
+ expect(parsed?.item.latestReviews).toEqual([
+ { login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' }
+ ])
+ })
+
it('returns null when item is unparseable', () => {
expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull()
expect(readWorkItemDetails(null)).toBeNull()
diff --git a/mobile/src/session/github-pr-value-readers.ts b/mobile/src/session/github-pr-value-readers.ts
index e7dcf9313..6ba8ccd69 100644
--- a/mobile/src/session/github-pr-value-readers.ts
+++ b/mobile/src/session/github-pr-value-readers.ts
@@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null
if (!isRecord(value)) {
return null
}
- const login = readString(value.login)
+ // Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json`
+ // keeps nested `author.login` — accept both so mobile never drops reviewers.
+ const nestedAuthor = isRecord(value.author) ? value.author : null
+ const login =
+ readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined)
if (login === undefined) {
return null
}
+ const avatarUrl =
+ readString(value.avatarUrl) ??
+ (nestedAuthor
+ ? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null)
+ : null)
return {
login,
state: readString(value.state) ?? null,
- avatarUrl: readString(value.avatarUrl) ?? null
+ avatarUrl
}
}
diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts
index 4ce32a7ce..1b4d9a1b5 100644
--- a/mobile/src/session/mobile-pr-sidebar-state.ts
+++ b/mobile/src/session/mobile-pr-sidebar-state.ts
@@ -157,6 +157,7 @@ export function resolvePrSidebarDetailsAfterPhase2(args: {
}
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
+// The synthetic id is the only id shape we mint — real GitHub node ids never match.
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
return {
item: {
@@ -175,6 +176,20 @@ export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
}
}
+// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews).
+export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean {
+ return details.item.id === `pr-${details.item.number}`
+}
+
+// Phase-2 still needs a real payload: null (in flight / not started) or a
+// synthetic placeholder from a failed fetch. Real details (including empty body)
+// do not qualify — ensure/retry must not re-pull those.
+export function prSidebarDetailsNeedFetch(
+ details: GitHubWorkItemDetails | null | undefined
+): boolean {
+ return details == null || isPrSidebarDetailsPlaceholder(details)
+}
+
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
// no visible/in-flight sidebar work (hidden or terminal error states).
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts
index 72bde8724..d879454c5 100644
--- a/mobile/src/session/pr-actions-engine.test.ts
+++ b/mobile/src/session/pr-actions-engine.test.ts
@@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle
expect(engine.error).toBe('socket hung up')
expect(engine.busy).toBeNull()
})
+
+ it('surfaces a refetch throw as error without rejecting the action promise', async () => {
+ const engine = new PrActionsEngine({
+ mutations: {
+ mergePR: async () => ({ ok: true }),
+ setPRAutoMerge: async () => ({ ok: true }),
+ updatePRState: async () => ({ ok: true }),
+ requestReviewers: async () => ({ ok: true }),
+ removeReviewers: async () => ({ ok: true }),
+ rerunChecks: async () => ({ ok: true })
+ },
+ prNumber: 1,
+ refetch: async () => {
+ throw new Error('refresh failed')
+ },
+ onChange: () => {}
+ })
+ await expect(engine.merge()).resolves.toBeUndefined()
+ expect(engine.error).toBe('refresh failed')
+ expect(engine.busy).toBeNull()
+ })
+
+ it('does not notify on no-op setError(null) at action start', async () => {
+ const onChange = vi.fn()
+ const engine = new PrActionsEngine({
+ mutations: {
+ mergePR: async () => ({ ok: true }),
+ setPRAutoMerge: async () => ({ ok: true }),
+ updatePRState: async () => ({ ok: true }),
+ requestReviewers: async () => ({ ok: true }),
+ removeReviewers: async () => ({ ok: true }),
+ rerunChecks: async () => ({ ok: true })
+ },
+ prNumber: 1,
+ refetch: () => {},
+ onChange
+ })
+ // Idle: error is already null. Action start must only notify for busy, not a
+ // redundant clearError path.
+ onChange.mockClear()
+ const p = engine.merge()
+ // First notify is setBusy only (setError(null) no-ops).
+ expect(onChange).toHaveBeenCalledTimes(1)
+ await p
+ })
})
describe('PrActionsEngine — PR identity changes', () => {
diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts
index a647b62df..7c3495453 100644
--- a/mobile/src/session/pr-actions-engine.ts
+++ b/mobile/src/session/pr-actions-engine.ts
@@ -129,7 +129,12 @@ export class PrActionsEngine {
return f
}
+ // Why: action start pairs setBusy + setError(null); skip notify when unchanged
+ // so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
+ if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) {
+ return
+ }
this.busy = key
this.cfg.onChange()
}
@@ -143,6 +148,9 @@ export class PrActionsEngine {
}
private setError(message: string | null): void {
+ if (this.error === message) {
+ return
+ }
this.error = message
this.cfg.onChange()
}
@@ -180,7 +188,14 @@ export class PrActionsEngine {
}
if (outcome.ok) {
handlers.onSuccess()
- await this.cfg.refetch()
+ // Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox.
+ try {
+ await this.cfg.refetch()
+ } catch (err) {
+ if (this.identity === identity) {
+ this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.')
+ }
+ }
return
}
// Both failure classes clear optimism to authoritative; only the message
@@ -205,6 +220,10 @@ export class PrActionsEngine {
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
+ } catch (err) {
+ if (this.identity === identity) {
+ this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.')
+ }
} finally {
this.clearBusyIfOwned(identity, { kind: 'merge' })
}
diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
index de77433fc..f402f7b58 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
@@ -5,6 +5,8 @@ import type { GitHubPrReadOutcome } from './github-pr-rpc'
import {
classifyPrSidebarFailure,
emptyPrSidebarDetails,
+ isPrSidebarDetailsPlaceholder,
+ prSidebarDetailsNeedFetch,
loadPrSidebarData,
loadPrSidebarDetails,
resolvePrSidebarDetailsAfterPhase2,
@@ -240,6 +242,29 @@ describe('resolvePrSidebarDetailsAfterPhase2', () => {
expect(empty.comments).toEqual([])
expect(empty.item.number).toBe(7)
expect(empty.item.type).toBe('pr')
+ expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true)
+ })
+})
+
+describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => {
+ it('detects the synthetic hyphen id, not the host pr:n shape', () => {
+ const placeholder = emptyPrSidebarDetails(PR)
+ expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true)
+ expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false)
+ // Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`).
+ expect(
+ isPrSidebarDetailsPlaceholder({
+ ...DETAILS,
+ item: { ...DETAILS.item, id: `pr:${PR.number}` }
+ })
+ ).toBe(false)
+ })
+
+ it('needs fetch for null and placeholders, not real details', () => {
+ expect(prSidebarDetailsNeedFetch(null)).toBe(true)
+ expect(prSidebarDetailsNeedFetch(undefined)).toBe(true)
+ expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true)
+ expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false)
})
})
diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
index 415a7581b..752b2ecb3 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
@@ -11,6 +11,7 @@ import {
import {
loadPrSidebarData,
loadPrSidebarDetails,
+ prSidebarDetailsNeedFetch,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
@@ -103,12 +104,21 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
if (!probeReady || !client) {
return
}
- void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
- if (!cancelled) {
- setIsGithubRepo(outcome.ok && outcome.result !== null)
- setRepoProbeLoaded(true)
- }
- })
+ void fetchGithubRepoSlug(client, worktreeId)
+ .then((outcome) => {
+ if (!cancelled) {
+ setIsGithubRepo(outcome.ok && outcome.result !== null)
+ setRepoProbeLoaded(true)
+ }
+ })
+ .catch(() => {
+ // Why: sendGithubPrRead already normalizes throws, but a cancelled
+ // unmount + any unexpected rejection must not surface as LogBox.
+ if (!cancelled) {
+ setIsGithubRepo(false)
+ setRepoProbeLoaded(true)
+ }
+ })
return () => {
cancelled = true
}
@@ -228,9 +238,12 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
+ // Retries synthetic placeholders too: a failed phase-2 installs non-null empty
+ // details so Description/Comments leave the spinner, and without this ensure
+ // would never re-fetch on tab re-open.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
- if (current.kind !== 'ready' || current.data.details != null) {
+ if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
return
}
const deps = buildDeps()
@@ -303,7 +316,8 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
(state.kind !== 'ready' && state.kind !== 'loading')
) {
void load({ includeDetails: true })
- } else if (state.kind === 'ready' && state.data.details == null) {
+ } else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) {
+ // Retries phase-2 placeholders too (failed details load, not only null).
void ensurePrSidebarDetails()
}
}, [identity, state, load, ensurePrSidebarDetails])
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index 332f62312..25656aae2 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -16,6 +16,7 @@ import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
+import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
@@ -177,8 +178,11 @@ export function MobileSourceControlPanel({
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
+ // Placeholder details (failed phase 2) also count as missing so reopening the
+ // PR tab retries instead of leaving empty Description/Comments forever.
const prDetailsMissingFor =
- prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
+ prController.prSidebarState.kind === 'ready' &&
+ prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
? prController.prSidebarState.data.pr.number
: null
@@ -299,6 +303,8 @@ export function MobileSourceControlPanel({
// Controller + chip state still live for instant re-open without a full cold start.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
+ // Git status always reports a conflictOperation enum; 'unknown' means none.
+ const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown'
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
return (
@@ -313,7 +319,11 @@ export function MobileSourceControlPanel({
- {ready ? (
+ {/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
+ tab they duplicate the ready PR body (#, state, checks rollup, branch
+ trajectory), so hide the whole card there and let the PR panel own it —
+ unless a merge/rebase conflict is active, which only this card can abort. */}
+ {ready && (activeTab !== 'pr' || hasActiveConflict) ? (
{
expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true)
expect(labels).toContain('Rebase onto base')
expect(labels).toContain('Switch branch')
- expect(labels).toContain('History')
+ expect(labels).toContain('Commits')
expect(labels).toContain('Create PR')
})
@@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => {
const handlers = noopHandlers()
const actions = buildMobileSourceControlActions(args({ handlers }))
action(actions, 'Switch branch')?.onPress()
- action(actions, 'History')?.onPress()
+ action(actions, 'Commits')?.onPress()
expect(handlers.checkout).toHaveBeenCalled()
expect(handlers.history).toHaveBeenCalled()
})
diff --git a/mobile/src/source-control/mobile-source-control-actions.ts b/mobile/src/source-control/mobile-source-control-actions.ts
index 0e504c5a2..b06af8a6a 100644
--- a/mobile/src/source-control/mobile-source-control-actions.ts
+++ b/mobile/src/source-control/mobile-source-control-actions.ts
@@ -217,7 +217,7 @@ export function buildMobileSourceControlActions(
onPress: handlers.checkout
},
{
- label: 'History',
+ label: 'Commits',
iconKey: 'history',
disabled: busy,
onPress: handlers.history
diff --git a/mobile/src/source-control/mobile-source-control-hub-styles.ts b/mobile/src/source-control/mobile-source-control-hub-styles.ts
index 683fa3a1d..10d126177 100644
--- a/mobile/src/source-control/mobile-source-control-hub-styles.ts
+++ b/mobile/src/source-control/mobile-source-control-hub-styles.ts
@@ -4,29 +4,29 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
// Styles for the hub's segmented control and the branch-card PR chip. Split from
// mobile-source-control-styles.ts so neither file crosses the line limit.
export const hubStyles = StyleSheet.create({
+ // Full-width tab bar under the hub header. Edge-to-edge, no inset track and no
+ // inner padding — segments share height evenly so the control doesn't float in a
+ // pill frame with gaps above/below the active cell.
segments: {
flexDirection: 'row',
- marginHorizontal: spacing.lg,
- marginTop: spacing.sm,
- padding: 3,
- borderRadius: radii.button + 2,
- backgroundColor: colors.bgRaised,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.borderSubtle,
- gap: 2
+ alignItems: 'stretch',
+ width: '100%',
+ backgroundColor: colors.bgPanel,
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: colors.borderSubtle
},
segment: {
flex: 1,
- minHeight: 34,
- borderRadius: radii.button,
+ minHeight: 40,
alignItems: 'center',
justifyContent: 'center',
- paddingHorizontal: spacing.xs
+ paddingHorizontal: spacing.xs,
+ // Reserved so active underline doesn't change layout height.
+ borderBottomWidth: 2,
+ borderBottomColor: 'transparent'
},
segmentActive: {
- backgroundColor: colors.bgPanel,
- borderWidth: StyleSheet.hairlineWidth,
- borderColor: colors.borderSubtle
+ borderBottomColor: colors.textPrimary
},
segmentPressed: {
opacity: 0.7
diff --git a/mobile/src/source-control/mobile-source-control-hub-tab.ts b/mobile/src/source-control/mobile-source-control-hub-tab.ts
index e243032a5..3a0a48406 100644
--- a/mobile/src/source-control/mobile-source-control-hub-tab.ts
+++ b/mobile/src/source-control/mobile-source-control-hub-tab.ts
@@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record = {
changes: 'Changes',
pr: 'Pull Request',
- history: 'History'
+ history: 'Commits'
}
// Normalize a route param (possibly an array from expo-router, possibly unknown)
diff --git a/src/main/github/client.ts b/src/main/github/client.ts
index 3a308a3ad..6e4005421 100644
--- a/src/main/github/client.ts
+++ b/src/main/github/client.ts
@@ -848,6 +848,43 @@ async function fetchIssueWorkItem(
return mapIssueWorkItem(JSON.parse(stdout) as Record)
}
+// REST /pulls/{n} has requested_reviewers but not latestReviews. When the JSON
+// `gh pr view` path fails, still pull review fields from gh so mobile/desktop
+// reviewer lists (CodeRabbit COMMENTED, etc.) are not silently empty.
+const WORK_ITEM_PR_REVIEW_JSON_FIELDS = 'reviewRequests,latestReviews'
+
+async function fetchPullRequestReviewFields(
+ number: number,
+ ownerRepo: OwnerRepo | null,
+ ghOptions: GhExecOptions
+): Promise> {
+ try {
+ const args = ownerRepo
+ ? [
+ 'pr',
+ 'view',
+ String(number),
+ '--repo',
+ `${ownerRepo.owner}/${ownerRepo.repo}`,
+ '--json',
+ WORK_ITEM_PR_REVIEW_JSON_FIELDS
+ ]
+ : ['pr', 'view', String(number), '--json', WORK_ITEM_PR_REVIEW_JSON_FIELDS]
+ const { stdout } = await ghExecFileAsync(args, ghOptions)
+ const item = JSON.parse(stdout) as Record
+ return {
+ ...(item.reviewRequests !== undefined
+ ? { reviewRequests: usersFromUnknown(item.reviewRequests) }
+ : {}),
+ ...(item.latestReviews !== undefined
+ ? { latestReviews: latestReviewsFromUnknown(item.latestReviews) }
+ : {})
+ }
+ } catch {
+ return {}
+ }
+}
+
async function fetchPullRequestWorkItem(
repoPath: string,
ownerRepo: OwnerRepo | null,
@@ -872,24 +909,36 @@ async function fetchPullRequestWorkItem(
)
const item = JSON.parse(stdout) as Record
const mapped = mapPullRequestWorkItem(item, ownerRepo)
+ // Why: merge-metadata GraphQL is best-effort. A failure here must not fall
+ // through to the REST path below — that path drops latestReviews and blanks
+ // the mobile/desktop reviewer list for bots that only left a review.
const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined
- const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
- return {
- ...mapped,
- mergeQueueRequired: mergeMetadata.mergeQueueRequired,
- ...(mergeMetadata.autoMergeAllowed !== null
- ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
- : {}),
- ...(mergeMetadata.mergeMethodSettings
- ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
- : {})
+ try {
+ const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, baseRefName, ghOptions)
+ return {
+ ...mapped,
+ mergeQueueRequired: mergeMetadata.mergeQueueRequired,
+ ...(mergeMetadata.autoMergeAllowed !== null
+ ? { autoMergeAllowed: mergeMetadata.autoMergeAllowed }
+ : {}),
+ ...(mergeMetadata.mergeMethodSettings
+ ? { mergeMethodSettings: mergeMetadata.mergeMethodSettings }
+ : {})
+ }
+ } catch {
+ return mapped
}
} catch {
const { stdout } = await ghExecFileAsync(
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`],
ghOptions
)
- return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo)
+ const mapped = mapPullRequestWorkItem(
+ JSON.parse(stdout) as Record,
+ ownerRepo
+ )
+ const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
+ return { ...mapped, ...reviewFields }
}
}