Consolidate mobile source control into a single tabbed hub (#7923)

* Consolidate mobile source control into a single tabbed hub

Unify the changes list, pull request details, and commit history into
a single multi-segment panel. This improves navigation and state sharing
across different lenses of a worktree's source control.

- Add a segmented control to switch between Changes, PR, and History
- Introduce a persistent branch status card with an integrated PR chip
- Redirect standalone PR and history routes to the new unified hub
- Extract reusable UI and logic for the history list and PR summary

* Keep mobile source control tabs mounted to preserve view state

* Keep PR and History segments mounted (using display: 'none' when hidden) to preserve fetch, scroll, and expand states during tab switches.
* Decouple the History list from blocking on Git status loading.
* Support deep linking directly into the history tab of the main panel instead of using a standalone route.
* Enable retrying failed loads by reviving the transport loop if parked.
* Fix PR chip accessibility label and comment check.

* 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.

* Improve mobile git views and concurrent rendering safety

- Pass the `origin` parameter through history and PR redirect routes.
- Move source control panel ref updates to `useEffect` to prevent side
  effects during concurrent renders.
- Resolve commit file changes to empty if disconnected to avoid a stuck
  loading spinner.
- Standardize PR sidebar header button styling and accessibility labels.

* Resolve PR repo probe without active branch to avoid forever spinner

Previously, checking if a repository is a GitHub remote required an
active branch. In a detached HEAD or mid-rebase state (where the branch
is null), the probe never resolved, leaving the PR panel on a forever
spinner.

Decouple the repository probe from the branch presence so the panel
can correctly display the "Current branch unavailable" state. Also,
hide the PR status chip when no branch is active to avoid a spinner
on the chip.
This commit is contained in:
Jinjing 2026-07-09 19:23:36 -07:00 committed by GitHub
parent e8c84bb704
commit 43f639ddda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 4087 additions and 622 deletions

View File

@ -1,242 +1,28 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { useForceReconnect, useHostClient } from '../../../../src/transport/client-context'
import type { RpcSuccess } from '../../../../src/transport/types'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from '../../../../src/source-control/mobile-git-history'
import { resolveMobileHistoryScreenView } from '../../../../src/source-control/mobile-history-screen-state'
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
import { Redirect, useLocalSearchParams } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
function firstParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
}
export default function HistoryScreen() {
// History is now a segment of the Source Control hub. This route stays as a thin
// redirect so existing deep links (and any cached navigation) land on the hub with
// the History segment selected.
export default function HistoryRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const forceReconnect = useForceReconnect()
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
useEffect(() => {
let active = true
if (!client || connState !== 'connected' || !worktreeId) {
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
setError(null)
setRows(null)
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
if (active) {
setRows(mapMobileCommitRows(result, Date.now()))
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : 'Failed to load history')
}
}
})()
return () => {
active = false
}
}, [client, connState, reloadNonce, worktreeId])
const retry = useCallback(() => {
setError(null)
// Why: retrying the fetch is useless while the transport's reconnect loop
// is parked at its backoff cap — revive the connection instead (mirrors
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && client && !filesById[row.id]) {
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => ({ ...prev, [row.id]: entries }))
})
.catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
}
},
[client, expanded, filesById, worktreeId]
)
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
const files = filesById[item.id]
const isOpen = expanded === item.id
return (
<View style={styles.commit}>
<Pressable
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
onPress={() => toggleCommit(item)}
>
{isOpen ? (
<ChevronDown size={14} color={colors.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
)}
<View style={styles.commitMain}>
<Text style={styles.commitSubject} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.commitMeta} numberOfLines={1}>
{item.shortId} · {item.author} · {item.relativeTime}
</Text>
</View>
</Pressable>
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
files.map((file) => (
<View key={file.path} style={styles.fileRow}>
<Text style={styles.filePath} numberOfLines={1}>
{file.path}
</Text>
<Text style={styles.fileStat}>
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
</Text>
</View>
))
)}
</View>
) : null}
</View>
)
},
[expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} accessibilityLabel="Back">
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<Text style={styles.title}>Commit History</Text>
</View>
{view.kind === 'error' || view.kind === 'waiting' ? (
<View style={styles.state}>
<Text style={styles.stateText}>
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
</Text>
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
) : view.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : view.kind === 'empty' ? (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
) : (
<FlatList
data={view.rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
/>
)}
</SafeAreaView>
<Redirect
href={{
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId: firstParam(params.hostId),
worktreeId: firstParam(params.worktreeId),
name: firstParam(params.name),
origin: firstParam(params.origin),
tab: 'history'
}
}}
/>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm
},
back: { padding: spacing.xs },
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
commitHeaderPressed: { backgroundColor: colors.bgRaised },
commitMain: { flex: 1, minWidth: 0 },
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
commitMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
marginTop: 2
},
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
filePath: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
add: { color: colors.gitDecorationAdded },
del: { color: colors.gitDecorationDeleted },
empty: { color: colors.textMuted, fontSize: typography.metaSize }
})

View File

