Split mobile host screen

This commit is contained in:
Neil
2026-08-29 04:32:18 -07:00
parent f7bec75a23
commit 0b1d3e3d46
15 changed files with 1866 additions and 1417 deletions
-1
View File
@@ -66,7 +66,6 @@ inline src/renderer/src/store/slices/ui.ts
inline src/shared/keybindings.ts
inline src/shared/telemetry-events.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
-6
View File
@@ -45,12 +45,6 @@
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1603, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/index.tsx"],
"rules": {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,337 @@
import { Pressable, Text, View } from 'react-native'
import {
ChevronLeft,
Filter,
Layers,
List,
PanelLeftClose,
Plus,
Search,
SlidersHorizontal,
SquareTerminal,
UserCircle,
X
} from 'lucide-react-native'
import { StatusDot } from '../components/StatusDot'
import { classifyConnection, type ConnectionVerdict } from '../transport/connection-health'
import { colors } from '../theme/mobile-theme'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
}
export function HostScreenHeader({ controller }: { controller: HostScreenController }) {
const {
actions,
connState,
embedded,
floatingWorkspaceEnabled,
forceReconnectHost,
hostId,
lastConnectedAt,
onHideSidebar,
reconnectAttempts,
relayRecovery,
settings,
state
} = controller
return (
<View style={styles.topChrome}>
<View style={styles.statusBar}>
<Pressable
style={styles.backButton}
onPress={actions.leaveHost}
accessibilityRole="button"
accessibilityLabel="Back to hosts"
hitSlop={8}
>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
{(() => {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt,
...relayRecovery
})
return (
<>
<View style={styles.hostIdentity}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.hostNameText} numberOfLines={1}>
{state.hostName || 'Host'}
</Text>
</View>
{connState !== 'connected' &&
(() => {
// Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
if (!showReconnectButton) {
return null
}
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)
})()}
</>
)
})()}
{!embedded && floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
hitSlop={8}
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
) : null}
{embedded && onHideSidebar ? (
<Pressable
style={styles.sidebarCollapseButton}
onPress={onHideSidebar}
accessibilityRole="button"
accessibilityLabel="Hide sidebar"
hitSlop={8}
>
<PanelLeftClose size={14} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{/* Filter/sort/group toolbar */}
{embedded ? (
<View style={styles.embeddedToolbar}>
<View style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.filterChip,
styles.embeddedFilterChip,
settings.activeFilterCount > 0 && styles.filterChipActive
]}
onPress={() => state.setShowFilterModal(true)}
accessibilityRole="button"
accessibilityLabel={`Filter workspaces${settings.activeFilterCount > 0 ? `, ${settings.activeFilterCount} active` : ''}`}
>
<Filter
size={12}
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
settings.activeFilterCount > 0 && styles.filterChipTextActive
]}
numberOfLines={1}
>
Filter{settings.activeFilterCount > 0 ? ` ${settings.activeFilterCount}` : ''}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowSortPicker(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${settings.selectedSortLabel}`}
>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowGroupPicker(true)}
accessibilityRole="button"
accessibilityLabel="Group workspaces"
>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{state.groupMode === 'none'
? 'Group'
: state.groupMode === 'workspaceStatus'
? 'Status'
: state.groupMode === 'repo'
? 'Repo'
: 'PR'}
</Text>
</Pressable>
</View>
<View style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Accounts"
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Tasks"
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
{floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
) : null}
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openNewWorktreeModal}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="New workspace"
>
<Plus
size={16}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.embeddedToolbarIconButton}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
</View>
) : (
<View style={styles.toolbar}>
<Pressable
style={[styles.filterChip, settings.activeFilterCount > 0 && styles.filterChipActive]}
onPress={() => state.setShowFilterModal(true)}
>
<Filter
size={12}
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
settings.activeFilterCount > 0 && styles.filterChipTextActive
]}
>
Filter{settings.activeFilterCount > 0 ? ` (${settings.activeFilterCount})` : ''}
</Text>
</Pressable>
<Pressable style={styles.modeButton} onPress={() => state.setShowSortPicker(true)}>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable style={styles.modeButton} onPress={() => state.setShowGroupPicker(true)}>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{state.groupMode === 'none'
? 'Group'
: state.groupMode === 'workspaceStatus'
? 'Status'
: state.groupMode === 'repo'
? 'Repo'
: 'PR'}
</Text>
</Pressable>
<View style={styles.toolbarSpacer} />
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
disabled={connState !== 'connected'}
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable style={styles.searchToggle} onPress={() => state.setShowSearch((s) => !s)}>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
)}
</View>
)
}
@@ -0,0 +1,231 @@
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { ConfirmModal } from '../components/ConfirmModal'
import { NewWorktreeModalController } from '../components/NewWorktreeModalController'
import { PickerModal } from '../components/PickerModal'
import { colors } from '../theme/mobile-theme'
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
const {
actions,
catalog,
client,
existingWorktreePaths,
hostCapabilities,
hostId,
settings,
showNewWorktree,
state
} = controller
const actionTarget = state.actionTarget
return (
<>
<PickerModal
visible={state.showSortPicker}
title="Sort By"
options={SORT_OPTIONS}
selected={state.sortMode}
onSelect={settings.handleSortChange}
onClose={() => state.setShowSortPicker(false)}
/>
<PickerModal
visible={state.showGroupPicker}
title="Group By"
options={GROUP_OPTIONS}
selected={state.groupMode}
onSelect={settings.handleGroupChange}
onClose={() => state.setShowGroupPicker(false)}
/>
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
{settings.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
</Pressable>
)}
</View>
<Text style={styles.filterSectionLabel}>Workspaces</Text>
<View style={styles.filterGroup}>
<Pressable style={styles.filterRow} onPress={settings.toggleHideSleeping}>
<Text style={styles.filterRowText}>Hide sleeping</Text>
{state.filters.hideSleeping && <Check size={14} color={colors.textPrimary} />}
</Pressable>
<View style={styles.filterSeparator} />
<Pressable style={styles.filterRow} onPress={settings.toggleHideDefaultBranch}>
<Text style={styles.filterRowText}>Hide default branch</Text>
{state.filters.hideDefaultBranch && <Check size={14} color={colors.textPrimary} />}
</Pressable>
</View>
{controller.sectionsResult.uniqueRepos.length > 1 && (
<>
<Text style={styles.filterSectionLabel}>Repositories</Text>
<View style={styles.filterGroup}>
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
<View key={repo.id}>
{i > 0 && <View style={styles.filterSeparator} />}
<Pressable
style={styles.filterRow}
onPress={() => settings.toggleRepoFilter(repo.id)}
>
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
<Text style={styles.filterRowText} numberOfLines={1}>
{repo.name}
</Text>
{state.filters.filterRepoIds.has(repo.id) && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
</View>
))}
</View>
</>
)}
</BottomDrawer>
{/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
<BottomDrawer
visible={actionTarget != null}
onClose={() => {
state.setConfirmDelete(null)
state.setActionTarget(null)
}}
>
{state.confirmDelete ? (
<View>
<View style={styles.confirmContent}>
<Text style={styles.confirmTitle}>Delete Worktree</Text>
<Text style={styles.confirmMessage}>
Delete "{state.confirmDelete.displayName || state.confirmDelete.repo}" (
{state.confirmDelete.branch})?
</Text>
</View>
<View style={styles.confirmButtons}>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnCancel,
pressed && styles.confirmBtnPressed
]}
onPress={() => state.setConfirmDelete(null)}
>
<Text style={styles.confirmBtnCancelText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnDestructive,
pressed && styles.confirmBtnPressed
]}
onPress={() => {
if (state.confirmDelete) {
void actions.handleDeleteWorktree(state.confirmDelete)
}
state.setConfirmDelete(null)
state.setActionTarget(null)
}}
>
<Text style={styles.confirmBtnDestructiveText}>Delete</Text>
</Pressable>
</View>
</View>
) : (
<ActionSheetContent
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
message={actionTarget?.branch}
actions={
actionTarget
? [
...buildWorktreeNavigationActions({
hostId,
worktreeId: actionTarget.worktreeId,
worktreeName: actionTarget.displayName || actionTarget.repo,
hostCapabilities,
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
}),
{
label: 'Sleep',
icon: Moon,
onPress: () => {
if (client) {
state.setSleptIds((prev) =>
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
)
void client
.sendRequest('worktree.sleep', {
worktree: `id:${actionTarget.worktreeId}`
})
.catch(() => null)
}
state.setActionTarget(null)
}
},
{
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
onPress: () => {
actions.togglePin(actionTarget.worktreeId)
state.setActionTarget(null)
}
},
{
label: 'Delete',
destructive: true,
onPress: () => state.setConfirmDelete(actionTarget)
}
]
: []
}
/>
)}
</BottomDrawer>
{/* Host remove confirmation */}
<ConfirmModal
visible={state.confirmRemoveHost}
title="Remove Host"
message={`Remove "${state.hostName}"? You can re-pair later.`}
confirmLabel="Remove"
destructive
onConfirm={() => void actions.handleRemoveHost()}
onCancel={() => state.setConfirmRemoveHost(false)}
/>
<NewWorktreeModalController
ref={state.newWorktreeModalRef}
routeVisible={showNewWorktree}
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={state.worktrees}
onVisibleChange={(visible) => {
state.newWorktreeModalVisibleRef.current = visible
}}
onCreated={(worktreeId, worktreeName) => {
void catalog.fetchWorktrees({ allowDuringModal: true })
actions.navigateFromHostList(
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName)
)
}}
onRouteVisibleChange={actions.setShowNewWorktreeVisible}
/>
</>
)
}
@@ -0,0 +1,25 @@
import { Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { hostScreenStyles as styles } from './host-screen-styles'
import { HostScreenHeader } from './host-screen-header'
import { HostScreenOverlays } from './host-screen-overlays'
import { HostWorkspaceList } from './host-workspace-list'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenView({ controller }: { controller: HostScreenController }) {
if (controller.state.error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{controller.state.error}</Text>
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<HostScreenHeader controller={controller} />
<HostWorkspaceList controller={controller} />
<HostScreenOverlays controller={controller} />
</SafeAreaView>
)
}
@@ -0,0 +1,199 @@
import { Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
import { AuthFailedBanner } from '../components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
import { MobileRepoIcon } from '../components/MobileRepoIcon'
import { MobileSearchField } from '../components/MobileSearchField'
import { NewWorkspaceFab, FAB_SIZE } from '../components/NewWorkspaceFab'
import { WorktreeListRow } from '../components/WorktreeListRow'
import { colors, spacing } from '../theme/mobile-theme'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { repoColor } from '../worktree/repo-color'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
const {
actions,
activeWorktreeScroll,
catalog,
connState,
contentMaxWidth,
displayWorktrees,
embedded,
forceReconnectHost,
hostId,
insets,
isReadOnly,
isWideLayout,
noticeParam,
now,
reconnectAttempts,
relayRecovery,
routeNotice,
router,
sectionsResult,
setDismissedNotice,
settings,
state
} = controller
const { rawSections, sections, uniqueRepoColors } = sectionsResult
return (
<>
{/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */}
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId)}
onRepair={() => router.push('/pair-scan')}
onRemove={() => state.setConfirmRemoveHost(true)}
/>
)}
{connState !== 'connected' &&
!relayRecovery.pairingRejected &&
reconnectAttempts >= 3 &&
hostId ? (
<HostDiagnosticsLink
onPress={() =>
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
}
/>
) : null}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
message={routeNotice}
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
/>
)}
{/* Search bar */}
{state.showSearch && (
<View style={styles.searchBar}>
<MobileSearchField
value={state.search}
onChangeText={state.setSearch}
placeholder="Search worktrees…"
autoFocus
// Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
focusKey={state.showSearch}
accessibilityLabel="Search worktrees"
/>
</View>
)}
<HostWorkspaceListStates
connState={connState}
worktreesLoaded={state.worktreesLoaded}
displayCount={displayWorktrees.length}
sectionCount={sections.length}
catalogError={state.catalogError}
search={state.search}
activeFilterCount={settings.activeFilterCount}
/>
{sections.length > 0 && (
<SectionList
ref={activeWorktreeScroll.sectionListRef}
sections={sections}
keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)}
stickySectionHeadersEnabled={false}
// Why: keep the search IME up while tapping clear / scrolling results.
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onScrollToIndexFailed={activeWorktreeScroll.onScrollToIndexFailed}
// Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
contentContainerStyle={[
styles.list,
// Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
isWideLayout &&
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
]}
renderSectionHeader={({ section }) => {
if (!section.title) {
return null
}
const isCollapsed = state.collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon =
state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null
return (
<Pressable
style={styles.sectionHeader}
onPress={() => settings.toggleCollapsed(section.key)}
>
{isCollapsed ? (
<ChevronRight size={12} color={colors.textMuted} style={styles.sectionIcon} />
) : (
<ChevronDown size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{state.groupMode === 'repo' ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={repoSectionIcon}
size={14}
color={repoSectionColor ?? colors.textSecondary}
/>
</View>
) : null}
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{count}</Text>
</Pressable>
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
refreshControl={
<RefreshControl
refreshing={catalog.refreshing}
onRefresh={catalog.onRefresh}
tintColor={colors.textSecondary}
colors={[colors.textSecondary]}
/>
}
renderItem={({ item }) => (
<WorktreeListRow
item={item}
isReadOnly={isReadOnly}
now={now}
status={getWorktreeStatus(item)}
repoColor={uniqueRepoColors.get(item.repo) ?? repoColor(item.repo)}
repoIcon={state.repoIconsByName.get(item.repo) ?? null}
hideRepo={state.groupMode === 'repo'}
onPress={actions.openWorktreeSession}
onLongPress={
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
}
onToggleLineage={settings.toggleWorktreeLineage}
/>
)}
/>
)}
{/* Floating "new workspace" button — phone only; embedded sidebars keep the toolbar +. */}
{!embedded && (
<NewWorkspaceFab
onPress={actions.openNewWorktreeModal}
disabled={connState !== 'connected'}
/>
)}
</>
)
}
function ListSeparator() {
return <View style={styles.separator} />
}
@@ -0,0 +1,85 @@
import { useCallback } from 'react'
import { setCachedRepos } from '../cache/repo-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RepoSummary } from '../worktree/host-worktree-rpc-types'
import { repoColor } from '../worktree/repo-color'
import type { HostScreenState } from './use-host-screen-state'
const REPO_METADATA_REFRESH_MS = 60_000
export function useHostRepoMetadata(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: HostScreenState
}) {
const { client, connState, hostId, state } = args
const {
clientRef,
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
repoMetadataFetchedAtRef,
setRepoColorsByName,
setRepoIconsByName,
setRepoIdsByName
} = state
const fetchRepoMetadata = useCallback(
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (fetchRepoMetadataInFlightRef.current.has(client)) {
if (options.queueIfInFlight) {
fetchRepoMetadataPendingRef.current.add(client)
}
return
}
const now = Date.now()
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
return
}
fetchRepoMetadataInFlightRef.current.add(client)
const requestClient = client,
requestHostId = hostId
try {
do {
fetchRepoMetadataPendingRef.current.delete(requestClient)
const repoResponse = await requestClient.sendRequest('repo.list')
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
return
}
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
repoMetadataFetchedAtRef.current = Date.now()
setCachedRepos(requestHostId, repoResult.repos)
setRepoColorsByName(
new Map(
repoResult.repos.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
setRepoIconsByName(
new Map(
repoResult.repos.flatMap((repo) =>
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
)
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
} catch {
// Repo metadata is decorative; the next refresh can retry.
} finally {
fetchRepoMetadataInFlightRef.current.delete(requestClient)
}
},
[client, connState, hostId]
)
return fetchRepoMetadata
}
export type FetchHostRepoMetadata = ReturnType<typeof useHostRepoMetadata>
@@ -0,0 +1,158 @@
import { useMemo, useState } from 'react'
import { useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { visibleHostRouteNotice } from '../host-route-notice'
import { resolveHostRouteActionState } from '../host-route-action-state'
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
import { useNow } from '../hooks/use-now'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useForgetHostClient, useForceReconnect, useHostClient } from '../transport/client-context'
import {
useLastConnectedAt,
useReconnectAttempt,
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostScreenState } from './use-host-screen-state'
import { useHostViewSettings } from './use-host-view-settings'
import { useHostWorktreeActions } from './use-host-worktree-actions'
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
export type HostScreenProps = {
// When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
embedded?: boolean
// Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
hostId?: string
action?: string
onHideSidebar?: () => void
}
export function useHostScreenController({
embedded = false,
hostId: hostIdProp,
action: actionProp,
onHideSidebar
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
const noticeParam = params.notice?.trim()
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
// Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const relayRecovery = useRelayRecoveryStatus(hostId)
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
// One tick drives every visible agent row's relative timestamp.
const now = useNow(30_000)
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
const state = useHostScreenState(hostId, action)
const settings = useHostViewSettings({ client, connState, hostId, state })
useHostScreenIdentity({ client, hostId, state })
const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state })
const catalog = useHostWorktreeCatalog({
client,
connState,
embedded,
fetchRepoMetadata,
hostId,
state,
syncViewSettingsFromDesktop: settings.syncViewSettingsFromDesktop
})
const actions = useHostWorktreeActions({
client,
connState,
embedded,
fetchWorktrees: catalog.fetchWorktrees,
forgetHostClient,
hostId,
pathname,
router,
state
})
const resolvedRouteActionState = resolveHostRouteActionState(state.routeActionState, action)
// Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
if (resolvedRouteActionState !== state.routeActionState) {
state.setRouteActionState(resolvedRouteActionState)
}
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const displayWorktrees = useMemo(() => {
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
return applyWorktreeRowDisplayState(
base,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.sortMode,
filters: state.filters,
search: state.search,
groupMode: state.groupMode,
pinnedIds: state.pinnedIds,
repoIdsByName: state.repoIdsByName,
repoColorsByName: state.repoColorsByName,
collapsedGroups: state.collapsedGroups,
workspaceStatuses: state.workspaceStatuses
})
const existingWorktreePaths = useMemo(() => state.worktrees.map((w) => w.path), [state.worktrees])
const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections)
return {
actions,
activeWorktreeScroll,
catalog,
client,
connState,
contentMaxWidth,
displayWorktrees,
embedded,
existingWorktreePaths,
floatingWorkspaceEnabled,
forceReconnectHost,
hostCapabilities,
hostId,
insets,
isReadOnly: connState === 'auth-failed',
isWideLayout,
lastConnectedAt,
noticeParam,
now,
onHideSidebar,
reconnectAttempts,
relayRecovery,
routeNotice,
router,
sectionsResult,
setDismissedNotice,
settings,
showNewWorktree,
state
}
}
export type HostScreenController = ReturnType<typeof useHostScreenController>
@@ -0,0 +1,90 @@
import { useEffect } from 'react'
import { getCachedWorktrees } from '../cache/worktree-cache'
import { loadPinnedIds } from '../storage/preferences'
import { loadHosts, updateLastConnected } from '../transport/host-store'
import type { RpcClient } from '../transport/rpc-client'
import type { Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostScreenIdentity(args: {
client: RpcClient | null
hostId: string | undefined
state: HostScreenState
}): void {
const { client, hostId, state } = args
const {
clientRef,
repoMetadataFetchedAtRef,
setCatalogError,
setError,
setHostName,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoIconsByName,
setWorktrees,
setWorktreesLoaded
} = state
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
useEffect(() => {
if (!hostId) {
return
}
let stale = false
void (async () => {
const pins = await loadPinnedIds(hostId)
if (stale) {
return
}
setPinnedIds(pins)
})()
return () => {
stale = true
}
}, [hostId])
// Why: mirror client into a ref so imperative call sites read it without re-subscribing.
useEffect(() => {
clientRef.current = client
}, [client])
useEffect(() => {
setHostName('')
setError('')
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
repoMetadataFetchedAtRef.current = 0
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
setCatalogError(null)
if (freshCache) {
setWorktrees(freshCache)
setLastKnownWorktrees(freshCache)
setWorktreesLoaded(true)
} else {
setWorktreesLoaded(false)
setWorktrees([])
setLastKnownWorktrees([])
}
if (!hostId) {
return
}
let stale = false
void loadHosts().then((hosts) => {
if (stale) {
return
}
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
setHostName(host.name)
void updateLastConnected(host.id)
})
return () => {
stale = true
}
}, [hostId])
}
@@ -0,0 +1,148 @@
import { useRef, useState } from 'react'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
import { getCachedWorktrees } from '../cache/worktree-cache'
import { createInitialHostRouteActionState } from '../host-route-action-state'
import type { RpcClient } from '../transport/rpc-client'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../worktree/mobile-workspace-statuses'
import { WorktreeCatalogSnapshotClient } from '../worktree/worktree-catalog-snapshot-client'
import type {
MobileGroupMode,
MobileSortMode,
MobileViewState
} from '../worktree/workspace-view-settings'
import type { FilterState, Worktree } from '../worktree/workspace-list-sections'
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
// Why: useRef, not useMemo — React may discard memoized values, which would silently
// reset the snapshot token this object exists to own.
const worktreeCatalogRef = useRef(new WorktreeCatalogSnapshotClient())
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
// path renders as a failure instead of an empty host. Cleared on the next success.
const [catalogError, setCatalogError] = useState<string | null>(null)
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
string | null
>(null)
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, RepoIcon>>(new Map())
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
const [sortMode, setSortMode] = useState<MobileSortMode>('recent')
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
createInitialHostRouteActionState(action)
)
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
const viewStateRef = useRef<MobileViewState>({
groupMode: 'repo',
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
})
return {
actionTarget,
catalogError,
clientRef,
collapsedGroups,
confirmDelete,
confirmRemoveHost,
error,
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
fetchWorktreesInFlightRef,
filters,
groupMode,
hostName,
lastKnownWorktrees,
newWorktreeModalRef,
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoIconsByName,
repoIdsByName,
repoMetadataFetchedAtRef,
routeActionState,
search,
setActionTarget,
setCatalogError,
setCollapsedGroups,
setConfirmDelete,
setConfirmRemoveHost,
setError,
setFilters,
setGroupMode,
setHostName,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoIconsByName,
setRepoIdsByName,
setRouteActionState,
setSearch,
setShowFilterModal,
setShowGroupPicker,
setShowSearch,
setShowSortPicker,
setSleptIds,
setSortMode,
setWorkspaceStatuses,
setWorktrees,
setWorktreesLoaded,
showFilterModal,
showGroupPicker,
showSearch,
showSortPicker,
sleptIds,
sortMode,
viewStateRef,
workspaceStatuses,
worktreeCatalogRef,
worktrees,
worktreesLoaded
}
}
export type HostScreenState = ReturnType<typeof useHostScreenState>
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage'
import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../worktree/workspace-list-picker-options'
import {
applyDesktopViewSettings,
buildWorkspaceViewSettingsUpdate,
type MobileGroupMode,
type MobileSortMode,
type MobileViewState,
type WorkspaceViewSettings
} from '../worktree/workspace-view-settings'
import type { Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostViewSettings(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: HostScreenState
}) {
const { client, connState, hostId, state } = args
const {
clientRef,
collapsedGroups,
filters,
groupMode,
setCollapsedGroups,
setFilters,
setGroupMode,
setSortMode,
setWorkspaceStatuses,
sortMode,
viewStateRef,
workspaceStatuses
} = state
useEffect(() => {
viewStateRef.current = {
groupMode,
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
}
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
const applyViewState = useCallback((next: MobileViewState) => {
viewStateRef.current = next
setGroupMode(next.groupMode)
setSortMode(next.sortMode)
setWorkspaceStatuses(next.workspaceStatuses)
setCollapsedGroups(new Set(next.collapsedGroups))
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: Partial<MobileViewState>) => {
const next: MobileViewState = { ...viewStateRef.current, ...patch }
applyViewState(next)
if (!client) {
return
}
// Send only the touched fields: the host merges partial updates, so a stale
// mirror can no longer revert sibling settings another client just changed
// (STA-5781; supersedes the #8873 whole-payload special case).
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next)
if (Object.keys(payload).length === 0) {
return
}
void client.sendRequest('ui.set', payload).catch(() => {
// Best-effort: view settings are a convenience preference.
})
},
[client, applyViewState]
)
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(async () => {
if (!client || connState !== 'connected') {
return
}
const requestClient = client
const requestHostId = hostId
try {
const response = await requestClient.sendRequest('ui.get')
if (clientRef.current !== requestClient || hostId !== requestHostId || !response.ok) {
return
}
const ui = ((response as RpcSuccess).result as { ui?: WorkspaceViewSettings }).ui
if (!ui) {
return
}
applyViewState(applyDesktopViewSettings(viewStateRef.current, ui))
} catch {
// Transient transport failure; retry on the next focus/connect.
}
}, [client, connState, hostId, applyViewState])
const handleSortChange = useCallback(
(value: MobileSortMode) => {
persistViewSettings({ sortMode: value })
},
[persistViewSettings]
)
const toggleHideSleeping = useCallback(() => {
persistViewSettings({ hideSleeping: !viewStateRef.current.hideSleeping })
}, [persistViewSettings])
const toggleHideDefaultBranch = useCallback(() => {
persistViewSettings({ hideDefaultBranch: !viewStateRef.current.hideDefaultBranch })
}, [persistViewSettings])
const toggleRepoFilter = useCallback(
(repoId: string) => {
const next = new Set(viewStateRef.current.filterRepoIds)
if (next.has(repoId)) {
next.delete(repoId)
} else {
next.add(repoId)
}
persistViewSettings({ filterRepoIds: [...next] })
},
[persistViewSettings]
)
const clearFilters = useCallback(() => {
persistViewSettings({ hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] })
}, [persistViewSettings])
const activeFilterCount = useMemo(() => {
let count = 0
if (filters.hideSleeping) {
count++
}
if (filters.hideDefaultBranch) {
count++
}
count += filters.filterRepoIds.size
return count
}, [filters])
const selectedSortLabel =
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? 'Recent'
const handleGroupChange = useCallback(
(value: MobileGroupMode) => {
persistViewSettings({ groupMode: value })
},
[persistViewSettings]
)
const toggleCollapsed = useCallback(
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (!next.delete(key)) {
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
[persistViewSettings]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
[toggleCollapsed]
)
return {
activeFilterCount,
clearFilters,
handleGroupChange,
handleSortChange,
selectedSortLabel,
syncViewSettingsFromDesktop,
toggleCollapsed,
toggleHideDefaultBranch,
toggleHideSleeping,
toggleRepoFilter,
toggleWorktreeLineage
}
}
export type HostViewSettings = ReturnType<typeof useHostViewSettings>
@@ -0,0 +1,212 @@
import { useCallback } from 'react'
import { Alert } from 'react-native'
import type { useRouter } from 'expo-router'
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
import { savePinnedIds } from '../storage/preferences'
import type { useForgetHostClient } from '../transport/client-context'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { setHostRouteNewWorktreeVisible } from '../host-route-action-state'
import { leaveHostRoute } from '../host-route-exit'
import { getWorktreeRowIdentity, removeWorktreeRow } from '../worktree/worktree-host-row-identity'
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostWorktreeActions(args: {
client: RpcClient | null
connState: ConnectionState
embedded: boolean
fetchWorktrees: (options?: { allowDuringModal?: boolean }) => Promise<void>
forgetHostClient: ReturnType<typeof useForgetHostClient>
hostId: string | undefined
pathname: string
router: ReturnType<typeof useRouter>
state: HostScreenState
}) {
const {
client,
connState,
embedded,
fetchWorktrees,
forgetHostClient,
hostId,
pathname,
router,
state
} = args
const {
newWorktreeModalRef,
newWorktreeModalVisibleRef,
pinnedIds,
setConfirmRemoveHost,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRouteActionState,
setWorktrees,
worktrees
} = state
const leaveHost = useCallback(() => {
leaveHostRoute(router)
}, [router])
const openNewWorktreeModal = useCallback(() => {
const modal = newWorktreeModalRef.current
if (!modal) {
return
}
newWorktreeModalVisibleRef.current = true
modal.open()
}, [])
const setShowNewWorktreeVisible = useCallback((visible: boolean) => {
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
setPinnedIds((prev) => {
const next = new Set(prev)
if (pinned) {
next.add(worktreeId)
} else {
next.delete(worktreeId)
}
if (hostId) {
void savePinnedIds(hostId, next)
}
return next
})
},
[hostId]
)
const togglePin = useCallback(
(worktreeId: string) => {
const worktree = worktrees.find((w) => w.worktreeId === worktreeId)
const currentlyPinned = worktree
? isWorktreePinned(worktree, pinnedIds)
: pinnedIds.has(worktreeId)
const newPinned = !currentlyPinned
setWorktrees((prev) =>
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
)
setLastKnownWorktrees((prev) =>
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
)
updateLocalPins(worktreeId, newPinned)
if (client) {
client
.sendRequest('worktree.set', {
worktree: `id:${worktreeId}`,
isPinned: newPinned
})
.catch(() => {})
}
},
[client, worktrees, pinnedIds, updateLocalPins]
)
const handleDeleteWorktree = useCallback(
async (item: Worktree) => {
if (!client) {
return
}
const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item)
setWorktrees(removeFromList)
setLastKnownWorktrees(removeFromList)
try {
const response = await client.sendRequest('worktree.rm', {
worktree: `id:${item.worktreeId}`,
force: true
})
if (!response.ok) {
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
void fetchWorktrees()
} catch {
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
},
[client, fetchWorktrees]
)
const handleRemoveHost = useCallback(async () => {
if (!hostId) {
return
}
try {
await removeHostAndCloseClient(hostId, forgetHostClient)
leaveHost()
} catch {
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
}, [hostId, leaveHost, forgetHostClient])
const navigateFromHostList = useCallback(
(target: string) => {
if (!embedded) {
router.push(target)
return
}
if (pathname === (target.split('?')[0] ?? target)) {
return
}
if (pathname === `/h/${hostId}`) {
router.push(target)
return
}
router.replace(target)
},
[embedded, hostId, pathname, router]
)
const openWorktreeSession = useCallback(
(item: Worktree) => {
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
if (client && connState === 'connected') {
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
}
const target = `/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}`
navigateFromHostList(target)
},
[client, connState, hostId, navigateFromHostList]
)
const openFloatingWorkspace = useCallback(() => {
// Why: no worktree.activate here — the floating sentinel has no worktree
// record; session.tabs.list hydrates its host-owned tabs on open.
navigateFromHostList(floatingWorkspaceSessionPath(hostId))
}, [hostId, navigateFromHostList])
return {
handleDeleteWorktree,
handleRemoveHost,
leaveHost,
navigateFromHostList,
openFloatingWorkspace,
openNewWorktreeModal,
openWorktreeSession,
setShowNewWorktreeVisible,
togglePin
}
}
export type HostWorktreeActions = ReturnType<typeof useHostWorktreeActions>
@@ -0,0 +1,174 @@
import { useCallback, useEffect } from 'react'
import { useFocusEffect } from 'expo-router'
import { setCachedWorktrees } from '../cache/worktree-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useWorktreeResync } from '../transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
import {
clearConfirmedActiveWorktreeIdentity,
retainLiveSleptWorktreeIdentities
} from '../worktree/worktree-host-row-identity'
import { savePinnedIds } from '../storage/preferences'
import type { FetchHostRepoMetadata } from './use-host-repo-metadata'
import type { HostScreenState } from './use-host-screen-state'
export function useHostWorktreeCatalog(args: {
client: RpcClient | null
connState: ConnectionState
embedded: boolean
fetchRepoMetadata: FetchHostRepoMetadata
hostId: string | undefined
state: HostScreenState
syncViewSettingsFromDesktop: () => Promise<void>
}) {
const {
client,
connState,
embedded,
fetchRepoMetadata,
hostId,
state,
syncViewSettingsFromDesktop
} = args
const {
clientRef,
fetchWorktreesInFlightRef,
newWorktreeModalVisibleRef,
setCatalogError,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setSleptIds,
setWorktrees,
setWorktreesLoaded,
worktreeCatalogRef
} = state
const fetchWorktrees = useCallback(
async (options: { allowDuringModal?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
if (fetchWorktreesInFlightRef.current) {
return
}
fetchWorktreesInFlightRef.current = true
const requestClient = client
const requestHostId = hostId
try {
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
// surface it so a broken remote host is diagnosable instead of looking empty.
if (fetched.kind === 'request_failed') {
setCatalogError(fetched.code)
return
}
if (fetched.pending.admission.kind === 'invalid') {
setCatalogError('invalid_response')
}
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
// host truth over optimistic local edits regardless of payload size.
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
if (confirmed) {
setCatalogError(null)
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
setWorktrees((current) =>
areWorktreeListsEqual(current, confirmed) ? current : confirmed
)
setLastKnownWorktrees((current) =>
areWorktreeListsEqual(current, confirmed) ? current : confirmed
)
setWorktreesLoaded(true)
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, confirmed, { proven: true })
}
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeIdentity((pending) =>
clearConfirmedActiveWorktreeIdentity(pending, confirmed)
)
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
setPinnedIds((prev) => {
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
return prev
}
if (hostId) {
void savePinnedIds(hostId, serverPinned)
}
return serverPinned
})
}
} catch {
// Will retry on reconnect
if (clientRef.current === requestClient && hostId === requestHostId) {
setCatalogError('network_error')
}
} finally {
fetchWorktreesInFlightRef.current = false
}
},
[client, connState, hostId]
)
useFocusEffect(
useCallback(() => {
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
clientRef.current?.notifyForeground('focus')
}, [])
)
const startWorktreeRefresh = useCallback(() => {
if (!client || connState !== 'connected') {
return
}
void syncViewSettingsFromDesktop()
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
useFocusEffect(
useCallback(() => {
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
if (!embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
)
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
useEffect(() => {
if (embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
fetchWorktrees,
fetchRepoMetadata
})
return { fetchWorktrees, onRefresh, refreshing }
}
export type HostWorktreeCatalog = ReturnType<typeof useHostWorktreeCatalog>
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/index.tsx', import.meta.url), 'utf8')
const source = readFileSync(
new URL('../host-screen/use-host-worktree-actions.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -15,7 +18,7 @@ describe('mobile worktree activation', () => {
it('opens mobile sessions without foregrounding other paired clients', () => {
const openSession = sliceBetween(
'const openWorktreeSession = useCallback(',
'const handleSortChange = useCallback'
'const openFloatingWorkspace = useCallback'
)
expect(openSession).toContain("sendRequest('worktree.activate'")