From 0b1d3e3d463803e2db3430f68e63735fe8fe9fc4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 04:32:18 -0700 Subject: [PATCH] Split mobile host screen --- config/max-lines-baseline.txt | 1 - mobile/.oxlintrc.json | 6 - mobile/app/h/[hostId]/index.tsx | 1416 +---------------- mobile/src/host-screen/host-screen-header.tsx | 337 ++++ .../src/host-screen/host-screen-overlays.tsx | 231 +++ mobile/src/host-screen/host-screen-view.tsx | 25 + .../src/host-screen/host-workspace-list.tsx | 199 +++ .../src/host-screen/use-host-repo-metadata.ts | 85 + .../host-screen/use-host-screen-controller.ts | 158 ++ .../host-screen/use-host-screen-identity.ts | 90 ++ .../src/host-screen/use-host-screen-state.ts | 148 ++ .../src/host-screen/use-host-view-settings.ts | 194 +++ .../host-screen/use-host-worktree-actions.ts | 212 +++ .../host-screen/use-host-worktree-catalog.ts | 174 ++ .../mobile-worktree-activation-source.test.ts | 7 +- 15 files changed, 1866 insertions(+), 1417 deletions(-) create mode 100644 mobile/src/host-screen/host-screen-header.tsx create mode 100644 mobile/src/host-screen/host-screen-overlays.tsx create mode 100644 mobile/src/host-screen/host-screen-view.tsx create mode 100644 mobile/src/host-screen/host-workspace-list.tsx create mode 100644 mobile/src/host-screen/use-host-repo-metadata.ts create mode 100644 mobile/src/host-screen/use-host-screen-controller.ts create mode 100644 mobile/src/host-screen/use-host-screen-identity.ts create mode 100644 mobile/src/host-screen/use-host-screen-state.ts create mode 100644 mobile/src/host-screen/use-host-view-settings.ts create mode 100644 mobile/src/host-screen/use-host-worktree-actions.ts create mode 100644 mobile/src/host-screen/use-host-worktree-catalog.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 9325bd1d781..528f0705f9a 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/mobile/.oxlintrc.json b/mobile/.oxlintrc.json index f845c3e9a1f..703a9dfda62 100644 --- a/mobile/.oxlintrc.json +++ b/mobile/.oxlintrc.json @@ -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": { diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index ed26d3dd5cd..7563f7c50dc 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -1,1410 +1,14 @@ -import { useState, useEffect, useCallback, useMemo, useRef } from 'react' -import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native' -import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' -import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router' -import { - Search, - X, - Pin, - List, - SlidersHorizontal, - Layers, - ChevronDown, - ChevronRight, - ChevronLeft, - Plus, - Moon, - Filter, - Check, - UserCircle, - PanelLeftClose, - SquareTerminal -} from 'lucide-react-native' -import type { RpcClient } from '../../../src/transport/rpc-client' -import { loadHosts, updateLastConnected } from '../../../src/transport/host-store' -import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle' -import { - useHostClient, - useForgetHostClient, - useForceReconnect -} from '../../../src/transport/client-context' -import { useWorktreeResync } from '../../../src/transport/use-worktree-resync' -import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh' -import { - applyWorktreeRowDisplayState, - clearConfirmedActiveWorktreeIdentity, - getWorktreeRowIdentity, - removeWorktreeRow, - retainLiveSleptWorktreeIdentities -} from '../../../src/worktree/worktree-host-row-identity' -import { - useLastConnectedAt, - useRelayRecoveryStatus, - useReconnectAttempt -} from '../../../src/transport/client-context-connection-metrics' -import { - classifyConnection, - type ConnectionVerdict -} from '../../../src/transport/connection-health' -import type { RpcSuccess } from '../../../src/transport/types' -import { StatusDot } from '../../../src/components/StatusDot' -import { NewWorktreeModalController } from '../../../src/components/NewWorktreeModalController' -import { NewWorkspaceFab, FAB_SIZE } from '../../../src/components/NewWorkspaceFab' -import { MobileRepoIcon } from '../../../src/components/MobileRepoIcon' -import { WorktreeListRow } from '../../../src/components/WorktreeListRow' -import { useNow } from '../../../src/hooks/use-now' -import { useActiveWorktreeScroll } from '../../../src/hooks/use-active-worktree-scroll' -import type { RepoIcon } from '../../../../src/shared/repo-icon' -import { PickerModal } from '../../../src/components/PickerModal' -import { ActionSheetContent } from '../../../src/components/ActionSheetModal' -import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions' -import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace' -import { ConfirmModal } from '../../../src/components/ConfirmModal' -import { BottomDrawer } from '../../../src/components/BottomDrawer' -import { useHostProtocolGates } from '../../../src/components/HostProtocolGate' -import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner' -import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink' -import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner' -import { visibleHostRouteNotice } from '../../../src/host-route-notice' -import { MobileSearchField } from '../../../src/components/MobileSearchField' import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder' -import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache' -import { setCachedRepos } from '../../../src/cache/repo-cache' -import { colors, spacing } from '../../../src/theme/mobile-theme' +import { HostScreenView } from '../../../src/host-screen/host-screen-view' +import { + type HostScreenProps, + useHostScreenController +} from '../../../src/host-screen/use-host-screen-controller' import { useResponsiveLayout } from '../../../src/layout/responsive-layout' -import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles' -import { leaveHostRoute } from '../../../src/host-route-exit' -import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences' -import { - createInitialHostRouteActionState, - hostNewWorktreeSessionRoute, - resolveHostRouteActionState, - setHostRouteNewWorktreeVisible -} from '../../../src/host-route-action-state' -import { - applyDesktopViewSettings, - buildWorkspaceViewSettingsUpdate, - type MobileGroupMode, - type MobileSortMode, - type MobileViewState, - type WorkspaceViewSettings -} from '../../../src/worktree/workspace-view-settings' -import { - getWorktreeStatus, - isWorktreePinned, - type FilterState, - type Worktree -} from '../../../src/worktree/workspace-list-sections' -import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections' -import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage' -import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot' -import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client' -import { HostWorkspaceListStates } from '../../../src/worktree/host-workspace-list-states' -import { repoColor } from '../../../src/worktree/repo-color' -import { - WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS, - WORKSPACE_SORT_OPTIONS as SORT_OPTIONS -} from '../../../src/worktree/workspace-list-picker-options' -import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types' -import type { WorkspaceStatusDefinition } from '../../../../src/shared/worktree/types' -import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses' -function isErrorVerdict(v: ConnectionVerdict): boolean { - return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed' -} - -const REPO_METADATA_REFRESH_MS = 60_000 - -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 HostScreen({ - 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() - const [initialCache] = useState(() => - hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null - ) - // 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 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 forgetHostClient = useForgetHostClient() - const forceReconnectHost = useForceReconnect() - 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) - // One tick drives every visible agent row's relative timestamp. - const now = useNow(30_000) - 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()) - const [showSortPicker, setShowSortPicker] = useState(false) - const [showGroupPicker, setShowGroupPicker] = useState(false) - const [showFilterModal, setShowFilterModal] = useState(false) - const [actionTarget, setActionTarget] = useState(null) - const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates() - const [confirmDelete, setConfirmDelete] = useState(null) - const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) - const [routeActionState, setRouteActionState] = useState(() => - createInitialHostRouteActionState(action) - ) - const [sleptIds, setSleptIds] = useState>(new Set()) - - const leaveHost = useCallback(() => { - leaveHostRoute(router) - }, [router]) - 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 - }) - - 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] - ) - - const openNewWorktreeModal = useCallback(() => { - const modal = newWorktreeModalRef.current - if (!modal) { - return - } - newWorktreeModalVisibleRef.current = true - modal.open() - }, []) - - const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action) - // Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists. - if (resolvedRouteActionState !== routeActionState) { - setRouteActionState(resolvedRouteActionState) - } - const showNewWorktree = resolvedRouteActionState.showNewWorktree - const setShowNewWorktreeVisible = useCallback((visible: boolean) => { - setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible)) - }, []) - - // 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]) - - // 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]) - - // 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]) - - 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] - ) - - const fetchWorktrees = useCallback( - async (options: { allowDuringModal?: boolean } = {}) => { - if (!client || connState !== 'connected') { - 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 - }) - - 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]) - - 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 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' ? worktrees : lastKnownWorktrees - return applyWorktreeRowDisplayState(base, sleptIds, optimisticActiveWorktreeIdentity) - }, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeIdentity]) - - 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] - ) - const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({ - displayWorktrees, - sortMode, - filters, - search, - groupMode, - pinnedIds, - repoIdsByName, - repoColorsByName, - collapsedGroups, - workspaceStatuses - }) - const existingWorktreePaths = useMemo(() => worktrees.map((w) => w.path), [worktrees]) - - const { sectionListRef, onScrollToIndexFailed } = useActiveWorktreeScroll(sections) - - const isReadOnly = connState === 'auth-failed' - - if (error) { - return ( - - {error} - - ) - } - - return ( - - - - - - - {(() => { - const headerVerdict = classifyConnection({ - state: connState, - reconnectAttempts, - lastConnectedAt, - ...relayRecovery - }) - return ( - <> - - - - {hostName || 'Host'} - - - {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 ( - void forceReconnectHost(hostId!)} - hitSlop={8} - > - Reconnect - - ) - })()} - - ) - })()} - {!embedded && floatingWorkspaceEnabled ? ( - - - - ) : null} - {embedded && onHideSidebar ? ( - - - - ) : null} - - - {/* Filter/sort/group toolbar */} - {embedded ? ( - - - 0 && styles.filterChipActive - ]} - onPress={() => setShowFilterModal(true)} - accessibilityRole="button" - accessibilityLabel={`Filter workspaces${activeFilterCount > 0 ? `, ${activeFilterCount} active` : ''}`} - > - 0 ? colors.textPrimary : colors.textSecondary} - /> - 0 && styles.filterChipTextActive - ]} - numberOfLines={1} - > - Filter{activeFilterCount > 0 ? ` ${activeFilterCount}` : ''} - - - - setShowSortPicker(true)} - accessibilityRole="button" - accessibilityLabel={`Sort by ${selectedSortLabel}`} - > - - - {selectedSortLabel} - - - - setShowGroupPicker(true)} - accessibilityRole="button" - accessibilityLabel="Group workspaces" - > - - - {groupMode === 'none' - ? 'Group' - : groupMode === 'workspaceStatus' - ? 'Status' - : groupMode === 'repo' - ? 'Repo' - : 'PR'} - - - - - - navigateFromHostList(`/h/${hostId}/accounts`)} - disabled={connState !== 'connected'} - accessibilityRole="button" - accessibilityLabel="Accounts" - > - - - - navigateFromHostList(`/h/${hostId}/tasks`)} - disabled={connState !== 'connected'} - accessibilityRole="button" - accessibilityLabel="Tasks" - > - - - - {floatingWorkspaceEnabled ? ( - - - - ) : null} - - - - - - setShowSearch((s) => !s)} - accessibilityRole="button" - accessibilityLabel={showSearch ? 'Close search' : 'Search workspaces'} - > - {showSearch ? ( - - ) : ( - - )} - - - - ) : ( - - 0 && styles.filterChipActive]} - onPress={() => setShowFilterModal(true)} - > - 0 ? colors.textPrimary : colors.textSecondary} - /> - 0 && styles.filterChipTextActive - ]} - > - Filter{activeFilterCount > 0 ? ` (${activeFilterCount})` : ''} - - - - setShowSortPicker(true)}> - - - {selectedSortLabel} - - - - setShowGroupPicker(true)}> - - - {groupMode === 'none' - ? 'Group' - : groupMode === 'workspaceStatus' - ? 'Status' - : groupMode === 'repo' - ? 'Repo' - : 'PR'} - - - - - - navigateFromHostList(`/h/${hostId}/accounts`)} - disabled={connState !== 'connected'} - > - - - - navigateFromHostList(`/h/${hostId}/tasks`)} - disabled={connState !== 'connected'} - > - - - - setShowSearch((s) => !s)}> - {showSearch ? ( - - ) : ( - - )} - - - )} - - - {/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */} - {(connState === 'auth-failed' || relayRecovery.pairingRejected) && ( - hostId && void forceReconnectHost(hostId)} - onRepair={() => router.push('/pair-scan')} - onRemove={() => setConfirmRemoveHost(true)} - /> - )} - - {connState !== 'connected' && - !relayRecovery.pairingRejected && - reconnectAttempts >= 3 && - hostId ? ( - - 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 && ( - setDismissedNotice(noticeParam ?? null)} - /> - )} - - {/* Search bar */} - {showSearch && ( - - - - )} - - - - {sections.length > 0 && ( - w.sectionListKey ?? getWorktreeRowIdentity(w)} - stickySectionHeadersEnabled={false} - // Why: keep the search IME up while tapping clear / scrolling results. - keyboardShouldPersistTaps="handled" - keyboardDismissMode="on-drag" - onScrollToIndexFailed={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 = collapsedGroups.has(section.key) - const rawSection = rawSections.find((s) => s.key === section.key) - const count = rawSection?.data.length ?? 0 - const repoSectionColor = - groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null - const repoSectionIcon = groupMode === 'repo' ? repoIconsByName.get(section.title) : null - return ( - toggleCollapsed(section.key)}> - {isCollapsed ? ( - - ) : ( - - )} - {section.icon === 'pin' && ( - - )} - {groupMode === 'repo' ? ( - - - - ) : null} - {section.title} - {count} - - ) - }} - ItemSeparatorComponent={ListSeparator} - // Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect. - refreshControl={ - - } - renderItem={({ item }) => ( - - )} - /> - )} - - {/* Floating "new workspace" button — phone only; embedded sidebars keep the toolbar +. */} - {!embedded && ( - - )} - - setShowSortPicker(false)} - /> - - setShowGroupPicker(false)} - /> - - setShowFilterModal(false)}> - - Filter - {activeFilterCount > 0 && ( - - Clear filters - - )} - - - Workspaces - - - Hide sleeping - {filters.hideSleeping && } - - - - Hide default branch - {filters.hideDefaultBranch && } - - - - {uniqueRepos.length > 1 && ( - <> - Repositories - - {uniqueRepos.map((repo, i) => ( - - {i > 0 && } - toggleRepoFilter(repo.id)}> - - - {repo.name} - - {filters.filterRepoIds.has(repo.id) && ( - - )} - - - ))} - - - )} - - - {/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */} - { - setConfirmDelete(null) - setActionTarget(null) - }} - > - {confirmDelete ? ( - - - Delete Worktree - - Delete "{confirmDelete.displayName || confirmDelete.repo}" ({confirmDelete.branch})? - - - - [ - styles.confirmBtn, - styles.confirmBtnCancel, - pressed && styles.confirmBtnPressed - ]} - onPress={() => setConfirmDelete(null)} - > - Cancel - - [ - styles.confirmBtn, - styles.confirmBtnDestructive, - pressed && styles.confirmBtnPressed - ]} - onPress={() => { - if (confirmDelete) { - void handleDeleteWorktree(confirmDelete) - } - setConfirmDelete(null) - setActionTarget(null) - }} - > - Delete - - - - ) : ( - setActionTarget(null) - }), - { - label: 'Sleep', - icon: Moon, - onPress: () => { - if (client) { - setSleptIds((prev) => - new Set(prev).add(getWorktreeRowIdentity(actionTarget)) - ) - void client - .sendRequest('worktree.sleep', { - worktree: `id:${actionTarget.worktreeId}` - }) - .catch(() => null) - } - setActionTarget(null) - } - }, - { - label: isWorktreePinned(actionTarget, pinnedIds) ? 'Unpin' : 'Pin', - onPress: () => { - togglePin(actionTarget.worktreeId) - setActionTarget(null) - } - }, - { - label: 'Delete', - destructive: true, - onPress: () => setConfirmDelete(actionTarget) - } - ] - : [] - } - /> - )} - - - {/* Host remove confirmation */} - void handleRemoveHost()} - onCancel={() => setConfirmRemoveHost(false)} - /> - - { - newWorktreeModalVisibleRef.current = visible - }} - onCreated={(worktreeId, worktreeName) => { - void fetchWorktrees({ allowDuringModal: true }) - navigateFromHostList(hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName)) - }} - onRouteVisibleChange={setShowNewWorktreeVisible} - /> - - ) +export function HostScreen(props: HostScreenProps = {}) { + const controller = useHostScreenController(props) + return } // On wide layouts the sidebar hosts the list, so this route is just the empty detail pane. @@ -1415,7 +19,3 @@ export default function HostWorktreeRoute() { } return } - -function ListSeparator() { - return -} diff --git a/mobile/src/host-screen/host-screen-header.tsx b/mobile/src/host-screen/host-screen-header.tsx new file mode 100644 index 00000000000..8a5caab2858 --- /dev/null +++ b/mobile/src/host-screen/host-screen-header.tsx @@ -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 ( + + + + + + {(() => { + const headerVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt, + ...relayRecovery + }) + return ( + <> + + + + {state.hostName || 'Host'} + + + {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 ( + void forceReconnectHost(hostId!)} + hitSlop={8} + > + Reconnect + + ) + })()} + + ) + })()} + {!embedded && floatingWorkspaceEnabled ? ( + + + + ) : null} + {embedded && onHideSidebar ? ( + + + + ) : null} + + + {/* Filter/sort/group toolbar */} + {embedded ? ( + + + 0 && styles.filterChipActive + ]} + onPress={() => state.setShowFilterModal(true)} + accessibilityRole="button" + accessibilityLabel={`Filter workspaces${settings.activeFilterCount > 0 ? `, ${settings.activeFilterCount} active` : ''}`} + > + 0 ? colors.textPrimary : colors.textSecondary} + /> + 0 && styles.filterChipTextActive + ]} + numberOfLines={1} + > + Filter{settings.activeFilterCount > 0 ? ` ${settings.activeFilterCount}` : ''} + + + + state.setShowSortPicker(true)} + accessibilityRole="button" + accessibilityLabel={`Sort by ${settings.selectedSortLabel}`} + > + + + {settings.selectedSortLabel} + + + + state.setShowGroupPicker(true)} + accessibilityRole="button" + accessibilityLabel="Group workspaces" + > + + + {state.groupMode === 'none' + ? 'Group' + : state.groupMode === 'workspaceStatus' + ? 'Status' + : state.groupMode === 'repo' + ? 'Repo' + : 'PR'} + + + + + + actions.navigateFromHostList(`/h/${hostId}/accounts`)} + disabled={connState !== 'connected'} + accessibilityRole="button" + accessibilityLabel="Accounts" + > + + + + actions.navigateFromHostList(`/h/${hostId}/tasks`)} + disabled={connState !== 'connected'} + accessibilityRole="button" + accessibilityLabel="Tasks" + > + + + + {floatingWorkspaceEnabled ? ( + + + + ) : null} + + + + + + state.setShowSearch((s) => !s)} + accessibilityRole="button" + accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'} + > + {state.showSearch ? ( + + ) : ( + + )} + + + + ) : ( + + 0 && styles.filterChipActive]} + onPress={() => state.setShowFilterModal(true)} + > + 0 ? colors.textPrimary : colors.textSecondary} + /> + 0 && styles.filterChipTextActive + ]} + > + Filter{settings.activeFilterCount > 0 ? ` (${settings.activeFilterCount})` : ''} + + + + state.setShowSortPicker(true)}> + + + {settings.selectedSortLabel} + + + + state.setShowGroupPicker(true)}> + + + {state.groupMode === 'none' + ? 'Group' + : state.groupMode === 'workspaceStatus' + ? 'Status' + : state.groupMode === 'repo' + ? 'Repo' + : 'PR'} + + + + + + actions.navigateFromHostList(`/h/${hostId}/accounts`)} + disabled={connState !== 'connected'} + > + + + + actions.navigateFromHostList(`/h/${hostId}/tasks`)} + disabled={connState !== 'connected'} + > + + + + state.setShowSearch((s) => !s)}> + {state.showSearch ? ( + + ) : ( + + )} + + + )} + + ) +} diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx new file mode 100644 index 00000000000..07cbbd58fc5 --- /dev/null +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -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 ( + <> + state.setShowSortPicker(false)} + /> + + state.setShowGroupPicker(false)} + /> + + state.setShowFilterModal(false)}> + + Filter + {settings.activeFilterCount > 0 && ( + + Clear filters + + )} + + + Workspaces + + + Hide sleeping + {state.filters.hideSleeping && } + + + + Hide default branch + {state.filters.hideDefaultBranch && } + + + + {controller.sectionsResult.uniqueRepos.length > 1 && ( + <> + Repositories + + {controller.sectionsResult.uniqueRepos.map((repo, i) => ( + + {i > 0 && } + settings.toggleRepoFilter(repo.id)} + > + + + {repo.name} + + {state.filters.filterRepoIds.has(repo.id) && ( + + )} + + + ))} + + + )} + + + {/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */} + { + state.setConfirmDelete(null) + state.setActionTarget(null) + }} + > + {state.confirmDelete ? ( + + + Delete Worktree + + Delete "{state.confirmDelete.displayName || state.confirmDelete.repo}" ( + {state.confirmDelete.branch})? + + + + [ + styles.confirmBtn, + styles.confirmBtnCancel, + pressed && styles.confirmBtnPressed + ]} + onPress={() => state.setConfirmDelete(null)} + > + Cancel + + [ + styles.confirmBtn, + styles.confirmBtnDestructive, + pressed && styles.confirmBtnPressed + ]} + onPress={() => { + if (state.confirmDelete) { + void actions.handleDeleteWorktree(state.confirmDelete) + } + state.setConfirmDelete(null) + state.setActionTarget(null) + }} + > + Delete + + + + ) : ( + 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) + } + ] + : [] + } + /> + )} + + + {/* Host remove confirmation */} + void actions.handleRemoveHost()} + onCancel={() => state.setConfirmRemoveHost(false)} + /> + + { + state.newWorktreeModalVisibleRef.current = visible + }} + onCreated={(worktreeId, worktreeName) => { + void catalog.fetchWorktrees({ allowDuringModal: true }) + actions.navigateFromHostList( + hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName) + ) + }} + onRouteVisibleChange={actions.setShowNewWorktreeVisible} + /> + + ) +} diff --git a/mobile/src/host-screen/host-screen-view.tsx b/mobile/src/host-screen/host-screen-view.tsx new file mode 100644 index 00000000000..e70f07d773a --- /dev/null +++ b/mobile/src/host-screen/host-screen-view.tsx @@ -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 ( + + {controller.state.error} + + ) + } + + return ( + + + + + + ) +} diff --git a/mobile/src/host-screen/host-workspace-list.tsx b/mobile/src/host-screen/host-workspace-list.tsx new file mode 100644 index 00000000000..13f11780e1c --- /dev/null +++ b/mobile/src/host-screen/host-workspace-list.tsx @@ -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) && ( + hostId && void forceReconnectHost(hostId)} + onRepair={() => router.push('/pair-scan')} + onRemove={() => state.setConfirmRemoveHost(true)} + /> + )} + + {connState !== 'connected' && + !relayRecovery.pairingRejected && + reconnectAttempts >= 3 && + hostId ? ( + + 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 && ( + setDismissedNotice(noticeParam ?? null)} + /> + )} + + {/* Search bar */} + {state.showSearch && ( + + + + )} + + + + {sections.length > 0 && ( + 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 ( + settings.toggleCollapsed(section.key)} + > + {isCollapsed ? ( + + ) : ( + + )} + {section.icon === 'pin' && ( + + )} + {state.groupMode === 'repo' ? ( + + + + ) : null} + {section.title} + {count} + + ) + }} + ItemSeparatorComponent={ListSeparator} + // Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect. + refreshControl={ + + } + renderItem={({ item }) => ( + + )} + /> + )} + + {/* Floating "new workspace" button — phone only; embedded sidebars keep the toolbar +. */} + {!embedded && ( + + )} + + ) +} + +function ListSeparator() { + return +} diff --git a/mobile/src/host-screen/use-host-repo-metadata.ts b/mobile/src/host-screen/use-host-repo-metadata.ts new file mode 100644 index 00000000000..a5367a971ad --- /dev/null +++ b/mobile/src/host-screen/use-host-repo-metadata.ts @@ -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 diff --git a/mobile/src/host-screen/use-host-screen-controller.ts b/mobile/src/host-screen/use-host-screen-controller.ts new file mode 100644 index 00000000000..347fd36a72e --- /dev/null +++ b/mobile/src/host-screen/use-host-screen-controller.ts @@ -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(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 diff --git a/mobile/src/host-screen/use-host-screen-identity.ts b/mobile/src/host-screen/use-host-screen-identity.ts new file mode 100644 index 00000000000..c0b09f61714 --- /dev/null +++ b/mobile/src/host-screen/use-host-screen-identity.ts @@ -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]) +} diff --git a/mobile/src/host-screen/use-host-screen-state.ts b/mobile/src/host-screen/use-host-screen-state.ts new file mode 100644 index 00000000000..b27e15ffb91 --- /dev/null +++ b/mobile/src/host-screen/use-host-screen-state.ts @@ -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(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()) + 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, + 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 diff --git a/mobile/src/host-screen/use-host-view-settings.ts b/mobile/src/host-screen/use-host-view-settings.ts new file mode 100644 index 00000000000..877aa5f3c9f --- /dev/null +++ b/mobile/src/host-screen/use-host-view-settings.ts @@ -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) => { + 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 new file mode 100644 index 00000000000..21f49a4f306 --- /dev/null +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -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 + 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 { + 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 diff --git a/mobile/src/host-screen/use-host-worktree-catalog.ts b/mobile/src/host-screen/use-host-worktree-catalog.ts new file mode 100644 index 00000000000..9acfed854fa --- /dev/null +++ b/mobile/src/host-screen/use-host-worktree-catalog.ts @@ -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 +}) { + 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 diff --git a/mobile/src/worktree/mobile-worktree-activation-source.test.ts b/mobile/src/worktree/mobile-worktree-activation-source.test.ts index 0b06f21f70b..6d6497de67f 100644 --- a/mobile/src/worktree/mobile-worktree-activation-source.test.ts +++ b/mobile/src/worktree/mobile-worktree-activation-source.test.ts @@ -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'")