@ -1,30 +1,29 @@
import { useLocalSearchParams } from 'expo-router'
import { useHostClient } from '../../../../src/transport/client-context'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel'
// Narrow-layout full-screen PR route. The standalone panel can't ride on the review
// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare.
export default function MobilePrViewScreen() {
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
const { client, state: connState } = useHostClient(hostId)
const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
client,
connState,
worktreeId
})
import { Redirect, useLocalSearchParams } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
// 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 opens the same hub with
// initialTab="pr" (SessionDockColumn), not this route.
export default function PrRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
}>()
return (
<MobilePrViewPanel
client={client}
connState={connState}
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={status}
isGithubRepo={isGithubRepo}
branchContextLoaded={loaded && repoLoaded}
embedded={false}
<Redirect
href={{
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId: firstParam(params.hostId),
worktreeId: firstParam(params.worktreeId),
name: firstParam(params.name),
origin: firstParam(params.origin),
tab: 'pr'
}
}}
/>
)
}

View File

@ -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)
@ -4431,14 +4427,18 @@ export default function SessionScreen() {
setActivePanel(action.next)
return
}
const descriptor = panelRouteDescriptor(action.panel)
router.push({
pathname: panelRouteDescriptor(action.panel).pathname,
pathname: descriptor.pathname,
params: {
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
}
})
}
@ -5124,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}

View File

@ -1,6 +1,7 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
export default function MobileSourceControlScreen() {
const params = useLocalSearchParams<{
@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() {
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
tab?: string | string[]
}>()
return (
<MobileSourceControlPanel
@ -15,6 +17,7 @@ export default function MobileSourceControlScreen() {
worktreeId={firstParam(params.worktreeId)}
name={firstParam(params.name)}
origin={firstParam(params.origin)}
initialTab={parseSourceControlHubTab(params.tab)}
embedded={false}
/>
)

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,11 @@
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 } 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 Props = {
client: RpcClient | null
@ -20,13 +16,13 @@ type Props = {
gitStatus: MobileGitStatusResult | null
isGithubRepo?: boolean
branchContextLoaded?: boolean
// Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close
// affordance; the dock column owns the safe-area insets. Full-screen otherwise.
embedded?: boolean
onRequestClose?: () => void
controller: MobilePrSidebarController
}
export function MobilePrViewPanel({
// 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,
worktreeId,
@ -35,32 +31,10 @@ export function MobilePrViewPanel({
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
embedded = false,
onRequestClose
controller
}: Props) {
const router = useRouter()
const insets = useSafeAreaInsets()
const controller = useMobilePrSidebarController({
client,
connState,
worktreeId,
branch,
headSha
})
// 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).
const prSidebarKind = controller.prSidebarState.kind
const refetch = controller.refetchPRSidebar
useEffect(() => {
if (branch && isGithubRepo && prSidebarKind === 'hidden') {
refetch()
}
}, [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
@ -74,86 +48,22 @@ export function MobilePrViewPanel({
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 ? (
<Pressable
style={({ pressed }) => [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'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null
const sidebar = (
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
)
if (embedded) {
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onRequestClose}
hitSlop={8}
accessibilityLabel="Close pull request panel"
>
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Back to session"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
{openPrControl}
</View>
</View>
{sidebar}
</SafeAreaView>
<View style={styles.container}>
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
</View>
)
}
@ -161,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'
}
})

View File

@ -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 (
<View style={styles.section}>
<View style={styles.sectionBody}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
<View style={styles.badgeRow}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<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}
</View>
<PRTitle
title={title}
number={pr.number}

View File

@ -56,6 +56,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
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: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
badge: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,

View File

@ -1,27 +1,16 @@
import { memo } from 'react'
import { View, StyleSheet } from 'react-native'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel'
import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel'
import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
import { useMobileDockResize } from './use-mobile-dock-resize'
import type { ActivePanel } from './session-panel-host'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
type Props = {
activePanel: Exclude<ActivePanel, 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 (
<MobileSourceControlPanel
hostId={hostId}
@ -104,33 +75,18 @@ const DockPanelContent = memo(function DockPanelContent({
name={name}
origin="session"
embedded
initialTab={activePanel === 'pr' ? 'pr' : 'changes'}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
/>
)
}
if (activePanel === 'files') {
return (
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
name={name}
embedded
onRequestClose={onRequestClose}
/>
)
}
return (
<MobilePrViewPanel
client={client}
connState={connState}
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
name={name}
embedded
onRequestClose={onRequestClose}
/>

View File

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

View File

@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => {
pathname: '/h/[hostId]/files/[worktreeId]'
})
expect(panelRouteDescriptor('pr')).toEqual({
pathname: '/h/[hostId]/pr/[worktreeId]'
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: { tab: 'pr' }
})
})
})

View File

@ -47,13 +47,18 @@ export function resolvePanelAction(args: {
// Single source of truth for each panel's expo-router pathname pattern so narrow-push
// and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params.
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): { pathname: string } {
// The Pull Request panel is a segment of the source-control hub, so its narrow-push
// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route.
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): {
pathname: string
params?: Record<string, string>
} {
switch (panel) {
case 'sourceControl':
return { pathname: '/h/[hostId]/source-control/[worktreeId]' }
case 'files':
return { pathname: '/h/[hostId]/files/[worktreeId]' }
case 'pr':
return { pathname: '/h/[hostId]/pr/[worktreeId]' }
return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } }
}
}

View File

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

View File

@ -0,0 +1,100 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
const fetchGithubRepoSlugMock = vi.fn()
// Only the repo probe matters here; the load path never runs without a branch.
vi.mock('./github-pr-rpc', () => ({
fetchGithubRepoSlug: (...args: unknown[]) => fetchGithubRepoSlugMock(...args),
fetchHostedReviewForBranch: vi.fn(),
fetchPRChecks: vi.fn(),
fetchPRForBranch: vi.fn(),
fetchWorkItemDetails: vi.fn()
}))
vi.mock('../source-control/mobile-pr-link', () => ({
fetchWorktreeLinkedPR: vi.fn(async () => null)
}))
import {
useMobilePrSidebarController,
type MobilePrSidebarController
} from './use-mobile-pr-sidebar-controller'
let captured: MobilePrSidebarController | null = null
function Harness(props: Parameters<typeof useMobilePrSidebarController>[0]) {
captured = useMobilePrSidebarController(props)
return null
}
async function flush(): Promise<void> {
// Two ticks: the probe's resolved promise (.then) plus its setState commit.
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('useMobilePrSidebarController repo probe', () => {
const client = { sendRequest: vi.fn() } as unknown as RpcClient
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
captured = null
fetchGithubRepoSlugMock.mockReset()
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
})
// Regression: repo eligibility must not be gated on a branch. A detached HEAD /
// mid-rebase worktree (branch === null) still has to resolve the probe, or the
// Pull Request segment gets stranded on a forever spinner instead of the
// "Current branch unavailable" state.
it('resolves the probe when connected without a branch (detached HEAD)', async () => {
fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } })
await act(async () => {
renderer = create(
createElement(Harness, {
client,
connState: 'connected',
worktreeId: 'w',
branch: null,
headSha: 'sha'
})
)
})
await flush()
expect(fetchGithubRepoSlugMock).toHaveBeenCalledWith(client, 'w')
expect(captured?.prSidebarRepoProbeLoaded).toBe(true)
expect(captured?.prSidebarIsGithubRepo).toBe(true)
// No branch means no PR load ran — state stays hidden, never a spinner.
expect(captured?.prSidebarState.kind).toBe('hidden')
})
it('does not probe until the client is connected', async () => {
fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } })
await act(async () => {
renderer = create(
createElement(Harness, {
client,
connState: 'connecting',
worktreeId: 'w',
branch: null,
headSha: 'sha'
})
)
})
await flush()
expect(fetchGithubRepoSlugMock).not.toHaveBeenCalled()
expect(captured?.prSidebarRepoProbeLoaded).toBe(false)
})
})

