refactor(mobile): drop the orphaned non-hybrid host-screen controller chain

The hybrid WebView migration routes h/[hostId] through
useHybridHostScreenController, leaving the seven pre-hybrid hooks with zero
call sites. The four view components typed their prop against the dead
controller's ReturnType, so they now take HybridHostScreenController directly.

That retype surfaced a live mismatch: the hybrid forceReconnectHost takes no
arguments, but the header and workspace list still passed hostId through the
old loose type.

Repoints the STA-5781 cross-client source pin at
use-hybrid-host-screen-settings.ts, which carries the same patch-only builder
call; verified the assertion still fails when that call is mutated.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 01:57:54 -04:00
parent 8ec4d59331
commit 6553c694f0
12 changed files with 11 additions and 1200 deletions
@@ -16,13 +16,13 @@ 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'
import type { HybridHostScreenController } from './use-hybrid-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 }) {
export function HostScreenHeader({ controller }: { controller: HybridHostScreenController }) {
const {
actions,
connState,
@@ -77,7 +77,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
onPress={() => void forceReconnectHost()}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
@@ -18,9 +18,9 @@ import {
} 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'
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
export function HostScreenOverlays({ controller }: { controller: HybridHostScreenController }) {
const {
actions,
catalog,
+2 -2
View File
@@ -4,9 +4,9 @@ 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'
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
export function HostScreenView({ controller }: { controller: HostScreenController }) {
export function HostScreenView({ controller }: { controller: HybridHostScreenController }) {
if (controller.state.error) {
return (
<View style={styles.centered}>
@@ -14,9 +14,9 @@ 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'
import type { HybridHostScreenController } from './use-hybrid-host-screen-controller'
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
export function HostWorkspaceList({ controller }: { controller: HybridHostScreenController }) {
const {
actions,
activeWorktreeScroll,
@@ -51,7 +51,7 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId)}
onRetry={() => hostId && void forceReconnectHost()}
onRepair={() => shellOperations.repairPairing()}
onRemove={() => state.setConfirmRemoveHost(true)}
/>
@@ -1,151 +0,0 @@
import { useCallback } from 'react'
import { getRepoExecutionHostId } from '../../../src/shared/execution-host'
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 {
buildHostLabelById,
buildRepoHostIdByRepoId
} from '../worktree/worktree-host-context-labels'
import type { HostScreenState } from './use-host-screen-state'
const REPO_METADATA_REFRESH_MS = 60_000
type SshTargetSummaryRow = { id: string; label: string }
async function requestResult(client: RpcClient, method: string): Promise<unknown> {
try {
const response = await client.sendRequest(method)
return response.ok ? (response as RpcSuccess).result : null
} catch {
// Best-effort: hosts that predate a method still list repos; labels degrade to host ids.
return null
}
}
function readSshTargets(result: unknown): SshTargetSummaryRow[] {
const targets = (result as { targets?: unknown } | null)?.targets
if (!Array.isArray(targets)) {
return []
}
return targets.filter(
(target): target is SshTargetSummaryRow =>
typeof target === 'object' &&
target !== null &&
typeof (target as SshTargetSummaryRow).id === 'string' &&
typeof (target as SshTargetSummaryRow).label === 'string'
)
}
function readHostPlatform(result: unknown): NodeJS.Platform | null {
const platform = (result as { platform?: unknown } | null)?.platform
return typeof platform === 'string' && platform ? (platform as NodeJS.Platform) : null
}
function readHostSettingOverrides(result: unknown): unknown {
return (result as { settings?: { hostSettingOverrides?: unknown } } | null)?.settings
?.hostSettingOverrides
}
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,
setHostLabelById,
setHostPlatform,
setRepoColorsByName,
setRepoHostIdByRepoId,
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])))
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(repoResult.repos))
// Why: rows only name their host when the list spans hosts, so a single-host
// catalog never pays for the label lookups. Counted over repos, not the id-keyed
// map: one repo id registered on two hosts is two hosts.
const hostIds = new Set(repoResult.repos.map((repo) => getRepoExecutionHostId(repo)))
if (hostIds.size > 1) {
const [sshTargets, hostSettings, hostPlatform] = await Promise.all([
requestResult(requestClient, 'ssh.listTargetSummaries'),
requestResult(requestClient, 'settings.get'),
requestResult(requestClient, 'host.platform')
])
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
setHostLabelById(
buildHostLabelById({
sshTargets: readSshTargets(sshTargets),
hostSettingOverrides: readHostSettingOverrides(hostSettings)
})
)
setHostPlatform(readHostPlatform(hostPlatform))
}
} 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>
@@ -1,191 +0,0 @@
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 { applyWorktreeHostContextLabels } from '../worktree/worktree-host-context-labels'
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'
import type { HostWorkspaceCreationOperations } from '../worktree/host-workspace-creation-operations'
import type { HostScreenShellOperations } from '../worktree/host-screen-shell-operations'
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 applyWorktreeHostContextLabels(
applyWorktreeRowDisplayState(base, state.sleptIds, state.optimisticActiveWorktreeIdentity),
{
repoHostIdByRepoId: state.repoHostIdByRepoId,
hostLabelById: state.hostLabelById,
hostPlatform: state.hostPlatform
}
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.sleptIds,
state.optimisticActiveWorktreeIdentity,
state.repoHostIdByRepoId,
state.hostLabelById,
state.hostPlatform
])
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,
worktreesLoaded: state.worktreesLoaded
})
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
}
}
type HostScreenRouter = {
push: (target: string | { pathname: string; params?: Record<string, string> }) => void
}
type HostScreenState = ReturnType<typeof useHostScreenController>['state']
type HostScreenViewState = Omit<
HostScreenState,
| 'clientRef'
| 'fetchRepoMetadataInFlightRef'
| 'fetchRepoMetadataPendingRef'
| 'worktreeCatalogRef'
>
export type HostScreenController = Omit<
ReturnType<typeof useHostScreenController>,
'router' | 'state'
> & {
router: HostScreenRouter
state: HostScreenViewState
creationOperations?: HostWorkspaceCreationOperations | null
shellOperations?: HostScreenShellOperations
actions: ReturnType<typeof useHostScreenController>['actions'] & {
sleepWorktree?: (worktreeId: string) => Promise<void>
}
}
@@ -1,96 +0,0 @@
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,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
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())
setRepoHostIdByRepoId(new Map())
setHostLabelById(new Map())
setHostPlatform(null)
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])
}
@@ -1,161 +0,0 @@
import { useRef, useState } from 'react'
import type { ExecutionHostId } from '../../../src/shared/execution-host'
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())
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
new Map()
)
const [hostLabelById, setHostLabelById] = useState<Map<ExecutionHostId, string>>(new Map())
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
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,
hostLabelById,
hostName,
hostPlatform,
lastKnownWorktrees,
newWorktreeModalRef,
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoHostIdByRepoId,
repoIconsByName,
repoIdsByName,
repoMetadataFetchedAtRef,
routeActionState,
search,
setActionTarget,
setCatalogError,
setCollapsedGroups,
setConfirmDelete,
setConfirmRemoveHost,
setError,
setFilters,
setGroupMode,
setHostLabelById,
setHostName,
setHostPlatform,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
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>
@@ -1,194 +0,0 @@
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>
@@ -1,217 +0,0 @@
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 { loadHosts } from '../transport/host-store'
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 {
const host = (await loadHosts()).find((candidate) => candidate.id === hostId)
if (!host) {
throw new Error('Host identity unavailable')
}
await removeHostAndCloseClient(hostId, host.publicKeyB64, 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>
@@ -1,179 +0,0 @@
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 { defaultHostWorkspaceOperations } from '../worktree/default-host-workspace-operations'
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({
operations: defaultHostWorkspaceOperations(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({
available: client !== null,
connState,
fetchWorktrees,
fetchRepoMetadata
})
return { fetchWorktrees, onRefresh, refreshing }
}
export type HostWorktreeCatalog = ReturnType<typeof useHostWorktreeCatalog>
@@ -181,7 +181,7 @@ function createMobileClient(authority: Authority) {
function readMobileViewSettingsHookSource(): string {
return readFileSync(
join(__dirname, '../../../../mobile/src/host-screen/use-host-view-settings.ts'),
join(__dirname, '../../../../mobile/src/host-screen/use-hybrid-host-screen-settings.ts'),
'utf-8'
)
}