From a91ab04b1953a67cbf4e01f2c92d4a3e77ee78c8 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Thu, 9 Jul 2026 02:07:18 -0700
Subject: [PATCH] Optimize and integrate mobile PR view within source control
hub
- Lazy-load heavy PR comments and descriptions (Phase 2) only when the
PR tab is active, using fast metadata (Phase 1) for the branch chip.
- Unmount the PR body when inactive to avoid unnecessary comment tree
re-renders and preserve WebView resources during commit text editing.
- Implement soft-refresh on HEAD advancement to keep the ready UI
visible while re-fetching checks post-commit.
- Display the "Aborting..." label only when a merge or rebase abort
is actively in flight.
- Memoize the git history list and skip branch identity RPCs when
gating the dock icon.
---
mobile/app/h/[hostId]/pr/[worktreeId].tsx | 4 +-
.../app/h/[hostId]/session/[worktreeId].tsx | 36 +-
mobile/mock-source-control-hub.html | 433 ++++++++++++++++--
.../pr-sidebar/MobilePrViewPanel.tsx | 192 +-------
.../components/pr-sidebar/PRSidebarHeader.tsx | 41 +-
.../pr-sidebar/mobile-pr-sidebar-styles.ts | 14 +
mobile/src/session/SessionDockColumn.tsx | 60 +--
mobile/src/session/mobile-pr-sidebar-state.ts | 41 ++
.../session/use-mobile-pr-branch-context.ts | 19 +-
.../use-mobile-pr-sidebar-controller.test.ts | 62 +++
.../use-mobile-pr-sidebar-controller.ts | 235 ++++++++--
.../source-control/MobileGitHistoryList.tsx | 8 +-
.../MobileSourceControlBranchCard.tsx | 7 +-
.../MobileSourceControlHeader.tsx | 25 +-
.../MobileSourceControlPanel.tsx | 142 ++++--
...bile-source-control-conflict-abort.test.ts | 28 ++
.../mobile-source-control-conflict-abort.ts | 18 +
17 files changed, 988 insertions(+), 377 deletions(-)
create mode 100644 mobile/src/source-control/mobile-source-control-conflict-abort.test.ts
create mode 100644 mobile/src/source-control/mobile-source-control-conflict-abort.ts
diff --git a/mobile/app/h/[hostId]/pr/[worktreeId].tsx b/mobile/app/h/[hostId]/pr/[worktreeId].tsx
index 6e17b41019b..f2cf699269e 100644
--- a/mobile/app/h/[hostId]/pr/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/pr/[worktreeId].tsx
@@ -3,8 +3,8 @@ import { firstParam } from '../../../../src/source-control/mobile-source-control
// The Pull Request view is now a segment of the Source Control hub. This route
// stays as a thin redirect so existing deep links land on the hub with the Pull
-// Request segment selected. The wide-layout dock still renders MobilePrViewPanel
-// directly (it does not route through here).
+// Request segment selected. The wide-layout dock opens the same hub with
+// initialTab="pr" (SessionDockColumn), not this route.
export default function PrRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 95aa8d7384e..da2aae83fce 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -856,20 +856,16 @@ export default function SessionScreen() {
setActivePanel(null)
}
}, [canDockPanel, activePanel])
- // Session-level PR context feeds the docked PR panel and gates the GitHub-only
- // PR entry so GitLab/other providers do not open a GitHub RPC surface.
- const {
- branch: prBranch,
- headSha: prHeadSha,
- status: prStatus,
- isGithubRepo: prIsGithubRepo,
- repoLoaded: prRepoContextLoaded,
- loaded: prContextLoaded
- } = useMobilePrBranchContext({
- client,
- connState,
- worktreeId
- })
+ // Session-level GitHub remote probe gates the PR dock icon so non-GitHub
+ // providers do not open the hosted-review surface. Branch/head/status for the
+ // hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs.
+ const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
+ useMobilePrBranchContext({
+ client,
+ connState,
+ worktreeId,
+ includeBranchIdentity: false
+ })
useEffect(() => {
if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') {
setActivePanel(null)
@@ -4438,8 +4434,9 @@ export default function SessionScreen() {
hostId,
worktreeId,
name: worktreeName || '',
- // Source control's post-diff-open dismissal keys off origin: 'session' (U2).
- ...(action.panel === 'sourceControl' ? { origin: 'session' } : {}),
+ // SC + PR both land on the source-control hub; post-diff-open dismissal
+ // keys off origin: 'session' (U2). Files keeps its own route without origin.
+ ...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
}
@@ -5127,13 +5124,6 @@ export default function SessionScreen() {
hostId={hostId}
worktreeId={worktreeId}
name={worktreeName || ''}
- client={client}
- connState={connState}
- branch={prBranch}
- headSha={prHeadSha}
- gitStatus={prStatus}
- isGithubRepo={prIsGithubRepo}
- branchContextLoaded={prContextLoaded && prRepoContextLoaded}
availableWidth={sessionContentRowWidth}
onRequestClose={() => setActivePanel(null)}
onFileOpenStart={handleFileOpenStart}
diff --git a/mobile/mock-source-control-hub.html b/mobile/mock-source-control-hub.html
index 8b09552ac7a..0a64d75ecbf 100644
--- a/mobile/mock-source-control-hub.html
+++ b/mobile/mock-source-control-hub.html
@@ -1258,21 +1258,54 @@
-
+
+
+
M
-
+
+
+
+
EditorFileTab.tsx
src/renderer/src/components/tab-bar/
- +12 −4
+ +12 −4
-
+
+
+
@@ -1293,17 +1326,53 @@
+ Stage
🗑 Discard
-
+
+
M
-
+
+
+
CenterSplitDivider.tsx
src/renderer/src/components/split-view/
- +34 −8
+ +34 −8
-
+
+
+ Stage
🗑 Discard
-
+
+
M
-
+
+
+
split-view-geometry.ts
src/renderer/src/lib/
- +6 −2
+ +6 −2
-
+
+
+ Stage
🗑 Discard
-
+
+
A
-
+
+
+
SplitDividerHandle.test.tsx
- src/renderer/src/components/split-view/__tests__/
- +48 −0
+ src/renderer/src/components/split-view/__tests__/
+ +48 −0
-
+
+
@@ -1356,22 +1499,78 @@
Commit
-
+
+
+
@@ -1383,7 +1582,13 @@
0 selected
Stage
Unstage
-
Discard
+
+ Discard
+
@@ -1396,10 +1601,14 @@
Open
#7701
- octocat wants to merge into main
+ octocat wants to merge into main
Make center split divider more visible
- make-center-split-more-visible → main · updated 20m ago
+
+ make-center-split-more-visible → main · updated 20m ago
+
@@ -1407,25 +1616,71 @@
1 check failing
-
+
+
Fix failing checks
-
+
+
+
e2e / playwright (ubuntu-latest)
Failing · 1 test · 2m 14s
- Rerun
+
+ Rerun
+
Merge method
-
Squash and merge
-
+ Squash and merge
+
+
+
@@ -1433,10 +1688,25 @@
Auto-merge when checks pass
Merges automatically once required checks are green
-
+
+
+
-
+
+
+
+
+
Squash and merge
1 failing check must pass before merging
@@ -1448,20 +1718,37 @@
· 3
-
+
+
Resolve with AI
- O
+ O
octocat
Approved
- D
+ D
dstndev
Review requested
@@ -1492,12 +1779,41 @@
Rerun failed
-
-
build / typecheck Passed · 1m 02s
+
+
+
build / typecheck Passed · 1m 02s
-
-
lint / oxlint Passed · 38s
+
+
+
lint / oxlint Passed · 38s
@@ -1507,18 +1823,26 @@
-
+
Make center split divider more visible
-
a1b2c3d · Jinjing · 18m ago
+
+ a1b2c3d · Jinjing · 18m ago
+
Add split-view geometry helper
-
9f8e7d6 · Jinjing · 42m ago
+
+ 9f8e7d6 · Jinjing · 42m ago
+
@@ -1527,11 +1851,17 @@
-
+
Merge pull request #7699 from orca/main
-
0f7658d · web-flow · 3h ago
+
+ 0f7658d · web-flow · 3h ago
+
@@ -1645,10 +1975,13 @@
diff: '
',
file: '
',
stage: '
',
- discard: '
',
+ discard:
+ '
',
copy: '
',
- remote: '
',
- merge: '
',
+ remote:
+ '
',
+ merge:
+ '
',
hash: '
',
message: '
',
sparkle: '
'
diff --git a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
index 91cac6f2fe8..5eb17e243d9 100644
--- a/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
+++ b/mobile/src/components/pr-sidebar/MobilePrViewPanel.tsx
@@ -1,20 +1,13 @@
-import { useEffect } from 'react'
-import { Pressable, StyleSheet, Text, View } from 'react-native'
-import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
-import { useRouter } from 'expo-router'
-import { ChevronLeft, ExternalLink, X } from 'lucide-react-native'
-import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
+import { StyleSheet, View } from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { colors } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
-import {
- useMobilePrSidebarController,
- type MobilePrSidebarController
-} from '../../session/use-mobile-pr-sidebar-controller'
+import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
-import { openMobilePrUrl } from '../MobilePrComposeSheet'
-type BodyProps = {
+type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
@@ -24,32 +17,11 @@ type BodyProps = {
isGithubRepo?: boolean
branchContextLoaded?: boolean
controller: MobilePrSidebarController
- // 'chromeless' drops the panel's own header + SafeAreaView so it can live inside
- // the source-control hub, which owns the header, segmented control, and chrome.
- chromeless?: boolean
- // When false, the parent (the hub) owns triggering the initial load — the body
- // must not also fire it, or a ?tab=pr deep link double-fetches. Default true so
- // the standalone route stays self-loading.
- autoLoad?: boolean
- embedded?: boolean
- onRequestClose?: () => void
-}
-
-type Props = Omit
-
-// Standalone entry (the /pr route + wide-layout dock): owns its controller and
-// self-loads. The hub uses MobilePrViewPanelBody with a shared controller instead.
-export function MobilePrViewPanel(props: Props) {
- const controller = useMobilePrSidebarController({
- client: props.client,
- connState: props.connState,
- worktreeId: props.worktreeId,
- branch: props.branch,
- headSha: props.headSha
- })
- return
}
+// Chromeless PR sidebar body for the source-control hub's Pull Request segment.
+// The hub owns the header, segmented control, load triggers, and the shared
+// controller (one fetch feeds both the branch-card chip and this body).
export function MobilePrViewPanelBody({
client,
connState,
@@ -59,28 +31,10 @@ export function MobilePrViewPanelBody({
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
- controller,
- chromeless = false,
- autoLoad = true,
- embedded = false,
- onRequestClose
-}: BodyProps) {
- const router = useRouter()
+ controller
+}: Props) {
const insets = useSafeAreaInsets()
- // A docked/full-screen PR panel is always visible — there is no drawer to open,
- // so trigger the load directly once context is ready rather than gating on the
- // showPRSidebar overlay flag (KTD4). Skipped when the hub owns the trigger.
- const prSidebarKind = controller.prSidebarState.kind
- const refetch = controller.refetchPRSidebar
- useEffect(() => {
- if (autoLoad && branch && isGithubRepo && prSidebarKind === 'hidden') {
- refetch()
- }
- }, [autoLoad, branch, isGithubRepo, prSidebarKind, refetch])
-
- // Embedded: the dock column applies the bottom inset; full-screen relies on its own
- // SafeAreaView (edges top only), so content must clear the home indicator itself.
const sidebarState = !branchContextLoaded
? ({ kind: 'loading' } as const)
: !isGithubRepo
@@ -94,91 +48,22 @@ export function MobilePrViewPanelBody({
message: 'Current branch unavailable.'
} as const)
: controller.prSidebarState
- // Why: open-on-host lives in the chrome so the PR URL is always flush-right of
- // the screen title, even when the body is scrolled past the PR header.
- const prUrl =
- sidebarState.kind === 'ready' && sidebarState.data.pr.url ? sidebarState.data.pr.url : null
- const prNumber = sidebarState.kind === 'ready' ? sidebarState.data.pr.number : null
- const openPr = prUrl ? () => openMobilePrUrl(prUrl) : undefined
- const openPrControl = openPr ? (
- [styles.iconButton, pressed && styles.iconButtonPressed]}
- onPress={openPr}
- hitSlop={8}
- accessibilityRole="link"
- accessibilityLabel={
- prNumber != null
- ? `Open pull request #${prNumber} on the web`
- : 'Open pull request on the web'
- }
- >
-
-
- ) : null
- const sidebar = (
-
- )
-
- // Chromeless: the hub owns the header + chrome, so render just the scrollable body.
- if (chromeless) {
- return {sidebar}
- }
-
- if (embedded) {
- return (
-
-
-
- [styles.iconButton, pressed && styles.iconButtonPressed]}
- onPress={onRequestClose}
- hitSlop={8}
- accessibilityLabel="Close pull request panel"
- >
-
-
-
- Pull Request
-
- {openPrControl}
-
-
- {sidebar}
-
- )
- }
return (
-
-
-
- [styles.iconButton, pressed && styles.iconButtonPressed]}
- onPress={() => router.back()}
- hitSlop={8}
- accessibilityLabel="Back to session"
- >
-
-
-
- Pull Request
-
- {openPrControl}
-
-
- {sidebar}
-
+
+
+
)
}
@@ -186,34 +71,5 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
- },
- header: {
- backgroundColor: colors.bgPanel,
- borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: colors.borderSubtle
- },
- topBar: {
- minHeight: 58,
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.md,
- paddingHorizontal: spacing.md
- },
- iconButton: {
- width: 36,
- height: 36,
- alignItems: 'center',
- justifyContent: 'center',
- borderRadius: radii.button
- },
- iconButtonPressed: {
- backgroundColor: colors.bgRaised
- },
- title: {
- flex: 1,
- minWidth: 0,
- color: colors.textPrimary,
- fontSize: typography.titleSize,
- fontWeight: '600'
}
})
diff --git a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
index d974089c326..3d342820bbf 100644
--- a/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
+++ b/mobile/src/components/pr-sidebar/PRSidebarHeader.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
-import { ArrowRight, Pencil } from 'lucide-react-native'
+import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
@@ -36,19 +36,32 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
return (
- [
- styles.badge,
- { borderColor: badgeColor },
- pressed && { opacity: 0.6 }
- ]}
- >
- {badge.label}
-
+
+ [
+ styles.badge,
+ { borderColor: badgeColor },
+ pressed && { opacity: 0.6 }
+ ]}
+ >
+ {badge.label}
+
+ {openPr ? (
+ [styles.openWebButton, pressed && { opacity: 0.6 }]}
+ >
+
+
+ ) : null}
+
hostId: string
worktreeId: string
name: string
- client: RpcClient | null
- connState: ConnectionState
- branch: string | null
- headSha: string | null
- gitStatus: MobileGitStatusResult | null
- isGithubRepo: boolean
- branchContextLoaded: boolean
availableWidth: number
onRequestClose: () => void
onFileOpenStart?: () => void
@@ -40,13 +29,6 @@ export function SessionDockColumn({
hostId,
worktreeId,
name,
- client,
- connState,
- branch,
- headSha,
- gitStatus,
- isGithubRepo,
- branchContextLoaded,
availableWidth,
onRequestClose,
onFileOpenStart,
@@ -63,13 +45,6 @@ export function SessionDockColumn({
hostId={hostId}
worktreeId={worktreeId}
name={name}
- client={client}
- connState={connState}
- branch={branch}
- headSha={headSha}
- gitStatus={gitStatus}
- isGithubRepo={isGithubRepo}
- branchContextLoaded={branchContextLoaded}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
@@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({
hostId,
worktreeId,
name,
- client,
- connState,
- branch,
- headSha,
- gitStatus,
- isGithubRepo,
- branchContextLoaded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: DockPanelContentProps) {
- if (activePanel === 'sourceControl') {
+ // Source Control and Pull Request share one hub instance so swapping dock icons
+ // keeps commit draft / visited-tab / scroll state and lands on the right segment
+ // (design: PR dock maps to hub + tab=pr).
+ if (activePanel === 'sourceControl' || activePanel === 'pr') {
return (
)
}
- if (activePanel === 'files') {
- return (
-
- )
- }
return (
-
diff --git a/mobile/src/session/mobile-pr-sidebar-state.ts b/mobile/src/session/mobile-pr-sidebar-state.ts
index 5f5b679b210..4ce32a7cec7 100644
--- a/mobile/src/session/mobile-pr-sidebar-state.ts
+++ b/mobile/src/session/mobile-pr-sidebar-state.ts
@@ -140,6 +140,47 @@ export async function loadPrSidebarDetails(
}
}
+// UI treats details===null as "still loading". After phase 2 finishes (success or
+// non-fatal failure), never leave null — use prior comments if any, else empty body.
+export function resolvePrSidebarDetailsAfterPhase2(args: {
+ fetched: GitHubWorkItemDetails | null
+ prior: GitHubWorkItemDetails | null
+ pr: PRInfo
+}): GitHubWorkItemDetails {
+ if (args.fetched != null) {
+ return args.fetched
+ }
+ if (args.prior != null) {
+ return args.prior
+ }
+ return emptyPrSidebarDetails(args.pr)
+}
+
+// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
+export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
+ return {
+ item: {
+ id: `pr-${pr.number}`,
+ type: 'pr',
+ number: pr.number,
+ title: pr.title,
+ state: pr.state,
+ url: pr.url,
+ labels: [],
+ updatedAt: pr.updatedAt,
+ author: null
+ },
+ body: '',
+ comments: []
+ }
+}
+
+// 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 {
+ return kind === 'ready' || kind === 'none' || kind === 'loading'
+}
+
// Stale-response guard (KTD6): a load tagged with an older sequence must not
// overwrite a newer one. The hook bumps a monotonic counter per load.
export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean {
diff --git a/mobile/src/session/use-mobile-pr-branch-context.ts b/mobile/src/session/use-mobile-pr-branch-context.ts
index ef2cf1a48d5..d0816b02f71 100644
--- a/mobile/src/session/use-mobile-pr-branch-context.ts
+++ b/mobile/src/session/use-mobile-pr-branch-context.ts
@@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext(
// Loads repo eligibility independently from branch/SHA. The header PR icon only
// needs the cheap GitHub probe; the panel can keep loading branch context after
// the entry point is already stable in the top bar.
+//
+// `includeBranchIdentity: false` skips git.status + branchCompare — use when the
+// caller only gates a PR entry on hosted-repo eligibility (session dock icon).
export function useMobilePrBranchContext(input: {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
+ includeBranchIdentity?: boolean
}): MobilePrBranchContext {
- const { client, connState, worktreeId } = input
+ const { client, connState, worktreeId, includeBranchIdentity = true } = input
const [context, setContext] = useState({
branch: null,
headSha: null,
@@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
- loaded: false
+ loaded: !includeBranchIdentity
})
return
}
@@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
- loaded: false
+ // Repo-only mode is "loaded" for branch fields immediately (they stay null).
+ loaded: !includeBranchIdentity
})
void loadMobilePrRepoContext(client, worktreeId)
@@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: {
}
})
+ if (!includeBranchIdentity) {
+ return () => {
+ cancelled = true
+ }
+ }
+
void loadMobilePrBranchIdentity(client, worktreeId)
.then((next) => {
if (!cancelled) {
@@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: {
return () => {
cancelled = true
}
- }, [ready, client, worktreeId])
+ }, [ready, client, worktreeId, includeBranchIdentity])
return context
}
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 005c4dc0e5b..de77433fcb6 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.test.ts
@@ -4,11 +4,15 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import type { GitHubPrReadOutcome } from './github-pr-rpc'
import {
classifyPrSidebarFailure,
+ emptyPrSidebarDetails,
loadPrSidebarData,
loadPrSidebarDetails,
+ resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
+ shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps
} from './mobile-pr-sidebar-state'
+import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller'
function ok(result: T): GitHubPrReadOutcome {
return { ok: true, result }
@@ -200,3 +204,61 @@ describe('shouldApplyResult', () => {
expect(shouldApplyResult(2, 3)).toBe(false)
})
})
+
+describe('shouldSoftRefreshPrSidebarOnHeadChange', () => {
+ it('restarts ready/none/loading so mid-flight head advances are not stuck', () => {
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true)
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true)
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true)
+ })
+
+ it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => {
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false)
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false)
+ expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false)
+ })
+})
+
+describe('resolvePrSidebarDetailsAfterPhase2', () => {
+ it('prefers a successful fetch over prior details', () => {
+ const prior = emptyPrSidebarDetails(PR)
+ expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS)
+ })
+
+ it('keeps prior details when the fetch is non-fatal null', () => {
+ const prior = emptyPrSidebarDetails(PR)
+ expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior)
+ })
+
+ it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => {
+ const empty = resolvePrSidebarDetailsAfterPhase2({
+ fetched: null,
+ prior: null,
+ pr: PR
+ })
+ expect(empty.body).toBe('')
+ expect(empty.comments).toEqual([])
+ expect(empty.item.number).toBe(7)
+ expect(empty.item.type).toBe('pr')
+ })
+})
+
+describe('buildMobilePrSidebarIdentity', () => {
+ it('keys identity on worktree + branch, not head SHA', () => {
+ expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat')
+ // Head advances on commit must not form a new identity — soft refresh keeps ready UI.
+ expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe(
+ buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })
+ )
+ })
+
+ it('is null without a branch and changes when branch or worktree changes', () => {
+ expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull()
+ expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
+ buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' })
+ )
+ expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
+ buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' })
+ )
+ })
+})
diff --git a/mobile/src/session/use-mobile-pr-sidebar-controller.ts b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
index ea3da7181d8..f59242a0583 100644
--- a/mobile/src/session/use-mobile-pr-sidebar-controller.ts
+++ b/mobile/src/session/use-mobile-pr-sidebar-controller.ts
@@ -11,7 +11,9 @@ import {
import {
loadPrSidebarData,
loadPrSidebarDetails,
+ resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
+ shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps,
type PrSidebarState
} from './mobile-pr-sidebar-state'
@@ -27,12 +29,20 @@ type PrSidebarControllerInput = {
headSha: string | null
}
-function buildPrSidebarIdentity(args: {
+// Load options for the shared PR controller. The Source Control hub chip only needs
+// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
+// Pull Request segment is actually open.
+export type PrSidebarLoadOptions = {
+ includeDetails?: boolean
+}
+
+// Identity is worktree + branch only. Head SHA advances on every commit and must not
+// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
+export function buildMobilePrSidebarIdentity(args: {
worktreeId: string
branch: string | null
- headSha: string | null
}): string | null {
- return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null
+ return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null
}
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
@@ -41,13 +51,27 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
// independent of whether the branch has an open PR (a no-PR branch shows an
// empty state rather than hiding the icon).
const [isGithubRepo, setIsGithubRepo] = useState(false)
+ // False until the probe resolves for this worktree. Consumers gate "unavailable
+ // for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
+ const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
const [state, setState] = useState({ kind: 'hidden' })
const [showPRSidebar, setShowPRSidebar] = useState(false)
const loadSeqRef = useRef(0)
+ // Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
+ // phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
+ const detailsSeqRef = useRef(0)
+ // The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
+ // fill-in effect fires as soon as phase 1 renders ready with null details —
+ // exactly when load()'s own phase 2 just started. Without this claim, every
+ // cold PR-segment open fetched the heavy details payload twice.
+ const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
const stateIdentityRef = useRef(null)
+ const stateRef = useRef(state)
+ stateRef.current = state
+ const headShaRef = useRef(headSha)
const ready = client !== null && connState === 'connected' && !!branch
- const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha })
+ const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
const buildDeps = useCallback((): PrSidebarLoadDeps | null => {
if (!client) {
@@ -63,15 +87,22 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}, [client])
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
+ // Worktree change must reset eligibility; a brief disconnect must not — otherwise
+ // the hub shows "unavailable for this provider" and hides the chip mid-session.
+ useEffect(() => {
+ setIsGithubRepo(false)
+ setRepoProbeLoaded(false)
+ }, [worktreeId])
+
useEffect(() => {
let cancelled = false
if (!ready || !client) {
- setIsGithubRepo(false)
return
}
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
+ setRepoProbeLoaded(true)
}
})
return () => {
@@ -82,47 +113,183 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
useEffect(() => {
if (!identity) {
loadSeqRef.current += 1
+ detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
return
}
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
- // Why: ready/loading data is scoped to branch+head. A branch switch must
- // not let the open panel keep rendering the previous PR as "fresh."
+ // Why: ready/loading data is scoped to branch. A branch switch must not let
+ // the open panel keep rendering the previous PR as "fresh."
loadSeqRef.current += 1
+ detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
}
}, [identity])
- const load = useCallback(async () => {
+ const load = useCallback(
+ async (options?: PrSidebarLoadOptions) => {
+ const includeDetails = options?.includeDetails ?? true
+ const deps = buildDeps()
+ const loadIdentity = identity
+ if (!deps || !branch || !loadIdentity) {
+ return
+ }
+ const seq = loadSeqRef.current + 1
+ loadSeqRef.current = seq
+ // In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
+ // ownership when its own phase 2 actually starts (the bump below), so a load
+ // superseded at the phase-1 guard can never orphan a detailsSeq and silently
+ // discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
+ // its identity/number/kind guards only let matching details through.
+ const previousIdentity = stateIdentityRef.current
+ stateIdentityRef.current = loadIdentity
+ // Soft refresh: same branch already showing ready/none stays visible while
+ // checks re-fetch (head advanced after commit). Hard loading only on first load
+ // or after a real identity wipe.
+ const keepVisible =
+ previousIdentity === loadIdentity &&
+ (stateRef.current.kind === 'ready' ||
+ stateRef.current.kind === 'none' ||
+ stateRef.current.kind === 'loading')
+ if (!keepVisible) {
+ setState({ kind: 'loading' })
+ }
+ // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
+ // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
+ const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
+ if (
+ !shouldApplyResult(seq, loadSeqRef.current) ||
+ stateIdentityRef.current !== loadIdentity
+ ) {
+ return
+ }
+ stateIdentityRef.current = loadIdentity
+
+ // Keep prior comments/body visible across phase 1 when the same PR is still open.
+ // loadPrSidebarData always returns details:null; without this, soft refresh and
+ // PR-tab refresh blank the comment tree until phase 2 finishes.
+ const priorDetails =
+ next.kind === 'ready' &&
+ stateRef.current.kind === 'ready' &&
+ stateRef.current.data.details != null &&
+ stateRef.current.data.pr.number === next.data.pr.number
+ ? stateRef.current.data.details
+ : null
+
+ if (next.kind === 'ready' && priorDetails != null) {
+ setState({ kind: 'ready', data: { ...next.data, details: priorDetails } })
+ if (!includeDetails) {
+ return
+ }
+ } else {
+ setState(next)
+ if (next.kind !== 'ready' || !includeDetails) {
+ return
+ }
+ }
+
+ // Phase 2: refresh (or first-load) the heavy comments/body payload.
+ const detailsSeq = detailsSeqRef.current + 1
+ detailsSeqRef.current = detailsSeq
+ detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number }
+ const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
+ // Release the claim unless a newer phase-2 superseded it (never clear theirs).
+ if (detailsInFlightRef.current?.seq === detailsSeq) {
+ detailsInFlightRef.current = null
+ }
+ // Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
+ // deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
+ // bumping detailsSeq, and must not discard the in-flight details it preserved
+ // (ensure dedupes against this claim, so nothing would re-fetch them).
+ if (
+ detailsSeq !== detailsSeqRef.current ||
+ stateIdentityRef.current !== loadIdentity ||
+ stateRef.current.kind !== 'ready' ||
+ stateRef.current.data.pr.number !== next.data.pr.number
+ ) {
+ return
+ }
+ // Non-fatal null must not leave details===null (UI treats that as forever-loading).
+ const details = resolvePrSidebarDetailsAfterPhase2({
+ fetched: fetchedDetails,
+ prior: stateRef.current.data.details,
+ pr: stateRef.current.data.pr
+ })
+ setState({ kind: 'ready', data: { ...stateRef.current.data, details } })
+ },
+ [buildDeps, branch, headSha, identity, worktreeId]
+ )
+
+ // 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.
+ const ensurePrSidebarDetails = useCallback(async () => {
+ const current = stateRef.current
+ if (current.kind !== 'ready' || current.data.details != null) {
+ return
+ }
const deps = buildDeps()
const loadIdentity = identity
- if (!deps || !branch || !loadIdentity) {
+ if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) {
return
}
- const seq = loadSeqRef.current + 1
- loadSeqRef.current = seq
- stateIdentityRef.current = loadIdentity
- setState({ kind: 'loading' })
- // Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
- // forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
- const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
- if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) {
+ const prNumber = current.data.pr.number
+ // A live phase-2 fetch for this PR is already in flight (its claim still owns
+ // the latest details seq) — do not start a duplicate.
+ const inFlight = detailsInFlightRef.current
+ if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
return
}
- stateIdentityRef.current = loadIdentity
- setState(next)
- if (next.kind !== 'ready') {
+ const detailsSeq = detailsSeqRef.current + 1
+ detailsSeqRef.current = detailsSeq
+ detailsInFlightRef.current = { seq: detailsSeq, prNumber }
+ const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber)
+ if (detailsInFlightRef.current?.seq === detailsSeq) {
+ detailsInFlightRef.current = null
+ }
+ if (
+ detailsSeq !== detailsSeqRef.current ||
+ stateIdentityRef.current !== loadIdentity ||
+ stateRef.current.kind !== 'ready' ||
+ stateRef.current.data.pr.number !== prNumber
+ ) {
return
}
- // Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never
- // blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered.
- const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
- if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) {
- setState({ kind: 'ready', data: { ...next.data, details } })
+ const details = resolvePrSidebarDetailsAfterPhase2({
+ fetched: fetchedDetails,
+ prior: stateRef.current.data.details,
+ pr: stateRef.current.data.pr
+ })
+ setState({
+ kind: 'ready',
+ data: { ...stateRef.current.data, details }
+ })
+ }, [buildDeps, identity, worktreeId])
+
+ // Soft-refresh checks when HEAD advances on the same branch (post-commit).
+ // Also restarts an in-flight phase-1 load so a mid-flight head advance is not
+ // applied with a stale SHA (headShaRef would otherwise advance with no reload).
+ useEffect(() => {
+ if (headShaRef.current === headSha) {
+ return
}
- }, [buildDeps, branch, headSha, identity, worktreeId])
+ headShaRef.current = headSha
+ // Identity was just wiped (branch/worktree switch): stateRef still holds the
+ // pre-wipe state in this effect flush, which would start a load flavored for
+ // the OLD surface (e.g. heavy details on the Changes tab). Let the owning
+ // surface's hidden-state effects drive the first load for the new identity.
+ if (stateIdentityRef.current === null) {
+ return
+ }
+ const current = stateRef.current
+ if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) {
+ return
+ }
+ void load({
+ includeDetails: current.kind === 'ready' && current.data.details != null
+ })
+ }, [headSha, load])
const openPRSidebar = useCallback(() => {
setShowPRSidebar(true)
@@ -131,18 +298,26 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
stateIdentityRef.current !== identity ||
(state.kind !== 'ready' && state.kind !== 'loading')
) {
- void load()
+ void load({ includeDetails: true })
+ } else if (state.kind === 'ready' && state.data.details == null) {
+ void ensurePrSidebarDetails()
}
- }, [identity, state.kind, load])
+ }, [identity, state, load, ensurePrSidebarDetails])
+
+ const retry = useCallback(() => {
+ void load({ includeDetails: true })
+ }, [load])
return {
prSidebarState: state,
prSidebarIsGithubRepo: isGithubRepo,
+ prSidebarRepoProbeLoaded: repoProbeLoaded,
showPRSidebar,
setShowPRSidebar,
openPRSidebar,
- retryPRSidebar: load,
- refetchPRSidebar: load
+ retryPRSidebar: retry,
+ refetchPRSidebar: load,
+ ensurePrSidebarDetails
}
}
diff --git a/mobile/src/source-control/MobileGitHistoryList.tsx b/mobile/src/source-control/MobileGitHistoryList.tsx
index e4856be152f..db12d3117f1 100644
--- a/mobile/src/source-control/MobileGitHistoryList.tsx
+++ b/mobile/src/source-control/MobileGitHistoryList.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useState } from 'react'
+import { memo, useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -26,7 +26,9 @@ type Props = {
// Headerless commit-history list. Extracted from the /history route so the hub's
// History segment and the standalone route render the same body over one code path.
-export function MobileGitHistoryList({
+// Memoized: it stays mounted (hidden) while the Changes segment is active, and must
+// not re-reconcile its FlatList on every commit-message keystroke re-render.
+export const MobileGitHistoryList = memo(function MobileGitHistoryList({
client,
connState,
worktreeId,
@@ -217,7 +219,7 @@ export function MobileGitHistoryList({
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
/>
)
-}
+})
const styles = StyleSheet.create({
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
diff --git a/mobile/src/source-control/MobileSourceControlBranchCard.tsx b/mobile/src/source-control/MobileSourceControlBranchCard.tsx
index f7135347fd0..04aae01b0bc 100644
--- a/mobile/src/source-control/MobileSourceControlBranchCard.tsx
+++ b/mobile/src/source-control/MobileSourceControlBranchCard.tsx
@@ -4,6 +4,7 @@ import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
import { MobileSourceControlPrChip } from './MobileSourceControlPrChip'
import type { MobilePrChipSummary } from './mobile-pr-chip-summary'
+import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort'
type Props = {
branchLabel: string
@@ -12,7 +13,10 @@ type Props = {
stagedCount: number
branchCount: number
conflictOperation: string | null
+ // True while any serial git IO is in flight — disables Abort so ops don't race.
conflictBusy: boolean
+ // True only while abort-merge / abort-rebase itself is running (label accuracy).
+ conflictAborting: boolean
onAbortConflict: (operation: string) => void
// The PR chip is shown only on repos with a hosted-review remote; null hides it.
prChip: MobilePrChipSummary | null
@@ -30,6 +34,7 @@ export function MobileSourceControlBranchCard({
branchCount,
conflictOperation,
conflictBusy,
+ conflictAborting,
onAbortConflict,
prChip,
onOpenPr
@@ -60,7 +65,7 @@ export function MobileSourceControlBranchCard({
onPress={() => onAbortConflict(conflictOperation)}
>
- {conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`}
+ {mobileConflictAbortLabel(conflictOperation, conflictAborting)}
) : null}
diff --git a/mobile/src/source-control/MobileSourceControlHeader.tsx b/mobile/src/source-control/MobileSourceControlHeader.tsx
index 5d6de2d93be..4485f2ac803 100644
--- a/mobile/src/source-control/MobileSourceControlHeader.tsx
+++ b/mobile/src/source-control/MobileSourceControlHeader.tsx
@@ -1,5 +1,5 @@
import { Pressable, Text, View } from 'react-native'
-import { ChevronLeft, RefreshCw, X } from 'lucide-react-native'
+import { ChevronLeft, ExternalLink, RefreshCw, X } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
@@ -9,6 +9,10 @@ type Props = {
ioBusy: boolean
onBack: () => void
onRefresh: () => void
+ // When set (PR segment ready with a host URL), show open-on-web flush-right of
+ // the title so the control stays visible while the PR body scrolls.
+ onOpenPrWeb?: () => void
+ prNumber?: number | null
}
export function MobileSourceControlHeader({
@@ -16,7 +20,9 @@ export function MobileSourceControlHeader({
worktreeLabel,
ioBusy,
onBack,
- onRefresh
+ onRefresh,
+ onOpenPrWeb,
+ prNumber = null
}: Props) {
return (
@@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
{worktreeLabel}
+ {onOpenPrWeb ? (
+ [styles.refreshButton, pressed && styles.refreshButtonPressed]}
+ onPress={onOpenPrWeb}
+ hitSlop={8}
+ accessibilityRole="link"
+ accessibilityLabel={
+ prNumber != null
+ ? `Open pull request #${prNumber} on the web`
+ : 'Open pull request on the web'
+ }
+ >
+
+
+ ) : null}
[
styles.refreshButton,
diff --git a/mobile/src/source-control/MobileSourceControlPanel.tsx b/mobile/src/source-control/MobileSourceControlPanel.tsx
index 3d0973b7f12..e16c2d05388 100644
--- a/mobile/src/source-control/MobileSourceControlPanel.tsx
+++ b/mobile/src/source-control/MobileSourceControlPanel.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { colors } from '../theme/mobile-theme'
@@ -14,8 +14,10 @@ import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
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 { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
+import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
export type MobileSourceControlPanelProps = {
hostId: string
@@ -43,8 +45,9 @@ export function MobileSourceControlPanel({
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState(initialTab)
- // Track first visit so PR/History keep their fetch + scroll state across segment
- // switches without paying the mount cost until the user actually opens them.
+ // Track first visit so Changes/History keep scroll state across segment switches
+ // without paying the mount cost until the user actually opens them. PR unmounts
+ // when inactive (WebViews / comment tree) but its controller state stays for the chip.
const [visitedTabs, setVisitedTabs] = useState>(
() => new Set([initialTab])
)
@@ -104,6 +107,7 @@ export function MobileSourceControlPanel({
openingBranchPath,
loadStatus,
status,
+ branchCompareResult,
branchLabel,
syncLabel,
unstagedCount,
@@ -118,8 +122,25 @@ export function MobileSourceControlPanel({
// segment, so the chip's rollup can never disagree with the checks list it
// links to. Branch + head come from the already-loaded git.status — no second
// status read. The chip loads independently, so it never blocks the file list.
- const prBranch = status?.branch ?? null
- const prHeadSha = status?.head ?? null
+ // Keep last-known identity across a transient status unload (disconnect /
+ // failed refresh) so the controller does not wipe ready → hidden → cold start.
+ // Head SHA matches the review path: status.head ?? branchCompare headOid.
+ const lastPrBranchRef = useRef(null)
+ const lastPrHeadRef = useRef(null)
+ useEffect(() => {
+ lastPrBranchRef.current = null
+ lastPrHeadRef.current = null
+ }, [worktreeId])
+ const statusBranch = status?.branch ?? null
+ const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
+ if (statusBranch) {
+ lastPrBranchRef.current = statusBranch
+ }
+ if (statusHead) {
+ lastPrHeadRef.current = statusHead
+ }
+ const prBranch = statusBranch ?? lastPrBranchRef.current
+ const prHeadSha = statusHead ?? lastPrHeadRef.current
const prController = useMobilePrSidebarController({
client,
connState,
@@ -130,11 +151,49 @@ export function MobileSourceControlPanel({
const isHostedRepo = prController.prSidebarIsGithubRepo
const prSidebarKind = prController.prSidebarState.kind
const refetchPr = prController.refetchPRSidebar
+ const ensurePrDetails = prController.ensurePrSidebarDetails
+ // Refs so tab effects do not re-fire when headSha recreates load() (soft refresh).
+ const refetchPrRef = useRef(refetchPr)
+ refetchPrRef.current = refetchPr
+ const ensurePrDetailsRef = useRef(ensurePrDetails)
+ ensurePrDetailsRef.current = ensurePrDetails
+
+ // Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
+ // the Pull Request segment is open — opening SC to stage must not pull details.
useEffect(() => {
- if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
- refetchPr()
+ if (activeTab === 'pr') {
+ return
}
- }, [prBranch, isHostedRepo, prSidebarKind, refetchPr])
+ if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
+ void refetchPrRef.current({ includeDetails: false })
+ }
+ }, [activeTab, prBranch, isHostedRepo, prSidebarKind])
+
+ // Number of the ready PR whose phase-2 details are still missing (null when
+ // 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).
+ const prDetailsMissingFor =
+ prController.prSidebarState.kind === 'ready' && prController.prSidebarState.data.details == null
+ ? prController.prSidebarState.data.pr.number
+ : null
+
+ // PR segment: full load or phase-2 fill-in. Body only mounts while active.
+ // Guarded (and keyed) on prBranch so a branch that arrives while the segment is
+ // already open — e.g. mounted on a detached HEAD, then checkout — still loads;
+ // kind stays 'hidden' through that transition, so kind alone can't re-fire this.
+ useEffect(() => {
+ if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
+ return
+ }
+ if (prSidebarKind === 'hidden') {
+ void refetchPrRef.current({ includeDetails: true })
+ return
+ }
+ if (prDetailsMissingFor != null) {
+ void ensurePrDetailsRef.current()
+ }
+ }, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
const prChip = useMemo(() => {
if (!isHostedRepo) {
@@ -147,21 +206,39 @@ export function MobileSourceControlPanel({
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prController.prSidebarState])
+ // Design: refresh the active segment's body work, plus git.status for the shared
+ // branch card (counts/sync stay honest even while on History). Preserve ready
+ // status on a failed refresh so PR chip identity is not wiped to hidden.
const onRefresh = useCallback(() => {
- void loadStatus()
- // Chip + PR segment share the controller; always refresh when hosted so the
- // branch card stays in sync even while the Changes segment is active.
- if (isHostedRepo) {
- void refetchPr()
- }
- if (visitedTabs.has('history')) {
+ void loadStatus({ preserveReadyOnFailure: true })
+ if (activeTab === 'history') {
setHistoryRefreshNonce((n) => n + 1)
+ return
}
- }, [isHostedRepo, loadStatus, refetchPr, visitedTabs])
+ if (!isHostedRepo) {
+ return
+ }
+ if (activeTab === 'pr') {
+ void refetchPr({ includeDetails: true })
+ return
+ }
+ // Changes: light chip refresh so the branch card stays current without comments.
+ void refetchPr({ includeDetails: false })
+ }, [activeTab, isHostedRepo, loadStatus, refetchPr])
// Embedded mode docks beside the terminal: close the dock instead of popping
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
+ // Chromeless PR body has no panel header — surface open-on-web on the hub chrome
+ // while the Pull Request segment is active (same affordance as the old /pr route).
+ const prWebUrl =
+ activeTab === 'pr' &&
+ prController.prSidebarState.kind === 'ready' &&
+ prController.prSidebarState.data.pr.url
+ ? prController.prSidebarState.data.pr.url
+ : null
+ const prWebNumber =
+ prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null
const header = (
openMobilePrUrl(prWebUrl) : undefined}
+ prNumber={prWebNumber}
/>
)
@@ -206,8 +285,14 @@ export function MobileSourceControlPanel({
// History only needs the RPC client — do not block it behind git.status.
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
+ const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
const showHistory = activeTab === 'history' || visitedTabs.has('history')
- const showPr = ready && (activeTab === 'pr' || visitedTabs.has('pr'))
+ // Why: only mount the PR body while its segment is active. Keep-mounting retained
+ // Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
+ // Controller + chip state still live for instant re-open without a full cold start.
+ const showPrBody = ready && activeTab === 'pr'
+ const conflictOperation = status?.conflictOperation ?? null
+ const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
return (
@@ -228,24 +313,25 @@ export function MobileSourceControlPanel({
unstagedCount={unstagedCount}
stagedCount={stagedCount}
branchCount={branchEntries.length}
- conflictOperation={status?.conflictOperation ?? null}
+ conflictOperation={conflictOperation}
conflictBusy={busyAction !== null}
+ conflictAborting={conflictAborting}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
prChip={prChip}
onOpenPr={openPrTab}
/>
) : null}
- {activeTab === 'changes' ? (
- ready ? (
+ {showChanges ? (
+
- ) : (
- statusGate
- )
+
+ ) : activeTab === 'changes' ? (
+ statusGate
) : null}
- {showPr ? (
-
+ {showPrBody ? (
+
) : activeTab === 'pr' ? (
diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts
new file mode 100644
index 00000000000..fa9e40a4746
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-conflict-abort.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import {
+ isMobileConflictAborting,
+ mobileConflictAbortLabel
+} from './mobile-source-control-conflict-abort'
+
+describe('isMobileConflictAborting', () => {
+ it('is true only for the matching abort action', () => {
+ expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true)
+ expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true)
+ })
+
+ it('is false for other busy actions (stage/commit must not look like abort)', () => {
+ expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false)
+ expect(isMobileConflictAborting('commit', 'rebase')).toBe(false)
+ expect(isMobileConflictAborting(null, 'merge')).toBe(false)
+ expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false)
+ expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false)
+ })
+})
+
+describe('mobileConflictAbortLabel', () => {
+ it('shows Aborting only while abort is in flight', () => {
+ expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…')
+ expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge')
+ expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase')
+ })
+})
diff --git a/mobile/src/source-control/mobile-source-control-conflict-abort.ts b/mobile/src/source-control/mobile-source-control-conflict-abort.ts
new file mode 100644
index 00000000000..ac71bcbf9ed
--- /dev/null
+++ b/mobile/src/source-control/mobile-source-control-conflict-abort.ts
@@ -0,0 +1,18 @@
+// Pure helpers for the branch-card conflict Abort control. Kept free of React so
+// the busy-label rule (abort-in-flight only) is unit-testable.
+
+/** True while git.abortMerge / git.abortRebase is the active serial action. */
+export function isMobileConflictAborting(
+ busyAction: string | null,
+ conflictOperation: string | null
+): boolean {
+ if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') {
+ return false
+ }
+ return busyAction === `abort-${conflictOperation}`
+}
+
+/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */
+export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string {
+ return aborting ? 'Aborting…' : `Abort ${conflictOperation}`
+}