View File

@ -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<T>(result: T): GitHubPrReadOutcome<T> {
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' })
)
})
})

View File

@ -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,31 @@ 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<PrSidebarState>({ 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<string | null>(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 })
// Repo eligibility (a GitHub remote) is independent of the branch, so the probe
// must not require one: a detached HEAD / mid-rebase worktree (branch === null)
// would otherwise never set repoProbeLoaded, stranding the PR segment on a
// forever spinner instead of the "Current branch unavailable" state.
const probeReady = client !== null && connState === 'connected'
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
const buildDeps = useCallback((): PrSidebarLoadDeps | null => {
if (!client) {
@ -63,66 +91,209 @@ 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)
if (!probeReady || !client) {
return
}
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
setRepoProbeLoaded(true)
}
})
return () => {
cancelled = true
}
}, [ready, client, worktreeId])
}, [probeReady, client, worktreeId])
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,17 +302,27 @@ 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
}
}
export type MobilePrSidebarController = ReturnType<typeof useMobilePrSidebarController>

View File

@ -0,0 +1,271 @@
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'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useForceReconnect } from '../transport/client-context'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from './mobile-git-history'
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
import type { GitBranchChangeEntry } from '../../../src/shared/types'
type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
// Needed so Retry can revive a parked reconnect loop (STA-1511 / #5049).
hostId: string
bottomInset: number
// Bumped by the hub header refresh so History reloads without remounting.
refreshNonce?: number
}
// 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.
// 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,
hostId,
bottomInset,
refreshNonce = 0
}: Props) {
const forceReconnect = useForceReconnect()
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
// Worktree identity change must wipe history immediately — even while
// disconnected — so a kept-mounted hub segment never shows another tree's commits.
useEffect(() => {
setRows(null)
setError(null)
setExpanded(null)
setFilesById({})
}, [worktreeId])
useEffect(() => {
let active = true
if (!client || connState !== 'connected' || !worktreeId) {
// Why: leave already-loaded rows (and expand state) alone across a drop —
// resolveMobileHistoryScreenView keeps them visible (STA-1511).
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
setError(null)
setRows(null)
setExpanded(null)
setFilesById({})
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
if (active) {
setRows(mapMobileCommitRows(result, Date.now()))
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : 'Failed to load history')
}
}
})()
return () => {
active = false
}
}, [client, connState, reloadNonce, refreshNonce, worktreeId])
const retry = useCallback(() => {
setError(null)
// Why: retrying the fetch is useless while the transport's reconnect loop
// is parked at its backoff cap — revive the connection instead (mirrors
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && !filesById[row.id]) {
// No client (disconnected while cached rows stay visible): resolve to an
// empty file list so the row shows "No file changes" instead of a spinner
// that never completes — no request can be made.
if (!client) {
setFilesById((prev) => ({ ...prev, [row.id]: [] }))
return
}
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => {
// Drop stale responses if the row is no longer loading (collapsed + re-opened).
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: entries }
})
})
.catch(() =>
setFilesById((prev) => {
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: [] }
})
)
}
},
[client, expanded, filesById, worktreeId]
)
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
const files = filesById[item.id]
const isOpen = expanded === item.id
return (
<View style={styles.commit}>
<Pressable
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
onPress={() => toggleCommit(item)}
>
{isOpen ? (
<ChevronDown size={14} color={colors.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
)}
<View style={styles.commitMain}>
<Text style={styles.commitSubject} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.commitMeta} numberOfLines={1}>
{item.shortId} · {item.author} · {item.relativeTime}
</Text>
</View>
</Pressable>
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
files.map((file) => (
<View key={file.path} style={styles.fileRow}>
<Text style={styles.filePath} numberOfLines={1}>
{file.path}
</Text>
<Text style={styles.fileStat}>
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
</Text>
</View>
))
)}
</View>
) : null}
</View>
)
},
[expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
if (view.kind === 'error' || view.kind === 'waiting') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
</Text>
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)
}
if (view.kind === 'loading') {
return (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
}
if (view.kind === 'empty') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
)
}
return (
<FlatList
data={view.rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
/>
)
})
const styles = StyleSheet.create({
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
commitHeaderPressed: { backgroundColor: colors.bgRaised },
commitMain: { flex: 1, minWidth: 0 },
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
commitMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
marginTop: 2
},
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
filePath: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
add: { color: colors.gitDecorationAdded },
del: { color: colors.gitDecorationDeleted },
empty: { color: colors.textMuted, fontSize: typography.metaSize }
})

