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
This commit is contained in:
Jinjing
2026-07-09 18:04:28 -07:00
parent 4baaca601a
commit eb678cd77f
22 changed files with 2924 additions and 332 deletions
+18 -234
View File
@@ -1,242 +1,26 @@
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[]
}>()
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),
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 }
})
+22 -25
View File
@@ -1,30 +1,27 @@
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 still renders MobilePrViewPanel
// directly (it does not route through here).
export default function PrRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: 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),
tab: 'pr'
}
}}
/>
)
}
@@ -4431,14 +4431,17 @@ 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' } : {})
...(action.panel === 'sourceControl' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
}
})
}
@@ -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
@@ -7,11 +7,14 @@ import { colors, radii, spacing, typography } 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 {
useMobilePrSidebarController,
type MobilePrSidebarController
} from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
type Props = {
type BodyProps = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
@@ -20,13 +23,34 @@ 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.
controller: MobilePrSidebarController
// 'chromeless' drops the panel's own header + SafeAreaView so it can live inside
// the source-control hub, which owns the header, segmented control, and chrome.
chromeless?: boolean
// When false, the parent (the hub) owns triggering the initial load — the body
// must not also fire it, or a ?tab=pr deep link double-fetches. Default true so
// the standalone route stays self-loading.
autoLoad?: boolean
embedded?: boolean
onRequestClose?: () => void
}
export function MobilePrViewPanel({
type Props = Omit<BodyProps, 'controller' | 'autoLoad'>
// Standalone entry (the /pr route + wide-layout dock): owns its controller and
// self-loads. The hub uses MobilePrViewPanelBody with a shared controller instead.
export function MobilePrViewPanel(props: Props) {
const controller = useMobilePrSidebarController({
client: props.client,
connState: props.connState,
worktreeId: props.worktreeId,
branch: props.branch,
headSha: props.headSha
})
return <MobilePrViewPanelBody {...props} controller={controller} />
}
export function MobilePrViewPanelBody({
client,
connState,
worktreeId,
@@ -35,29 +59,25 @@ export function MobilePrViewPanel({
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
controller,
chromeless = false,
autoLoad = true,
embedded = false,
onRequestClose
}: Props) {
}: BodyProps) {
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).
// showPRSidebar overlay flag (KTD4). Skipped when the hub owns the trigger.
const prSidebarKind = controller.prSidebarState.kind
const refetch = controller.refetchPRSidebar
useEffect(() => {
if (branch && isGithubRepo && prSidebarKind === 'hidden') {
if (autoLoad && branch && isGithubRepo && prSidebarKind === 'hidden') {
refetch()
}
}, [branch, isGithubRepo, prSidebarKind, refetch])
}, [autoLoad, branch, isGithubRepo, prSidebarKind, refetch])
// Embedded: the dock column applies the bottom inset; full-screen relies on its own
// SafeAreaView (edges top only), so content must clear the home indicator itself.
@@ -110,6 +130,11 @@ export function MobilePrViewPanel({
/>
)
// Chromeless: the hub owns the header + chrome, so render just the scrollable body.
if (chromeless) {
return <View style={styles.container}>{sidebar}</View>
}
if (embedded) {
return (
<View style={styles.container}>
@@ -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' }
})
})
})
+7 -2
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' } }
}
}
@@ -145,3 +145,5 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
refetchPRSidebar: load
}
}
export type MobilePrSidebarController = ReturnType<typeof useMobilePrSidebarController>
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from './mobile-git-history'
import type { GitBranchChangeEntry } from '../../../src/shared/types'
type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
bottomInset: 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.
export function MobileGitHistoryList({ client, connState, worktreeId, bottomInset }: Props) {
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
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, worktreeId])
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]
)
if (error) {
return (
<View style={styles.state}>
<Text style={styles.stateText}>{error}</Text>
</View>
)
}
if (rows === null) {
return (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
}
if (rows.length === 0) {
return (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
)
}
return (
<FlatList
data={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 },
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 }
})
@@ -0,0 +1,73 @@
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'
type Props = {
branchLabel: string
syncLabel: string | null
unstagedCount: number
stagedCount: number
branchCount: number
conflictOperation: string | null
conflictBusy: 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,
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}>
{conflictBusy ? 'Aborting…' : `Abort ${conflictOperation}`}
</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
</View>
)
}
@@ -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,
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useMemo, 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,15 @@ 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 { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
export type MobileSourceControlPanelProps = {
hostId: string
@@ -15,6 +24,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 +37,15 @@ export function MobileSourceControlPanel({
name = '',
origin = '',
embedded = false,
initialTab = 'changes',
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
const openHistoryTab = useCallback(() => setActiveTab('history'), [])
const openPrTab = useCallback(() => setActiveTab('pr'), [])
const state = useMobileSourceControlState({
hostId,
worktreeId,
@@ -38,12 +54,15 @@ export function MobileSourceControlPanel({
embedded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
onOpenedFileDiff,
onOpenHistory: openHistoryTab
})
const actionSheetActions = useMobileSourceControlActionSheet(state)
const {
client,
connState,
forceReconnect,
insets,
router,
setRootRef,
worktreeLabel,
@@ -51,14 +70,53 @@ export function MobileSourceControlPanel({
busyAction,
openingPath,
openingBranchPath,
loadStatus
loadStatus,
status,
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.
const prBranch = status?.branch ?? null
const prHeadSha = status?.head ?? null
const prController = useMobilePrSidebarController({
client,
connState,
worktreeId,
branch: prBranch,
headSha: prHeadSha
})
const isHostedRepo = prController.prSidebarIsGithubRepo
const prSidebarKind = prController.prSidebarState.kind
const refetchPr = prController.refetchPRSidebar
useEffect(() => {
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
refetchPr()
}
}, [prBranch, isHostedRepo, prSidebarKind, refetchPr])
const prChip = useMemo(() => {
if (!isHostedRepo) {
return null
}
const commentCount =
prController.prSidebarState.kind === 'ready'
? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments)
: null
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prController.prSidebarState])
// 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()
const header = (
<MobileSourceControlHeader
@@ -80,6 +138,8 @@ export function MobileSourceControlPanel({
</SafeAreaView>
)}
<MobileSourceControlSegments active={activeTab} onSelect={setActiveTab} />
{screenState.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
@@ -110,7 +170,46 @@ export function MobileSourceControlPanel({
) : null}
</View>
) : (
<MobileSourceControlContent state={state} />
<>
<MobileSourceControlBranchCard
branchLabel={branchLabel}
syncLabel={syncLabel}
unstagedCount={unstagedCount}
stagedCount={stagedCount}
branchCount={branchEntries.length}
conflictOperation={status?.conflictOperation ?? null}
conflictBusy={busyAction !== null}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
prChip={prChip}
onOpenPr={openPrTab}
/>
{activeTab === 'changes' ? (
<MobileSourceControlContent state={state} />
) : activeTab === 'pr' ? (
<MobilePrViewPanelBody
client={client}
connState={connState}
worktreeId={worktreeId}
branch={prBranch}
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
branchContextLoaded={ready}
controller={prController}
chromeless
autoLoad={false}
/>
) : (
<View style={hubStyles.tabBody}>
<MobileGitHistoryList
client={client}
connState={connState}
worktreeId={worktreeId}
bottomInset={insets.bottom}
/>
</View>
)}
</>
)}
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
@@ -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 && 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 && summary.commentCount > 0
? `, ${summary.commentCount} unresolved comments`
: ''
return `Pull request ${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.`
}
}
}
@@ -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>
)
}
@@ -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)
})
})
@@ -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' }
}
@@ -0,0 +1,121 @@
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
}
})
@@ -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')
})
})
@@ -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'
}
@@ -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,6 +251,12 @@ export function useMobileSourceControlRunners(params: Params) {
const openHistory = useCallback(() => {
setShowActionSheet(false)
// Inside the hub, History is a segment — switch to it rather than pushing a
// route. The standalone/dock usage (no override) keeps pushing the route.
if (onOpenHistory) {
onOpenHistory()
return
}
if (hostId && worktreeId) {
router.push(
`/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
@@ -255,7 +264,7 @@ export function useMobileSourceControlRunners(params: Params) {
>[0]
)
}
}, [hostId, router, setShowActionSheet, worktreeId])
}, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId])
// Switch to a local branch, then reload status.
const checkoutBranch = useCallback(
@@ -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,