diff --git a/mobile/src/host-screen/host-screen-header.tsx b/mobile/src/host-screen/host-screen-header.tsx index 8a5caab2858..462cbacf34d 100644 --- a/mobile/src/host-screen/host-screen-header.tsx +++ b/mobile/src/host-screen/host-screen-header.tsx @@ -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 ( void forceReconnectHost(hostId!)} + onPress={() => void forceReconnectHost()} hitSlop={8} > Reconnect diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index 03bedc0c895..9778b5a2e42 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -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, diff --git a/mobile/src/host-screen/host-screen-view.tsx b/mobile/src/host-screen/host-screen-view.tsx index e70f07d773a..6197baedede 100644 --- a/mobile/src/host-screen/host-screen-view.tsx +++ b/mobile/src/host-screen/host-screen-view.tsx @@ -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 ( diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx index 5caf4de9397..574575b4088 100644 --- a/mobile/src/host-screen/host-workspace-list.tsx +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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) && ( hostId && void forceReconnectHost(hostId)} + onRetry={() => hostId && void forceReconnectHost()} onRepair={() => shellOperations.repairPairing()} onRemove={() => state.setConfirmRemoveHost(true)} /> diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts deleted file mode 100644 index 198efbaf3ea..00000000000 --- a/mobile/src/host-screen/use-host-repo-metadata.ts +++ /dev/null @@ -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 { - 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 diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts deleted file mode 100644 index 1bc3ce01f4b..00000000000 --- a/mobile/src/host-screen/use-host-screen-controller.ts +++ /dev/null @@ -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(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 }) => void -} -type HostScreenState = ReturnType['state'] -type HostScreenViewState = Omit< - HostScreenState, - | 'clientRef' - | 'fetchRepoMetadataInFlightRef' - | 'fetchRepoMetadataPendingRef' - | 'worktreeCatalogRef' -> - -export type HostScreenController = Omit< - ReturnType, - 'router' | 'state' -> & { - router: HostScreenRouter - state: HostScreenViewState - creationOperations?: HostWorkspaceCreationOperations | null - shellOperations?: HostScreenShellOperations - actions: ReturnType['actions'] & { - sleepWorktree?: (worktreeId: string) => Promise - } -} diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts deleted file mode 100644 index 0a45502090f..00000000000 --- a/mobile/src/host-screen/use-host-screen-identity.ts +++ /dev/null @@ -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]) -} diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts deleted file mode 100644 index ca6bd0d85e7..00000000000 --- a/mobile/src/host-screen/use-host-screen-state.ts +++ /dev/null @@ -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(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()) - const fetchRepoMetadataPendingRef = useRef(new WeakSet()) - const repoMetadataFetchedAtRef = useRef(0) - const newWorktreeModalRef = useRef<{ open: () => void }>(null) - const newWorktreeModalVisibleRef = useRef(false) - const [worktrees, setWorktrees] = useState(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(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>(new Map()) - const [repoIconsByName, setRepoIconsByName] = useState>(new Map()) - const [hostName, setHostName] = useState('') - const [error, setError] = useState('') - const [lastKnownWorktrees, setLastKnownWorktrees] = useState(initialCache ?? []) - const [search, setSearch] = useState('') - const [showSearch, setShowSearch] = useState(false) - const [sortMode, setSortMode] = useState('recent') - const [filters, setFilters] = useState({ - filterRepoIds: new Set(), - hideSleeping: false, - hideDefaultBranch: false, - alwaysShowDefaultBranch: true - }) - const [groupMode, setGroupMode] = useState('repo') - const [workspaceStatuses, setWorkspaceStatuses] = useState( - 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>(new Map()) - // Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform. - const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState>( - new Map() - ) - const [hostLabelById, setHostLabelById] = useState>(new Map()) - const [hostPlatform, setHostPlatform] = useState(null) - const [showSortPicker, setShowSortPicker] = useState(false) - const [showGroupPicker, setShowGroupPicker] = useState(false) - const [showFilterModal, setShowFilterModal] = useState(false) - const [actionTarget, setActionTarget] = useState(null) - const [confirmDelete, setConfirmDelete] = useState(null) - const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) - const [routeActionState, setRouteActionState] = useState(() => - createInitialHostRouteActionState(action) - ) - const [sleptIds, setSleptIds] = useState>(new Set()) - const [pinnedIds, setPinnedIds] = useState>(new Set()) - const [collapsedGroups, setCollapsedGroups] = useState>(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({ - 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 diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts deleted file mode 100644 index 877aa5f3c9f..00000000000 --- a/mobile/src/host-screen/use-host-view-settings.ts +++ /dev/null @@ -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) => { - 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 diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts deleted file mode 100644 index 575595e7790..00000000000 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ /dev/null @@ -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 - forgetHostClient: ReturnType - hostId: string | undefined - pathname: string - router: ReturnType - 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 diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts deleted file mode 100644 index 4f9e45cf35a..00000000000 --- a/mobile/src/host-screen/use-host-worktree-catalog.ts +++ /dev/null @@ -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 -}) { - 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 diff --git a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx index 314977a17aa..fae6a4489a6 100644 --- a/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx +++ b/src/renderer/src/app-shell/workspace-view-cross-client-sync.test.tsx @@ -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' ) }