View File

@ -0,0 +1,78 @@
import { Pressable, Text, View } from 'react-native'
import { GitBranch } from 'lucide-react-native'
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
syncLabel: string | null
unstagedCount: number
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
onOpenPr: () => void
}
// Persistent card at the top of every hub segment: branch identity, sync/counts,
// conflict state, and the PR chip. Shared so PR/History see the same status the
// Changes lens does without re-deriving it.
export function MobileSourceControlBranchCard({
branchLabel,
syncLabel,
unstagedCount,
stagedCount,
branchCount,
conflictOperation,
conflictBusy,
conflictAborting,
onAbortConflict,
prChip,
onOpenPr
}: Props) {
const showConflict = conflictOperation !== null && conflictOperation !== 'unknown'
return (
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.branchText} numberOfLines={1}>
{branchLabel}
</Text>
</View>
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
</View>
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
{showConflict ? (
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{conflictOperation}</Text>
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
<Pressable
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
disabled={conflictBusy}
onPress={() => onAbortConflict(conflictOperation)}
>
<Text style={styles.abortText}>
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
</View>
)
}

View File

@ -1,5 +1,5 @@
import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native'
import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
@ -7,12 +7,13 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st
import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
type Props = {
state: MobileSourceControlState
}
// The ready-state body: summary card, changed-files list, and commit bar.
// The ready-state Changes segment body: quick actions, changed-files list, and commit bar.
export function MobileSourceControlContent({ state }: Props) {
const {
insets,
@ -29,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) {
keyboardLift,
openingPath,
openingBranchPath,
status,
sections,
branchEntries,
hasVisibleChanges,
stageablePaths,
unstageablePaths,
stagedCount,
unstagedCount,
branchLabel,
syncLabel,
primaryAction,
createPrAction,
stageAll,
unstageAll,
generateCommitMessage,
cancelGenerateCommitMessage,
abortConflictOperation,
openFile,
openBranchDiff,
runGitAction
@ -66,41 +61,7 @@ export function MobileSourceControlContent({ state }: Props) {
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
</View>
) : null}
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.branchText} numberOfLines={1}>
{branchLabel}
</Text>
</View>
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
</View>
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchEntries.length > 0 ? (
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
) : null}
{status && status.conflictOperation !== 'unknown' ? (
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
{(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && (
<Pressable
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
disabled={busyAction !== null}
onPress={() => void abortConflictOperation(status.conflictOperation)}
>
<Text style={styles.abortText}>
{busyAction === `abort-${status.conflictOperation}`
? 'Aborting…'
: `Abort ${status.conflictOperation}`}
</Text>
</Pressable>
)}
</View>
) : null}
</View>
<View style={hubStyles.changesControls}>
{commitFailureRecovery ? (
<MobileCommitFailurePanel
failure={commitFailureRecovery}
@ -170,6 +131,7 @@ export function MobileSourceControlContent({ state }: Props) {
</View>
) : (
<SectionList
style={hubStyles.tabBody}
sections={sections}
renderItem={makeRenderFileRow({
busyAction,

View File

@ -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 (
<View style={styles.topBar}>
@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
{worktreeLabel}
</Text>
</View>
{onOpenPrWeb ? (
<Pressable
style={({ pressed }) => [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'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.refreshButton,

View File

@ -1,3 +1,4 @@
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'
@ -6,7 +7,17 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a
import { MobileSourceControlHeader } from './MobileSourceControlHeader'
import { MobileSourceControlContent } from './MobileSourceControlContent'
import { MobileSourceControlModals } from './MobileSourceControlModals'
import { MobileSourceControlSegments } from './MobileSourceControlSegments'
import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard'
import { MobileGitHistoryList } from './MobileGitHistoryList'
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
@ -15,6 +26,8 @@ export type MobileSourceControlPanelProps = {
/** Where the panel was launched from; drives the file-open dismissal path. */
origin?: string
embedded?: boolean
/** Initial hub segment (from the route's `tab` deep-link param). */
initialTab?: SourceControlHubTab
onRequestClose?: () => void
onFileOpenStart?: () => void
onOpenedFileDiff?: (relativePath: string) => void
@ -26,10 +39,48 @@ export function MobileSourceControlPanel({
name = '',
origin = '',
embedded = false,
initialTab = 'changes',
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
// 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<ReadonlySet<SourceControlHubTab>>(
() => new Set<SourceControlHubTab>([initialTab])
)
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
// Deep-link / push with a different `tab` param should adopt the new segment
// (expo-router can reuse the screen instance when only query params change).
useEffect(() => {
setActiveTab(initialTab)
setVisitedTabs((prev) => {
if (prev.has(initialTab)) {
return prev
}
const next = new Set(prev)
next.add(initialTab)
return next
})
}, [initialTab])
const selectTab = useCallback((tab: SourceControlHubTab) => {
setActiveTab(tab)
setVisitedTabs((prev) => {
if (prev.has(tab)) {
return prev
}
const next = new Set(prev)
next.add(tab)
return next
})
}, [])
const openHistoryTab = useCallback(() => selectTab('history'), [selectTab])
const openPrTab = useCallback(() => selectTab('pr'), [selectTab])
const state = useMobileSourceControlState({
hostId,
worktreeId,
@ -38,12 +89,15 @@ export function MobileSourceControlPanel({
embedded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
onOpenedFileDiff,
onOpenHistory: openHistoryTab
})
const actionSheetActions = useMobileSourceControlActionSheet(state)
const {
client,
connState,
forceReconnect,
insets,
router,
setRootRef,
worktreeLabel,
@ -51,25 +105,202 @@ export function MobileSourceControlPanel({
busyAction,
openingPath,
openingBranchPath,
loadStatus
loadStatus,
status,
branchCompareResult,
branchLabel,
syncLabel,
unstagedCount,
stagedCount,
branchEntries,
abortConflictOperation
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const ready = screenState.kind === 'ready'
// One PR controller feeds both the branch-card chip and the Pull Request
// 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.
// 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<string | null>(null)
const lastPrHeadRef = useRef<string | null>(null)
useEffect(() => {
lastPrBranchRef.current = null
lastPrHeadRef.current = null
}, [worktreeId])
const statusBranch = status?.branch ?? null
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
// Write last-known identity in an effect, not the render body: a discarded
// concurrent render must not leave the fallback holding a never-committed value.
useEffect(() => {
if (statusBranch) {
lastPrBranchRef.current = statusBranch
}
if (statusHead) {
lastPrHeadRef.current = statusHead
}
}, [statusBranch, statusHead])
const prBranch = statusBranch ?? lastPrBranchRef.current
const prHeadSha = statusHead ?? lastPrHeadRef.current
const prController = useMobilePrSidebarController({
client,
connState,
worktreeId,
branch: prBranch,
headSha: prHeadSha
})
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 (activeTab === 'pr') {
return
}
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(() => {
// No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared
// state stays 'hidden' — which the chip would render as a forever spinner.
// Hide the chip instead; the Pull Request segment shows "branch unavailable".
if (!isHostedRepo || !prBranch) {
return null
}
const commentCount =
prController.prSidebarState.kind === 'ready'
? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments)
: null
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prBranch, 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({ preserveReadyOnFailure: true })
if (activeTab === 'history') {
setHistoryRefreshNonce((n) => n + 1)
return
}
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).
// Fall back to router.back() when embedded without a close handler so the button
// never silently no-ops.
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 = (
<MobileSourceControlHeader
embedded={embedded}
worktreeLabel={worktreeLabel}
ioBusy={ioBusy}
onBack={onBack}
onRefresh={() => void loadStatus()}
onRefresh={onRefresh}
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
prNumber={prWebNumber}
/>
)
const statusGate =
screenState.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
</View>
) : null
// 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')
// 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 (
<View ref={setRootRef} style={styles.container}>
{embedded ? (
@ -80,38 +311,64 @@ export function MobileSourceControlPanel({
</SafeAreaView>
)}
{screenState.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{ready ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
syncLabel={syncLabel}
unstagedCount={unstagedCount}
stagedCount={stagedCount}
branchCount={branchEntries.length}
conflictOperation={conflictOperation}
conflictBusy={busyAction !== null}
conflictAborting={conflictAborting}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
prChip={prChip}
onOpenPr={openPrTab}
/>
) : null}
{showChanges ? (
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
<MobileSourceControlContent state={state} />
</View>
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
) : activeTab === 'changes' ? (
statusGate
) : null}
{showPrBody ? (
<View style={hubStyles.tabBody}>
<MobilePrViewPanelBody
client={client}
connState={connState}
worktreeId={worktreeId}
branch={prBranch}
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
// Gate on the probe too: isGithubRepo=false mid-probe must render as
// loading, not flash "unavailable for this provider" (old /pr parity).
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
controller={prController}
/>
</View>
) : (
<MobileSourceControlContent state={state} />
)}
) : activeTab === 'pr' ? (
statusGate
) : null}
{showHistory ? (
<View style={activeTab === 'history' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
<MobileGitHistoryList
client={client}
connState={connState}
worktreeId={worktreeId}
hostId={hostId}
bottomInset={insets.bottom}
refreshNonce={historyRefreshNonce}
/>
</View>
) : null}
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
</View>

View File

@ -0,0 +1,121 @@
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import {
AlertTriangle,
Check,
ChevronRight,
CircleDot,
GitPullRequest,
MessageSquare,
X
} from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color'
import { hubStyles } from './mobile-source-control-hub-styles'
import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary'
type Props = {
summary: MobilePrChipSummary
onPress: () => void
}
// The glanceable PR status line on the branch card. Tapping it switches to the
// Pull Request segment. Rendered only when the repo supports hosted review — the
// parent gates on that, so this component always has something meaningful to show.
export function MobileSourceControlPrChip({ summary, onPress }: Props) {
return (
<Pressable
style={({ pressed }) => [hubStyles.chip, pressed && hubStyles.chipPressed]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={chipAccessibilityLabel(summary)}
>
<View style={hubStyles.chipIcon}>
<GitPullRequest size={15} color={colors.textSecondary} strokeWidth={2.1} />
</View>
{summary.kind === 'loading' ? (
<>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
Loading pull request
</Text>
</>
) : summary.kind === 'none' ? (
<>
<Text style={hubStyles.chipCreateText}>Create pull request</Text>
<View style={hubStyles.chipSpacer} />
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
) : summary.kind === 'unavailable' ? (
<>
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
{summary.message}
</Text>
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
) : (
<>
<Text style={hubStyles.chipNumber}>#{summary.number}</Text>
<View style={[hubStyles.statePill, { borderColor: statusColor(summary.stateToken) }]}>
<Text style={[hubStyles.statePillText, { color: statusColor(summary.stateToken) }]}>
{summary.stateLabel}
</Text>
</View>
<ChipRollup rollup={summary.rollup} />
{summary.commentCount != null && summary.commentCount > 0 ? (
<View style={hubStyles.comment}>
<MessageSquare size={13} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={hubStyles.commentText}>{summary.commentCount}</Text>
</View>
) : null}
<View style={hubStyles.chipSpacer} />
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
)}
</Pressable>
)
}
function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) {
const color = statusColor(rollup.token)
return (
<View style={hubStyles.rollup}>
<RollupIcon kind={rollup.kind} color={color} />
<Text style={[hubStyles.rollupText, { color }]}>{rollup.text}</Text>
</View>
)
}
function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) {
const size = 13
const strokeWidth = 2.3
switch (kind) {
case 'conflict':
return <AlertTriangle size={size} color={color} strokeWidth={strokeWidth} />
case 'failing':
return <X size={size} color={color} strokeWidth={strokeWidth} />
case 'running':
return <CircleDot size={size} color={color} strokeWidth={strokeWidth} />
case 'passed':
return <Check size={size} color={color} strokeWidth={strokeWidth} />
case 'none':
return null
}
}
function chipAccessibilityLabel(summary: MobilePrChipSummary): string {
switch (summary.kind) {
case 'loading':
return 'Loading pull request'
case 'none':
return 'Create pull request'
case 'unavailable':
return `Pull request unavailable: ${summary.message}`
case 'ready': {
const comments =
summary.commentCount != null && summary.commentCount > 0
? `, ${summary.commentCount} unresolved comments`
: ''
return `Pull request #${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.`
}
}
}

View File

@ -0,0 +1,45 @@
import { Pressable, Text, View } from 'react-native'
import {
SOURCE_CONTROL_HUB_TABS,
SOURCE_CONTROL_HUB_TAB_LABELS,
type SourceControlHubTab
} from './mobile-source-control-hub-tab'
import { hubStyles } from './mobile-source-control-hub-styles'
type Props = {
active: SourceControlHubTab
onSelect: (tab: SourceControlHubTab) => void
}
// The hub's top-level lens switcher. Switching is local state (no route push) so
// scroll position and the shared branch card persist across Changes/PR/History.
export function MobileSourceControlSegments({ active, onSelect }: Props) {
return (
<View style={hubStyles.segments} accessibilityRole="tablist">
{SOURCE_CONTROL_HUB_TABS.map((tab) => {
const isActive = tab === active
return (
<Pressable
key={tab}
style={({ pressed }) => [
hubStyles.segment,
isActive && hubStyles.segmentActive,
pressed && !isActive && hubStyles.segmentPressed
]}
onPress={() => onSelect(tab)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
>
<Text
style={[hubStyles.segmentText, isActive && hubStyles.segmentTextActive]}
numberOfLines={1}
>
{SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
</Text>
</Pressable>
)
})}
</View>
)
}

View File

@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest'
import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types'
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
function pr(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 7701,
title: 'Make center split more visible',
state: 'open',
url: 'https://example.test/pr/7701',
checksStatus: 'success',
updatedAt: '2026-07-07T00:00:00Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function check(
conclusion: PRCheckDetail['conclusion'],
status: PRCheckDetail['status'] = 'completed'
): PRCheckDetail {
return { name: `check-${conclusion}-${status}`, status, conclusion, url: null }
}
function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState {
return { kind: 'ready', data: { pr: prInfo, checks, details: null } }
}
describe('buildMobilePrChipSummary', () => {
it('maps non-ready states', () => {
expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' })
expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' })
expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' })
expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({
kind: 'unavailable',
message: 'net'
})
expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({
kind: 'unavailable',
message: 'auth'
})
})
it('surfaces the PR number and state badge', () => {
const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')]))
expect(summary.kind).toBe('ready')
if (summary.kind !== 'ready') {
return
}
expect(summary.number).toBe(7701)
expect(summary.stateLabel).toBe('Draft')
})
it('rolls up passed checks as passed/total', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('success'), check('skipped')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' })
})
it('prefers failing over running and passing', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' })
})
it('shows running when nothing has failed yet', () => {
const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')]))
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' })
})
it('lets a merge conflict win over green checks', () => {
const summary = buildMobilePrChipSummary(
ready(pr({ mergeable: 'CONFLICTING' }), [check('success')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup.kind).toBe('conflict')
})
it('reports no checks when the list is empty', () => {
const summary = buildMobilePrChipSummary(ready(pr(), []))
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' })
})
it('passes through the unresolved comment count', () => {
const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.commentCount).toBe(3)
})
})
describe('countUnresolvedReviewThreads', () => {
function comment(overrides: Partial<PRComment>): PRComment {
return {
id: 1,
author: 'octocat',
authorAvatarUrl: '',
body: 'x',
createdAt: '2026-07-07T00:00:00Z',
url: '',
...overrides
}
}
it('returns null when details have not loaded', () => {
expect(countUnresolvedReviewThreads(null)).toBeNull()
expect(countUnresolvedReviewThreads(undefined)).toBeNull()
})
it('counts each unresolved thread once and ignores resolved threads', () => {
const comments = [
comment({ id: 1, threadId: 't1', isResolved: false }),
comment({ id: 2, threadId: 't1', isResolved: false }),
comment({ id: 3, threadId: 't2', isResolved: true }),
comment({ id: 4, threadId: 't3' }),
comment({ id: 5 })
]
expect(countUnresolvedReviewThreads(comments)).toBe(2)
})
})

View File

@ -0,0 +1,104 @@
import type { PRComment } from '../../../src/shared/types'
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
import {
prStateBadge,
summarizePRChecks,
type MobileStatusToken
} from '../components/pr-sidebar/pr-checks-presentation'
// Pure derivation of the branch-card PR chip from the shared PR sidebar state.
// No React/native imports so the rollup precedence is unit-testable (KTD5). The
// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's
// check rollup can never disagree with the checks list it links to.
// The single check-rollup token the chip shows. Exactly one wins, by precedence:
// merge conflict > failing > running > passed > no checks. Worst-actionable-first
// so a red/amber signal is never hidden behind a green count.
export type MobilePrChipRollup =
| { kind: 'conflict'; text: string; token: MobileStatusToken }
| { kind: 'failing'; text: string; token: MobileStatusToken }
| { kind: 'running'; text: string; token: MobileStatusToken }
| { kind: 'passed'; text: string; token: MobileStatusToken }
| { kind: 'none'; text: string; token: MobileStatusToken }
export type MobilePrChipSummary =
| { kind: 'loading' }
// GitHub repo, but this branch has no open/linked PR — the chip becomes a
// "create pull request" affordance that routes to the PR segment's composer.
| { kind: 'none' }
// A permanent (auth/permission) or transient failure loading PR data. The chip
// degrades to a muted, tappable line rather than vanishing.
| { kind: 'unavailable'; message: string }
| {
kind: 'ready'
number: number
stateLabel: string
stateToken: MobileStatusToken
rollup: MobilePrChipRollup
// Unresolved review-comment count, when the deferred details payload has
// loaded; null while it is still loading or unavailable.
commentCount: number | null
}
// Unresolved review threads (the "💬 n" count). Counts each inline thread once by
// threadId when it is not resolved; top-level conversation comments (no threadId)
// are not review threads and don't count. Returns null when details haven't loaded.
export function countUnresolvedReviewThreads(
comments: PRComment[] | null | undefined
): number | null {
if (!comments) {
return null
}
const unresolved = new Set<string>()
for (const comment of comments) {
if (comment.threadId && comment.isResolved !== true) {
unresolved.add(comment.threadId)
}
}
return unresolved.size
}
export function buildMobilePrChipSummary(
state: PrSidebarState,
commentCount: number | null = null
): MobilePrChipSummary {
switch (state.kind) {
case 'hidden':
case 'loading':
return { kind: 'loading' }
case 'none':
return { kind: 'none' }
case 'error':
case 'blocked':
return { kind: 'unavailable', message: state.message }
case 'ready': {
const badge = prStateBadge(state.data.pr.state)
return {
kind: 'ready',
number: state.data.pr.number,
stateLabel: badge.label,
stateToken: badge.token,
rollup: buildChipRollup(state),
commentCount
}
}
}
}
function buildChipRollup(state: Extract<PrSidebarState, { kind: 'ready' }>): MobilePrChipRollup {
// Conflicts win: the checks may be green, but the PR still can't merge.
if (state.data.pr.mergeable === 'CONFLICTING') {
return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' }
}
const checks = summarizePRChecks(state.data.checks)
if (checks.failed > 0) {
return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' }
}
if (checks.pending > 0) {
return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' }
}
if (checks.passed > 0) {
return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' }
}
return { kind: 'none', text: 'No checks', token: 'textSecondary' }
}

View File

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

View File

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

View File

@ -0,0 +1,126 @@
import { StyleSheet } from 'react-native'
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({
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
},
segment: {
flex: 1,
minHeight: 34,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xs
},
segmentActive: {
backgroundColor: colors.bgPanel,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
segmentPressed: {
opacity: 0.7
},
segmentText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
segmentTextActive: {
color: colors.textPrimary
},
// The PR chip sits below the count row inside the branch card, separated by a
// hairline so it reads as a distinct, tappable status line.
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
chipPressed: {
opacity: 0.7
},
chipIcon: {
width: 18,
alignItems: 'center'
},
chipNumber: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
statePill: {
paddingHorizontal: spacing.sm,
paddingVertical: 1,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth
},
statePillText: {
fontSize: typography.metaSize,
fontWeight: '700'
},
rollup: {
flexDirection: 'row',
alignItems: 'center',
gap: 4
},
rollupText: {
fontSize: typography.metaSize,
fontWeight: '600'
},
comment: {
flexDirection: 'row',
alignItems: 'center',
gap: 4
},
commentText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
// Pushes the chevron to the trailing edge without a fixed-width spacer.
chipSpacer: {
flex: 1,
minWidth: spacing.sm
},
chipCreateText: {
color: colors.accentBlue,
fontSize: typography.bodySize,
fontWeight: '600'
},
chipMutedText: {
flex: 1,
color: colors.textMuted,
fontSize: typography.metaSize
},
// Wraps the Changes-only controls (commit-failure/error notice, create-PR entry,
// bulk Stage/Unstage row) that used to live inside the summary card, now that the
// card is shared across segments and holds only branch status.
changesControls: {
paddingHorizontal: spacing.lg,
marginTop: spacing.xs
},
// Fills the remaining space below the header/segments/card so each segment's
// scroll view (SectionList / PR sidebar / history list) expands and scrolls.
tabBody: {
flex: 1
},
// Keep a previously-visited segment mounted (scroll + fetch state) without
// participating in layout while another segment is active.
tabBodyHidden: {
display: 'none'
}
})

View File

@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab'
describe('parseSourceControlHubTab', () => {
it('accepts each known tab', () => {
for (const tab of SOURCE_CONTROL_HUB_TABS) {
expect(parseSourceControlHubTab(tab)).toBe(tab)
}
})
it('reads the first element of an array param', () => {
expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr')
})
it('falls back to changes for unknown, empty, or missing values', () => {
expect(parseSourceControlHubTab('nope')).toBe('changes')
expect(parseSourceControlHubTab('')).toBe('changes')
expect(parseSourceControlHubTab(undefined)).toBe('changes')
expect(parseSourceControlHubTab(null)).toBe('changes')
expect(parseSourceControlHubTab([])).toBe('changes')
})
})

View File

@ -0,0 +1,29 @@
// The three lenses of the mobile Source Control hub. Kept as a pure module (no
// React/native imports) so tab parsing is unit-testable and the deep-link `tab`
// query param and the segmented control share one source of truth.
export type SourceControlHubTab = 'changes' | 'pr' | 'history'
export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
'changes',
'pr',
'history'
] as const
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
changes: 'Changes',
pr: 'Pull Request',
history: 'History'
}
// Normalize a route param (possibly an array from expo-router, possibly unknown)
// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid
// tab fall back rather than render a blank body.
export function parseSourceControlHubTab(
value: string | string[] | undefined | null
): SourceControlHubTab {
const first = Array.isArray(value) ? value[0] : value
return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab)
? (first as SourceControlHubTab)
: 'changes'
}

View File

@ -45,6 +45,8 @@ type Params = {
setCreatedPrUrl: (next: string | null) => void
setCreatedPrWarning: (next: string | null) => void
recordCommitFailure: RecordMobileCommitFailure
// Hub override: switch to the History segment instead of pushing the route.
onOpenHistory?: () => void
}
// All git workflow + action-sheet runners for the source-control panel. Split
@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) {
setShowBranchPicker,
setCreatedPrUrl,
setCreatedPrWarning,
recordCommitFailure
recordCommitFailure,
onOpenHistory
} = params
const runGitWorkflow = useCallback(
@ -248,14 +251,24 @@ export function useMobileSourceControlRunners(params: Params) {
const openHistory = useCallback(() => {
setShowActionSheet(false)
if (hostId && worktreeId) {
router.push(
`/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
typeof router.push
>[0]
)
// Inside the hub, History is a segment — switch to it rather than pushing a
// route. Fallback pushes the hub with `tab=history` (not the redirecting
// /history route) so deep links land in one hop.
if (onOpenHistory) {
onOpenHistory()
return
}
}, [hostId, router, setShowActionSheet, worktreeId])
if (hostId && worktreeId) {
router.push({
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId,
worktreeId,
tab: 'history'
}
} as Parameters<typeof router.push>[0])
}
}, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId])
// Switch to a local branch, then reload status.
const checkoutBranch = useCallback(

View File

@ -42,6 +42,9 @@ export type MobileSourceControlStateParams = {
onRequestClose?: () => void
onFileOpenStart?: () => void
onOpenedFileDiff?: (relativePath: string) => void
// When the panel runs inside the hub, "History" switches the segment instead of
// pushing the standalone route. Absent for the standalone/dock usage.
onOpenHistory?: () => void
}
export function useMobileSourceControlState(params: MobileSourceControlStateParams) {
@ -53,7 +56,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
embedded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
onOpenedFileDiff,
onOpenHistory
} = params
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
@ -197,7 +201,8 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
setShowBranchPicker,
setCreatedPrUrl,
setCreatedPrWarning,
recordCommitFailure
recordCommitFailure,
onOpenHistory
})
const createPrAction = useMobileSourceControlCreatePrAction({
client,