import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' import { useRouter, useFocusEffect } from 'expo-router' import { QrCode, Settings, ChevronRight, Terminal, ListTodo } from 'lucide-react-native' import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons' import { type AccountsSnapshot, type ProviderKey, decodeAccountsSnapshot, getActiveProviderRateLimits, getUsageBarState, hasActiveProviderUsage, hasRenderableUsage, UsageBar } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHostCatalog } from '../src/transport/host-store' import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection' import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' import type { RpcClient } from '../src/transport/rpc-client' import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context' import { useAllHostClients } from '../src/transport/use-all-host-clients' import { resolveHomeHostConnectionState, selectHomeAutoConnectHostIds } from '../src/transport/home-host-auto-connect' import { classifyConnection } from '../src/transport/connection-health' import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications' import { loadMobileOnboardingSteps, mobileOnboardingDestination } from '../src/onboarding/mobile-onboarding-plan' import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types' import { triggerMediumImpact } from '../src/platform/haptics' import { OrcaLogo } from '../src/components/OrcaLogo' import { MobileHostCard } from '../src/components/MobileHostCard' import { MobileHomeQuickActions } from '../src/components/MobileHomeQuickActions' import { TaskProviderLogo } from '../src/components/TaskProviderLogo' import { ActionSheetModal } from '../src/components/ActionSheetModal' import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions' import { ConfirmModal } from '../src/components/ConfirmModal' import { setCachedWorktrees, getCachedWorktrees, getProvenCachedWorktrees } from '../src/cache/worktree-cache' import { LAST_VISITED_WORKTREE_STORAGE_KEY, readLastVisitedWorktreeRecord } from '../src/worktree/last-visited-worktree-repo' import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache' import { colors, spacing, radii } from '../src/theme/mobile-theme' import { filterAvailableTaskProviders, normalizeVisibleTaskProviders, type TaskProvider } from '../src/tasks/mobile-task-providers' import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks' import { useResponsiveLayout } from '../src/layout/responsive-layout' import { useOpenMobileSession } from '../src/session/use-open-mobile-session' import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts' import { isResumeTargetConfirmedMissing, selectHomeResumeCard, type HomeResumeCard } from '../src/worktree/home-resume-card' import { hostRouteWithNotice } from '../src/host-route-notice' import { hostNewWorktreeRoute } from '../src/host-route-action-state' import { hostEndpointLabel } from '../src/transport/host-endpoint-label' type HomeTaskSettings = { visibleTaskProviders?: unknown } type HomePreflightStatus = { glab?: { installed?: boolean } } type HomeLinearStatus = { connected?: boolean } const TASK_PROVIDER_LABELS: Record = { github: 'GitHub', gitlab: 'GitLab', linear: 'Linear' } function formatDuration(ms: number): string { const totalMinutes = Math.floor(ms / 60_000) const totalHours = Math.floor(totalMinutes / 60) const days = Math.floor(totalHours / 24) const hours = totalHours % 24 if (days > 0) { return `${days}d ${hours}h` } const minutes = totalMinutes % 60 if (totalHours > 0) { return `${totalHours}h ${minutes}m` } return `${totalMinutes}m` } // Why: stable per-instance RpcClient identity so wireUp's dep key changes when forceReconnect swaps the client, re-attaching listeners. const clientIdentities = new WeakMap() let nextClientIdentity = 1 function clientKey(client: RpcClient): number { let id = clientIdentities.get(client) if (id == null) { id = nextClientIdentity++ clientIdentities.set(client, id) } return id } function fetchStats( client: RpcClient, hostId: string, setStats: ( updater: (prev: Record) => Record ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'stats.summary') .then((response) => { if (disposed()) { return } if (response.ok) { // Keyed by host: the header totals every desktop instead of showing whoever replied last. setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) } }) .catch(() => {}) } function fetchAccountsSnapshot( client: RpcClient, hostId: string, setSnapshots: ( updater: (prev: Record) => Record ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'accounts.list') .then((response) => { if (disposed()) { return } if (response.ok) { const snapshot = decodeAccountsSnapshot(response.result) setSnapshots((prev) => ({ ...prev, [hostId]: snapshot })) } }) .catch(() => {}) } function fetchTaskProviders( client: RpcClient, hostId: string, setProviders: ( updater: (prev: Record) => Record ) => void, disposed: () => boolean ) { Promise.all([ sendSingleFlightRequest(client, hostId, 'settings.get'), sendSingleFlightRequest(client, hostId, 'preflight.check'), sendSingleFlightRequest(client, hostId, 'linear.status') ]) .then(([settingsResponse, preflightResponse, linearResponse]) => { if (disposed()) { return } const settings = settingsResponse.ok ? (((settingsResponse.result as { settings?: HomeTaskSettings }).settings ?? {}) as HomeTaskSettings) : {} const preflight = preflightResponse.ok ? (preflightResponse.result as HomePreflightStatus) : null const linear = linearResponse.ok ? (linearResponse.result as HomeLinearStatus) : null const providers = filterAvailableTaskProviders( normalizeVisibleTaskProviders(settings.visibleTaskProviders), { gitlabInstalled: preflight?.glab?.installed === true, linearConnected: linear?.connected === true } ) setProviders((prev) => ({ ...prev, [hostId]: providers })) }) .catch(() => { if (disposed()) { return } setProviders((prev) => (prev[hostId] ? prev : { ...prev, [hostId]: ['github'] })) }) } // Why: hash repo name to a stable color, matching the host detail page's dots. const REPO_COLORS = ['#8b5cf6', '#3b82f6', '#22c55e', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4'] function repoColor(name: string): string { let hash = 0 for (let i = 0; i < name.length; i++) { hash = (hash * 31 + name.charCodeAt(i)) | 0 } return REPO_COLORS[Math.abs(hash) % REPO_COLORS.length] } export default function HomeScreen() { const router = useRouter() const openMobileHostEdit = useOpenMobileHostEdit() const openMobileTasks = useOpenMobileTasks() const openMobileSession = useOpenMobileSession() const openMobileAccounts = useOpenMobileAccounts() const insets = useSafeAreaInsets() // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. const { isWideLayout, contentMaxWidth } = useResponsiveLayout() const [hostCatalog, setHostCatalog] = useState([]) const [actionTarget, setActionTarget] = useState(null) const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null) const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) const [statsByHost, setStatsByHost] = useState>({}) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>( null ) // Why: focus can fire repeatedly while an async gate is pending; one probe per // mount avoids duplicate storage/permission reads and competing navigation. const onboardingOptInCheckedRef = useRef(false) // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog]) const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts]) const allClients = useAllHostClients(hostIds, { autoConnectHostIds, closeUnusedOnRelease: true }) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), [allClients] ) const closeHostClient = useCloseHost() const forceReconnectHost = useForceReconnect() const primeHosts = usePrimeHosts() // Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start. useEffect(() => { if (hosts.length > 0) { primeHosts(hosts) } }, [hosts, primeHosts]) const allClientsRef = useRef>([]) // Why: keep the focus callback stable (no refetch per render) while still exposing the latest host clients. allClientsRef.current = allClients.map((entry) => ({ hostId: entry.hostId, client: entry.client })) // Why: hydrate from a persisted snapshot on cold-start so Resume + Account cards paint immediately instead of flashing empty. const hydratedRef = useRef(false) useEffect(() => { if (hydratedRef.current) { return } hydratedRef.current = true let cancelled = false void loadHomeSnapshot().then((snap) => { if (cancelled || !snap) { return } setWorktreeInfo((prev) => (Object.keys(prev).length > 0 ? prev : snap.worktreeInfo)) setAccountsByHost((prev) => (Object.keys(prev).length > 0 ? prev : snap.accountsByHost)) for (const [hostId, info] of Object.entries(snap.worktreeInfo)) { const wt = info.lastActiveWorktree if (wt) { // Why: seed the in-memory cache so resumeWorktree's lastVisited fast-path finds the worktree object. setCachedWorktrees(hostId, [wt]) } } }) return () => { cancelled = true } }, []) // Why: persist the merged snapshot on each update so the next cold-start has fresh seed data (cache debounces writes). useEffect(() => { if (Object.keys(worktreeInfo).length === 0 && Object.keys(accountsByHost).length === 0) { return } saveHomeSnapshot({ worktreeInfo, accountsByHost, savedAt: Date.now() }) }, [worktreeInfo, accountsByHost]) useFocusEffect( useCallback(() => { let stale = false void loadHostCatalog().then(async (catalog) => { if (stale) { return } setHostCatalog(catalog) if (catalog.length === 0 || onboardingOptInCheckedRef.current) { return } onboardingOptInCheckedRef.current = true const onboardingSteps = await loadMobileOnboardingSteps() if (stale) { return } if (onboardingSteps.length > 0) { router.replace(mobileOnboardingDestination(onboardingSteps)) } }) void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => { if (stale) { return } // Why the validating reader: this record becomes the Resume card's navigation target, // so a malformed or older-shaped payload must read as no history, not a broken route. setLastVisited(readLastVisitedWorktreeRecord(raw)) }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } } return () => { stale = true } }, [router]) ) const sortedHosts = useMemo( () => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected), [hosts] ) const sortedHostCatalog = useMemo( () => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected), [hostCatalog] ) // Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working. useEffect(() => { setHostAttempts((prev) => { const next: Record = { ...prev } let changed = false for (const entry of allClients) { const a = entry.client.getReconnectAttempt() if (next[entry.hostId] !== a) { next[entry.hostId] = a changed = true } } return changed ? next : prev }) setHostLastConnected((prev) => { const next: Record = { ...prev } let changed = false for (const entry of allClients) { const t = entry.client.getLastConnectedAt() if (next[entry.hostId] !== t) { next[entry.hostId] = t changed = true } } return changed ? next : prev }) setHostStates((prev) => { const next: Record = { ...prev } let changed = false const liveIds = new Set(allClients.map((e) => e.hostId)) for (const entry of allClients) { if (next[entry.hostId] !== entry.state) { next[entry.hostId] = entry.state changed = true } } // Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'. for (const host of hostCatalog) { if (liveIds.has(host.id)) { continue } if (host.credentialStatus === 'missing') { if (next[host.id] !== 'auth-failed') { next[host.id] = 'auth-failed' changed = true } continue } if (host.credentialStatus === 'temporarily-unavailable') { if (next[host.id] !== 'disconnected') { next[host.id] = 'disconnected' changed = true } continue } const prevState = next[host.id] if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') { next[host.id] = 'disconnected' changed = true } } // Drop entries for hosts we no longer track at all. for (const id of Object.keys(next)) { if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) { delete next[id] changed = true } } return changed ? next : prev }) }, [allClients, hostCatalog]) // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the // returned disposer owns every handle allocated here. const wireHostSubscriptions = (entry: { hostId: string client: RpcClient state: ConnectionState }) => { let unsubNotif: (() => void) | null = null let unsubAccounts: (() => void) | null = null const refetchGate = createHostConnectRefetchGate() const wireUp = (state: ConnectionState) => { const reconnected = refetchGate.observe(state) if (state === 'connected') { if (!unsubNotif) { unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) } if (!unsubAccounts) { unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { if (!payload || typeof payload !== 'object') { return } const evt = payload as { type?: string; snapshot?: unknown } if (evt.type === 'ready' || evt.type === 'snapshot') { try { const snapshot = decodeAccountsSnapshot(evt.snapshot) setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) } catch { // Keep the last proven snapshot; malformed remote data must // not enter render state or crash the home host cards. } } }) } // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host // snapshot on every reconnect — a one-shot latch left the card on stale data forever. if (reconnected) { fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) } } else { if (unsubNotif) { unsubNotif() unsubNotif = null } if (unsubAccounts) { unsubAccounts() unsubAccounts = null } } } wireUp(entry.state) const unsubState = entry.client.onStateChange(wireUp) return () => { unsubState() unsubNotif?.() unsubAccounts?.() } } // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. useEffect(() => { const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) return () => { for (const c of cleanups) { c() } } // Why: key on host-id set + each client's identity so resubs fire when forceReconnect swaps a host's client, not on every render. }, [ allClients .map((e) => `${e.hostId}:${clientKey(e.client)}`) .sort() .join(',') ]) // Why: the card renders from cached/snapshot data the moment a candidate exists — see // selectHomeResumeCard for why its slot must not wait for the host to connect. const resumeCard = useMemo( () => selectHomeResumeCard({ hosts: sortedHosts, hostStates, worktreeInfo, lastVisited, cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null }), [sortedHosts, hostStates, worktreeInfo, lastVisited] ) // Why: the card is drawn from a snapshot that can name a workspace the desktop has since // deleted. When the host has proven otherwise, open its workspace list rather than a session // screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes // through and the session screen bounces once the host answers (F7). const openResume = useCallback( (card: HomeResumeCard) => { if ( isResumeTargetConfirmedMissing( card, getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null ) ) { router.push(hostRouteWithNotice(card.hostId, 'worktree-missing')) return } openMobileSession({ hostId: card.hostId, worktreeId: card.worktree.worktreeId, name: card.worktree.displayName || card.worktree.repo }) }, [openMobileSession, router] ) // Why: only show Account usage for connected hosts; stale cached usage would imply live data. const accountsHosts = useMemo(() => { const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = [] for (const host of sortedHosts) { if (hostStates[host.id] !== 'connected') { continue } const snap = accountsByHost[host.id] if (!snap) { continue } // Why: also show hosts whose only usage is the system-default login, else those users see no usage section. if (hasRenderableUsage(snap, 'claude') || hasRenderableUsage(snap, 'codex')) { items.push({ host, snapshot: snap }) } } return items }, [sortedHosts, hostStates, accountsByHost]) const connectedHosts = useMemo( () => sortedHosts.filter((host) => hostStates[host.id] === 'connected'), [sortedHosts, hostStates] ) const primaryConnectedHost = connectedHosts[0] ?? null const primaryTaskProviders = primaryConnectedHost ? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github']) : [] const openTasks = useCallback( (provider?: TaskProvider) => { if (!primaryConnectedHost) { return } openMobileTasks(primaryConnectedHost.id, provider) }, [openMobileTasks, primaryConnectedHost] ) const renderTaskHomeCard = () => ( [ styles.taskHomeCard, !primaryConnectedHost && styles.cardDisabled, pressed && styles.hostCardPressed ]} onPress={() => { openTasks() }} > Tasks {primaryTaskProviders.length > 0 ? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ') : 'No task sources connected'} TASK_PROVIDER_LABELS[provider]) .join(', ')} > {primaryTaskProviders.map((provider) => ( [ styles.taskHomeProviderButton, pressed && styles.taskHomeProviderButtonPressed ]} onPress={(event) => { event.stopPropagation() openTasks(provider) }} > ))} ) async function handleRemove() { if (!confirmRemove) { return } const hostToRemove = confirmRemove try { await removeHostAndCloseClient(hostToRemove.id, closeHostClient) setConfirmRemove(null) setHostCatalog(await loadHostCatalog()) } catch { // Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent. setConfirmRemove(hostToRemove) Alert.alert('Could not remove host', 'Please try again.') } } return ( {/* ─── Top bar ─── */} Orca [styles.iconButton, pressed && styles.iconButtonPressed]} onPress={() => router.push('/settings')} > {hostCatalog.length === 0 ? ( /* ─── Empty state: onboarding ─── */ Connect your desktop Pair with Orca on your computer to check on your agents, jump into any terminal, and drive work from your phone. router.push('/pair-scan')}> Pair Desktop How it works {ONBOARDING_STEPS.map((step, i) => ( 0 && styles.stepRowBorder]}> {i + 1} {step.title} {step.desc} ))} ) : ( /* ─── Populated state ─── */ h.id} // Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator. contentContainerStyle={[ styles.list, { paddingBottom: spacing.xl + insets.bottom }, isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' } ]} ListHeaderComponent={ Welcome back {stats && ( {stats.totalAgentsSpawned.toLocaleString()} Agents spawned {formatDuration(stats.totalAgentTimeMs)} Agent time {stats.totalPRsCreated.toLocaleString()} PRs created )} Desktops } ItemSeparatorComponent={CardGap} renderItem={({ item }) => { const state = resolveHomeHostConnectionState( item.id, hostStates[item.id], autoConnectHostIds ) const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null const verdict = classifyConnection({ state, reconnectAttempts: attempts, lastConnectedAt, endpoint: item.endpoint }) return ( { if (item.credentialStatus === 'missing') { router.push('/pair-scan') } else if (item.credentialStatus === 'temporarily-unavailable') { void loadHostCatalog() .then(setHostCatalog) .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) } else { router.push(`/h/${item.id}`) } }} onLongPress={() => { triggerMediumImpact() if (item.profile) { setActionTarget(item.profile) } else { setConfirmRemove(item) } }} onOpenActions={() => { if (item.profile) { setActionTarget(item.profile) } else { setConfirmRemove(item) } }} /> ) }} ListFooterComponent={ {/* ─── Resume card ─── */} {resumeCard ? ( <> Resume [ styles.resumeCard, !resumeCard.actionable && styles.cardDisabled, pressed && styles.hostCardPressed ]} onPress={() => openResume(resumeCard)} > {resumeCard.worktree.displayName} {resumeCard.worktree.repo} {' · '} {resumeCard.worktree.branch} ) : null} Tasks {renderTaskHomeCard()} {/* ─── Quick actions ─── */} router.push('/pair-scan')} onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))} /> {/* ─── Account usage ─── */} {accountsHosts.length > 0 ? ( <> Account usage {accountsHosts.map(({ host, snapshot }) => { const claudeActiveId = snapshot.claude.activeAccountId const claudeActive = snapshot.claude.accounts.find((a) => a.id === claudeActiveId) ?? null const codexActiveId = snapshot.codex.activeAccountId const codexActive = snapshot.codex.accounts.find((a) => a.id === codexActiveId) ?? null const showHostName = accountsHosts.length > 1 return ( [ styles.accountsCard, pressed && styles.hostCardPressed ]} onPress={() => openMobileAccounts(host.id)} > {showHostName ? ( {host.name} ) : null} {(['claude', 'codex'] as ProviderKey[]).map((provider) => { const active = provider === 'claude' ? claudeActive : codexActive const accounts = provider === 'claude' ? snapshot.claude.accounts : snapshot.codex.accounts const limits = getActiveProviderRateLimits(snapshot, provider) // Why: with no managed accounts, still render the row when the active target has live usage data. if (accounts.length === 0 && !hasActiveProviderUsage(limits)) { return null } const sessionBar = getUsageBarState(limits, 'session') const weeklyBar = getUsageBarState(limits, 'weekly') return ( {provider === 'claude' ? ( ) : ( )} {active?.email ?? 'System default'} ) })} ) })} ) : null} } /> )} {/* ─── Action sheets (shared by both states) ─── */} setActionTarget(null), onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: closeHostClient, onEdit: openMobileHostEdit, onRemove: (host) => setConfirmRemove(host) })} onClose={() => setActionTarget(null)} /> void handleRemove()} onCancel={() => setConfirmRemove(null)} /> ) } function CardGap() { return } const ONBOARDING_STEPS = [ { title: 'Open Orca desktop', desc: 'Go to Settings → Mobile and generate a pairing QR code.' }, { title: 'Scan the code', desc: 'Tap the button above to open the scanner. Point at the QR code on your screen.' }, { title: "You're connected", desc: 'Your desktop will appear here. Everything is encrypted end-to-end.' } ] const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgBase }, /* ─── Top bar ─── */ topBar: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.md }, brandLockup: { flexDirection: 'row', alignItems: 'center', minWidth: 0 }, logoMark: { marginRight: spacing.sm }, brandName: { color: colors.textPrimary, fontSize: 17, fontWeight: '700' }, iconButton: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center' }, iconButtonPressed: { backgroundColor: colors.bgRaised }, /* ─── Hero / greeting ─── */ hero: { paddingTop: spacing.xs, paddingBottom: spacing.md }, heroTitle: { color: colors.textPrimary, fontSize: 24, fontWeight: '800', letterSpacing: -0.3 }, /* ─── Stat cards ─── */ statsRow: { flexDirection: 'row', gap: 10, marginBottom: spacing.lg }, statCard: { flex: 1, backgroundColor: 'rgba(26,26,26,0.6)', borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: 10, paddingVertical: 8, paddingHorizontal: spacing.md }, statValue: { color: colors.textPrimary, fontSize: 18, fontWeight: '700', letterSpacing: -0.3 }, statLabel: { color: colors.textMuted, fontSize: 11, fontWeight: '500', marginTop: 2 }, /* ─── Section heading ─── */ sectionHeading: { fontSize: 11, fontWeight: '600', color: colors.textMuted, textTransform: 'uppercase', letterSpacing: 0.6, marginBottom: spacing.sm, paddingHorizontal: spacing.xs }, sectionHeadingTightTop: { marginTop: spacing.lg }, /* ─── List ─── */ list: { paddingHorizontal: spacing.lg, paddingBottom: spacing.xl }, cardGap: { height: spacing.sm }, /* ─── Host cards ─── */ hostCardPressed: { backgroundColor: colors.bgRaised }, /* ─── Resume card ─── */ resumeCard: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.bgPanel, borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, paddingLeft: spacing.md, paddingRight: spacing.md, paddingVertical: 12 }, resumeIcon: { width: 46, height: 46, borderRadius: 13, backgroundColor: colors.bgRaised, alignItems: 'center', justifyContent: 'center', marginRight: 14 }, resumeMain: { flex: 1, minWidth: 0 }, resumeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, resumeSub: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3 }, repoDot: { width: 7, height: 7, borderRadius: 3.5 }, resumeSubText: { fontSize: 12, color: colors.textSecondary, flex: 1 }, /* ─── Tasks card ─── */ taskHomeCard: { flexDirection: 'row', alignItems: 'center', backgroundColor: colors.bgPanel, borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, minHeight: 72, paddingLeft: spacing.md, paddingRight: spacing.md, paddingVertical: 12 }, cardDisabled: { opacity: 0.45 }, taskHomeIcon: { width: 46, height: 46, borderRadius: 13, backgroundColor: colors.bgRaised, alignItems: 'center', justifyContent: 'center', marginRight: 14 }, taskHomeMain: { flex: 1, minWidth: 0 }, taskHomeTitle: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, taskHomeSubtitle: { fontSize: 12, color: colors.textSecondary, marginTop: 3 }, taskHomeTrailing: { flexDirection: 'row', alignItems: 'center', flexShrink: 0, marginLeft: spacing.sm }, taskHomeProviderRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 2 }, taskHomeProviderButton: { width: 34, height: 34, alignItems: 'center', justifyContent: 'center', borderRadius: radii.button }, taskHomeProviderButtonPressed: { backgroundColor: colors.bgRaised }, /* ─── Account usage ─── */ accountsCard: { backgroundColor: colors.bgPanel, borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, paddingHorizontal: spacing.md, paddingVertical: spacing.sm + 2, gap: spacing.sm, marginBottom: spacing.sm }, accountsHostLabel: { fontSize: 11, color: colors.textMuted, fontWeight: '500', textTransform: 'uppercase', letterSpacing: 0.4 }, accountsRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm + 2 }, accountsIcon: { width: 32, height: 32, borderRadius: 9, backgroundColor: colors.bgRaised, alignItems: 'center', justifyContent: 'center' }, accountsInfo: { flex: 1, minWidth: 0, gap: 2 }, accountsEmail: { fontSize: 13, fontWeight: '600', color: colors.textPrimary }, accountsBars: { flexDirection: 'row', gap: spacing.md, marginTop: 4 }, /* ─── Empty state ─── */ emptyContainer: { flex: 1 }, emptyGreeting: { paddingHorizontal: spacing.lg, paddingTop: spacing.md, paddingBottom: spacing.sm }, emptyHero: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 32, paddingBottom: 40 }, emptyTitle: { fontSize: 22, fontWeight: '700', color: colors.textPrimary, textAlign: 'center', marginBottom: 10 }, emptyBody: { fontSize: 15, color: colors.textSecondary, textAlign: 'center', lineHeight: 22, marginBottom: 32 }, primaryButton: { flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: colors.textPrimary, paddingHorizontal: 28, paddingVertical: 14, borderRadius: radii.card }, primaryButtonText: { color: colors.bgBase, fontSize: 15, fontWeight: '700' }, /* ─── Onboarding steps ─── */ stepsSection: { paddingHorizontal: spacing.xl }, stepRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 14, paddingVertical: spacing.lg }, stepRowBorder: { borderTopWidth: 1, borderTopColor: colors.borderSubtle }, stepNum: { width: 28, height: 28, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.04)', borderWidth: 1, borderColor: colors.borderSubtle, alignItems: 'center', justifyContent: 'center', marginTop: 1 }, stepNumText: { fontSize: 12, fontWeight: '700', color: colors.textSecondary }, stepText: { flex: 1 }, stepTitle: { fontSize: 14, fontWeight: '600', color: colors.textPrimary, marginBottom: 3 }, stepDesc: { fontSize: 12, color: colors.textMuted, lineHeight: 17 } })