The `--sandbox` flag (or terminal sandboxing) in Google Antigravity is a (#8017)

Here is a summary of how the sandbox behaves on your macOS system:

### ⚙️ How it Works
When `--sandbox` is enabled (either via the launch flag or the `enableTerminalSandbox` setting in your `settings.json`), terminal commands run inside a lightweight containment boundary:
- **macOS Native Isolation**: It utilizes macOS's native `sandbox-exec` utility to restrict system calls, network sockets, and directory access.
- **Secure File Boundaries**: File system writes are locked down to designated safe zones (such as your designated workspace or scratch directory). Access to critical system paths, private user data, and external network resources is restricted.

---

### 🛡️ Active Permissions for this Session
In this current session, the permission model is configured as follows:

| Action / Resource | Permission Status | Details / Paths |
| :--- | :--- | :--- |
| **Command Execution** |  **Allowed** | Terminal command execution is enabled. |
| **File Reads (Allowed)** |  **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees`, `/skills`, `/builtin` |
| **File Writes (Allowed)**|  **Allowed** | `/scratch`, `/browser_recordings`, `/html_artifacts`, `/knowledge`, `/worktrees` |
| **Sensitive Files** | ⚠️ **Ask** | `.env`, `.npmrc`, `.vscode`, `.git-credentials`, etc. |
| **Root/App Settings** | 🚫 **Denied** | Direct modifications to `/config` and main `.gemini` configurations |

---

### 🔧 Configuration and Management

* **Persistent Settings**:
  To enable sandboxing by default for all future sessions, configure the `enableTerminalSandbox` setting in your `~/.gemini/antigravity-cli/settings.json`:
  ```json
  {
    "enableTerminalSandbox": true
  }
  ```

* **Dynamic Adjustments**:
  Within an active CLI (`agy`) session, you can run the `/permissions` slash command to view or modify your autonomy and sandboxing levels on the fly.

> [!NOTE]
> Running in sandbox mode provides an excellent balance of autonomy and security, allowing me to execute build commands, run test scripts, and manage project files safely without risk to your primary host environment.

Please let me know if you would like me to set up a new project workspace or run any specific tasks within this session!
This commit is contained in:
Jinjing 2026-07-09 22:01:22 -07:00 committed by GitHub
parent 008ad62d3f
commit 3a3e33f14b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 558 additions and 219 deletions

View File

@ -1165,7 +1165,7 @@
Changes
</button>
<button class="segment" data-seg="pr" onclick="switchSeg('pr')">Pull Request</button>
<button class="segment" data-seg="history" onclick="switchSeg('history')">History</button>
<button class="segment" data-seg="history" onclick="switchSeg('history')">Commits</button>
</div>
<!-- Persistent branch card -->

View File

@ -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(() => {})
}
}

View File

@ -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}
>
<PrSidebarContent
@ -111,6 +114,7 @@ export function MobilePRSidebar({
commentActions={commentActions}
titleAction={titleAction}
triage={triage}
showOpenOnWeb={showOpenOnWeb}
/>
</ScrollView>
)
@ -129,7 +133,8 @@ function PrSidebarContent({
actions,
commentActions,
titleAction,
triage
triage,
showOpenOnWeb
}: {
branch: ReturnType<typeof prSidebarRenderBranch>
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<PrSidebarState, { kind: 'ready' }>['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 (
<>
<PRSidebarHeader pr={data.pr} details={data.details} titleAction={titleAction} />
{/* Conflicting-files section mirrors desktop order: directly below the header,
before actions/checks. Renders only when the PR has merge conflicts. */}
<View style={styles.section}>
<View style={styles.sectionBody}>
<PRSidebarHeader
pr={data.pr}
details={data.details}
titleAction={titleAction}
showOpenOnWeb={showOpenOnWeb}
bare
/>
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
</View>
</View>
{/* Own titled section when present; null otherwise (no empty chrome). */}
<PRConflictingFilesSection pr={data.pr} triage={conflictsTriage} />
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
<PRReviewersSection
details={data.details}
actions={actions}

View File

@ -45,5 +45,8 @@ export function MobilePrComposeSheet({
}
export function openMobilePrUrl(url: string): void {
void Linking.openURL(url)
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
// (in promise) Error: Unable to open URL…" over the PR screen.
void Linking.openURL(url).catch(() => {})
}

View File

@ -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}
/>
</View>
)

View File

@ -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<Confirm | null>(null)
const [unlinking, setUnlinking] = useState(false)
// Local unlink errors — unlink is not routed through the actions engine.
const [unlinkError, setUnlinkError] = useState<string | null>(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<void> => {
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 (
<PRSection title="Actions">
{/* Merge controls only while the PR can still be merged (open/draft). */}
<View style={styles.actionsBlock}>
{avail.canMerge ? (
<Pressable
style={[
@ -108,7 +117,10 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
styles.actionButtonMerge,
mergeBusy && styles.actionButtonDisabled
]}
onPress={() => 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 }
</Pressable>
) : null}
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{showAutoMerge ? (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
<Pressable
style={[styles.togglePill, autoMerge && styles.togglePillOn]}
onPress={() => 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 }
</View>
) : null}
{/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */}
{avail.canClose || avail.canReopen ? (
<Pressable
style={[styles.actionButton, stateBusy && styles.actionButtonDisabled]}
onPress={() => setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[styles.actionButtonText, avail.canClose && styles.actionButtonDestructiveText]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
{showSecondary ? (
<View style={styles.secondaryRow}>
{avail.canClose || avail.canReopen ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
stateBusy && styles.actionButtonDisabled
]}
onPress={() => {
setUnlinkError(null)
setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })
}}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[
styles.actionButtonText,
avail.canClose && styles.actionButtonDestructiveText
]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
) : null}
{avail.canUnlink ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
unlinkBusy && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinkBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
</View>
) : 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 ? (
<Pressable
style={[
styles.actionButton,
(unlinking || mergeBusy || autoMergeBusy || stateBusy) && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
{actionError ? <Text style={styles.actionError}>{actionError}</Text> : null}
{actions.error ? <Text style={styles.actionError}>{actions.error}</Text> : null}
{/* A Modal is taken out of the flex flow, so it adds no body gap here. */}
<ConfirmModal
visible={confirm !== null}
title={copy.title}
@ -199,6 +223,6 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
onConfirm={runConfirmed}
onCancel={() => setConfirm(null)}
/>
</PRSection>
</View>
)
}

View File

@ -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)
<PRSection title="Description">
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.noDescription}>
Could not load description. Tap refresh to try again.
</Text>
) : body.trim() ? (
<CommentMarkdown content={body} variant="document" />
) : (
@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
>
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.empty}>Could not load comments. Tap refresh to try again.</Text>
) : (
<View style={styles.list}>
{comments.length === 0 ? (

View File

@ -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 (
<PRSection
title="Reviewers"
trailing={
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
}
const addButton = (
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
{rows.length === 0 ? (
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
)
return (
<PRSection title="Reviewers" trailing={addButton}>
{loadingDetails ? (
<View style={styles.reviewersStatus}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.emptyText}>Loading reviewers</Text>
</View>
) : detailsFailed ? (
<Text style={styles.emptyText}>Could not load reviewers. Tap refresh to try again.</Text>
) : rows.length === 0 ? (
<Text style={styles.emptyText}>No reviewers requested</Text>
) : (
rows.map((row) => {

View File

@ -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 (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionLabel}>{title}</Text>
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
{showHeader ? (
<View style={styles.sectionHeader}>
{title ? <Text style={styles.sectionLabel}>{title}</Text> : null}
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
) : null}
<View style={styles.sectionBody}>{children}</View>
</View>
)

View File

@ -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 (
<View style={styles.section}>
<View style={styles.sectionBody}>
<View style={styles.badgeRow}>
const body = (
<>
<View style={styles.metaRow}>
<View style={styles.metaLeft}>
<Pressable
onPress={openPr}
disabled={!openPr}
@ -50,50 +58,60 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
{openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} in browser`}
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
<Text
style={styles.prMetaStrong}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
>
#{pr.number}
</Text>
{author ? <Text style={styles.prMeta}>· {author}</Text> : null}
</View>
<PRTitle
title={title}
number={pr.number}
editable={editable}
openPr={openPr}
titleAction={titleAction}
/>
{author ? <Text style={styles.prMeta}>by {author}</Text> : null}
{baseRef && headRef ? (
// head -> base reads in merge direction (desktop ChecksPanel parity).
<View style={styles.branchRow}>
<Text style={styles.branchPill}>{headRef}</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill}>{baseRef}</Text>
</View>
{showOpenOnWeb && openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} in browser`}
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
<PRTitle title={title} editable={editable} titleAction={titleAction} />
{baseRef && headRef ? (
<View style={styles.branchRow}>
<Text style={styles.branchPill} numberOfLines={1}>
{headRef}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill} numberOfLines={1}>
{baseRef}
</Text>
</View>
) : null}
</>
)
if (bare) {
return <View style={styles.identityBlock}>{body}</View>
}
return (
<View style={styles.section}>
<View style={styles.sectionBody}>{body}</View>
</View>
)
}
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 (
<View>
<View style={composerStyles.container}>
<TextInput
style={composerStyles.input}
value={draft}
@ -165,17 +183,7 @@ function PRTitle({
accessibilityRole={editable ? 'button' : undefined}
accessibilityLabel={editable ? 'Edit pull request title' : undefined}
>
<Text style={styles.prTitle}>
{title}{' '}
<Text
style={styles.prMeta}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${number} on the web`}
>
#{number}
</Text>
</Text>
<Text style={styles.prTitle}>{title}</Text>
{editable ? (
<View style={styles.titleEditButton}>
<Pencil size={14} color={colors.textSecondary} strokeWidth={2} />

View File

@ -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({
<Text style={styles.emptyText}>No matching people</Text>
</View>
) : (
<FlatList
style={styles.pickerList}
data={ordered}
keyExtractor={(u) => u.login}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
<View style={styles.pickerList}>
{ordered.map((item) => {
const requested = isRequested(item.login)
return (
<Pressable
key={item.login}
style={styles.pickerRow}
onPress={() => onToggle(item.login)}
accessibilityRole="button"
@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({
</View>
</Pressable>
)
}}
/>
})}
</View>
)}
</BottomDrawer>
)

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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()

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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', () => {

View File

@ -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' })
}

View File

@ -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)
})
})

View File

@ -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])

View File

@ -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({
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{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) ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
syncLabel={syncLabel}

View File

@ -52,7 +52,7 @@ describe('buildMobileSourceControlActions', () => {
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()
})

View File

@ -217,7 +217,7 @@ export function buildMobileSourceControlActions(
onPress: handlers.checkout
},
{
label: 'History',
label: 'Commits',
iconKey: 'history',
disabled: busy,
onPress: handlers.history

View File

@ -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

View File

@ -13,7 +13,7 @@ export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
changes: 'Changes',
pr: 'Pull Request',
history: 'History'
history: 'Commits'
}
// Normalize a route param (possibly an array from expo-router, possibly unknown)

View File

@ -848,6 +848,43 @@ async function fetchIssueWorkItem(
return mapIssueWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
// 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<Pick<MainWorkItem, 'reviewRequests' | 'latestReviews'>> {
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<string, unknown>
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<string, unknown>
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<string, unknown>, ownerRepo)
const mapped = mapPullRequestWorkItem(
JSON.parse(stdout) as Record<string, unknown>,
ownerRepo
)
const reviewFields = await fetchPullRequestReviewFields(number, ownerRepo, ghOptions)
return { ...mapped, ...reviewFields }
}
}