Files
orca/mobile/app/index.tsx
Jinwoo HongandOrcaWin 738f640428 fix(mobile): relay UX overhaul — steady status colors, visible relay dials, coordinated deep links (F1-F10)
* fix(mobile): keep healthy relays green through focus and network nudges (F1+F2)

Focus/app-resume nudges probe the active relay instead of suspending it;
network-change nudges replace it make-before-break, suspending only after a
failed dial. Mount, Retry, and host-swap windows read 'connecting' instead of
'disconnected'; the host list keeps last-known worktrees for every
not-connected state and spins instead of rendering nothing.

* feat(mobile): surface the pairing relay path in the pairing log (F3)

The relay candidate was silent during pairing: dialing, E2EE handshake,
director recovery, and the winning path now emit redacted phase lines through
the same connectOptions.onLog the direct path already used.

* docs(mobile): relay UX investigation findings and F0-F10 fix plan

* feat(mobile): name and narrate relay dials while they happen (F5)

migrateTo forwards the dialing session's connecting/handshaking/reconnecting
phases whenever the client is suspended or disconnected — never downgrading a
live session — and exposes getPendingPath so the host card can say
'· Orca Relay' during the dial instead of only after it.

* feat(mobile): race a relay dial when the direct dial stalls (F6)

A 2.5s grace timer starts relay recovery while an unauthenticated direct dial
is still inside its 12s connect window; the race gets one attempt through the
existing mutex/cooldown machinery, cancels when direct authenticates, and
never arms for hosts without a relay endpoint.

* fix(mobile): overlay the protocol gate instead of unmounting the host stack (F9)

A pending status.get used to swap the mounted HostStack for a spinner at the
moment the socket connected, destroying in-flight nested navigation. Once
children have rendered for a host they stay mounted under an opaque
touch-blocking overlay; first visits and blocked verdicts keep the old
behavior.

* fix(mobile): keep loaded data through transient connection blips (F10)

Git history no longer blanks on reconnect (and commit files refetch instead
of caching an offline empty answer), the repo picker keeps its last-good list
when an in-flight repo.list rejects, the diff review's ready-state
preservation actually runs, and proven host capabilities survive a drop
flagged unverified instead of being wiped.

* feat(mobile): coordinate every home deep push and bounce dead resume targets (F4+F7+F8)

Notification taps, the Accounts card, and host-edit now use the shared
mount-then-replace transition (with a focused-route walker so root-layout
scope works); the Resume card renders from the snapshot in a disabled state
so its late arrival can't shift Tasks under the thumb; resume targets are
validated against proven catalog data, and a session route whose worktree
the host proves missing bounces to the host index with a notice banner
instead of stranding on a dead screen.

* test(mobile): cover the resume-target and notice policies (F7)

Key notice dismissal by code so closing one banner cannot swallow a later,
different one, and move the visibility rule into host-route-notice.ts where it
is testable without a screen.

Adds the missing units for F7's decision points: isResumeTargetConfirmedMissing
(unproven catalog is silence, synthetic routes exempt), the validating
last-visited reader, and the notice visibility rule.

* fix(mobile): review-pass hardening for the gate overlay and diff preservation

Adversarial review findings: the reader's hunk position now survives a
connection blip (reset only on item change), the covered stack is hidden from
TalkBack while the gate overlay is up, and the overlay's hit-test comment is
scoped honestly to in-tree views (native-Modal drawers present above it —
follow-up).

* fix(mobile): CI + CodeRabbit review fixes for #12609

Move the findings doc under docs/ (root directory guard), drop two unused
eslint-disable directives, and address review findings: an unproven snapshot
seed can no longer downgrade a proven worktree catalog; a locally-aborted
relay dial skips the director fallback; post-migration bookkeeping failures
log instead of masquerading as dial failures (which could suspend the healthy
session); the auth wait arms its timeout before subscribing; forwarded dial
phases stop at close(); the legacy selector_not_found fallback requires
runtime_error; the diff-loading effect depends on the fields it reads; and
host-edit auto-cancellation is now pinned by a test.

* fix(mobile): second review round — queued replacements, race fence, confirmed bounces

A network-change replacement now survives the recovery mutex and cooldowns as
a queued intent instead of being dropped or suspending a healthy session —
only a failed dial or a dead probe tears one down. The happy-eyeballs
migration withdraws when direct authenticated during the relay dial
(first-authenticated-wins). A worktree bounce requires two consecutive
host-proven misses, since a transient desktop repo-scan rejection answers
selector_not_found for a live worktree. Background network flaps no longer
wake a billed relay splice, the lifecycle foreground flag stays in sync, a
screen unmount cancels only its own pending host-stack transition, and diff
review keeps the loaded review when its reconnect refresh rejects.

Extracted mobile-endpoint-nudge-router.ts and the establisher's dialEligible
pass, and split the supervisor nudge tests, to stay under max-lines.

* fix(mobile): satisfy the React Doctor changed-code gate

Render-phase ref writes move into effects: the protocol gate's resolved/mounted
latches now record committed outcomes only (a discarded children render can no
longer count as mounted), and the bounce hook syncs its callback ref in an
effect. Array<T> annotations become T[] in the extracted modules.

* fix(mobile): keep the loaded diff when the reconnect refetch rejects (F10)

The diff-loading hook's catch was the one path still erasing a ready diff —
the same keepLoadedDiff guard its disconnect and loading branches already use,
now pinned by a reject-after-ready test.

* fix(mobile): process foreground revival nudges

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-08-04 19:47:39 -07:00

1352 lines
45 KiB
TypeScript

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, Plus, 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 { loadHosts } from '../src/transport/host-store'
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 { 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, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
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'
function endpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return endpoint
}
}
type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
type HomePreflightStatus = {
glab?: { installed?: boolean }
}
type HomeLinearStatus = {
connected?: boolean
}
const TASK_PROVIDER_LABELS: Record<TaskProvider, string> = {
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<RpcClient, number>()
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<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => 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<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
) => 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<string, TaskProvider[]>) => Record<string, TaskProvider[]>
) => 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 [hosts, setHosts] = useState<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
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 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 allClients = useAllHostClients(hostIds)
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<Array<{ hostId: string; client: RpcClient }>>([])
// 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 loadHosts().then(async (h) => {
if (stale) {
return
}
setHosts(h)
if (h.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]
)
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
useEffect(() => {
setHostAttempts((prev) => {
const next: Record<string, number> = { ...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<string, number | null> = { ...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<string, ConnectionState> = { ...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 hosts) {
if (liveIds.has(host.id)) {
continue
}
if (!host.publicKeyB64 || !host.deviceToken) {
if (next[host.id] !== 'auth-failed') {
next[host.id] = 'auth-failed'
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) && hosts.some((h) => h.id === id) === false) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [allClients, hosts])
// 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 primaryConnectedHost = useMemo(
() => sortedHosts.find((host) => hostStates[host.id] === 'connected') ?? null,
[sortedHosts, hostStates]
)
const primaryTaskProviders = primaryConnectedHost
? (taskProvidersByHost[primaryConnectedHost.id] ?? ['github'])
: []
const openTasks = useCallback(
(provider?: TaskProvider) => {
if (!primaryConnectedHost) {
return
}
openMobileTasks(primaryConnectedHost.id, provider)
},
[openMobileTasks, primaryConnectedHost]
)
const renderTaskHomeCard = () => (
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.taskHomeCard,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
openTasks()
}}
>
<View style={styles.taskHomeIcon}>
<ListTodo size={18} color={colors.textSecondary} />
</View>
<View style={styles.taskHomeMain}>
<Text style={styles.taskHomeTitle}>Tasks</Text>
<Text style={styles.taskHomeSubtitle} numberOfLines={1}>
{primaryTaskProviders.length > 0
? primaryTaskProviders.map((provider) => TASK_PROVIDER_LABELS[provider]).join(' · ')
: 'No task sources connected'}
</Text>
</View>
<View style={styles.taskHomeTrailing}>
<View
style={styles.taskHomeProviderRow}
accessibilityLabel={primaryTaskProviders
.map((provider) => TASK_PROVIDER_LABELS[provider])
.join(', ')}
>
{primaryTaskProviders.map((provider) => (
<Pressable
key={provider}
accessibilityRole="button"
accessibilityLabel={`Open ${TASK_PROVIDER_LABELS[provider]} tasks`}
hitSlop={8}
style={({ pressed }) => [
styles.taskHomeProviderButton,
pressed && styles.taskHomeProviderButtonPressed
]}
onPress={(event) => {
event.stopPropagation()
openTasks(provider)
}}
>
<TaskProviderLogo provider={provider} size={22} color={colors.textSecondary} />
</Pressable>
))}
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
)
async function handleRemove() {
if (!confirmRemove) {
return
}
const hostToRemove = confirmRemove
try {
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
setConfirmRemove(null)
setHosts(await loadHosts())
} 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 (
<SafeAreaView style={styles.container} edges={['top']}>
{/* ─── Top bar ─── */}
<View style={styles.topBar}>
<View style={styles.brandLockup}>
<View style={styles.logoMark}>
<OrcaLogo size={18} />
</View>
<Text style={styles.brandName}>Orca</Text>
</View>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={() => router.push('/settings')}
>
<Settings size={18} color={colors.textSecondary} />
</Pressable>
</View>
{hosts.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View
style={[
styles.emptyContainer,
{ paddingBottom: insets.bottom },
isWideLayout && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
]}
>
<View style={styles.emptyHero}>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
Pair with Orca on your computer to check on your agents, jump into any terminal, and
drive work from your phone.
</Text>
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
<QrCode size={17} color={colors.bgBase} />
<Text style={styles.primaryButtonText}>Pair Desktop</Text>
</Pressable>
</View>
<View style={styles.stepsSection}>
<Text style={styles.sectionHeading}>How it works</Text>
{ONBOARDING_STEPS.map((step, i) => (
<View key={step.title} style={[styles.stepRow, i > 0 && styles.stepRowBorder]}>
<View style={styles.stepNum}>
<Text style={styles.stepNumText}>{i + 1}</Text>
</View>
<View style={styles.stepText}>
<Text style={styles.stepTitle}>{step.title}</Text>
<Text style={styles.stepDesc}>{step.desc}</Text>
</View>
</View>
))}
</View>
</View>
) : (
/* ─── Populated state ─── */
<FlatList
data={sortedHosts}
keyExtractor={(h) => 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={
<View>
<View style={styles.hero}>
<Text style={styles.heroTitle}>Welcome back</Text>
</View>
{stats && (
<View style={styles.statsRow}>
<View style={styles.statCard}>
<Text style={styles.statValue}>
{stats.totalAgentsSpawned.toLocaleString()}
</Text>
<Text style={styles.statLabel}>Agents spawned</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{formatDuration(stats.totalAgentTimeMs)}</Text>
<Text style={styles.statLabel}>Agent time</Text>
</View>
<View style={styles.statCard}>
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
<Text style={styles.statLabel}>PRs created</Text>
</View>
</View>
)}
<Text style={styles.sectionHeading}>Desktops</Text>
</View>
}
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt,
endpoint: item.endpoint
})
return (
<MobileHostCard
host={item}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
}}
/>
)
}}
ListFooterComponent={
<View>
{/* ─── Resume card ─── */}
{resumeCard ? (
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
disabled={!resumeCard.actionable}
style={({ pressed }) => [
styles.resumeCard,
!resumeCard.actionable && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => openResume(resumeCard)}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
</View>
<View style={styles.resumeMain}>
<Text style={styles.resumeTitle} numberOfLines={1}>
{resumeCard.worktree.displayName}
</Text>
<View style={styles.resumeSub}>
<View
style={[
styles.repoDot,
{ backgroundColor: repoColor(resumeCard.worktree.repo) }
]}
/>
<Text style={styles.resumeSubText} numberOfLines={1}>
{resumeCard.worktree.repo}
{' · '}
{resumeCard.worktree.branch}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</>
) : null}
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
router.push(`/h/${primaryConnectedHost.id}?action=newWorktree`)
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
<>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>
Account usage
</Text>
{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 (
<Pressable
key={host.id}
style={({ pressed }) => [
styles.accountsCard,
pressed && styles.hostCardPressed
]}
onPress={() => openMobileAccounts(host.id)}
>
{showHostName ? (
<Text style={styles.accountsHostLabel} numberOfLines={1}>
{host.name}
</Text>
) : 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 (
<View key={provider} style={styles.accountsRow}>
<View style={styles.accountsIcon}>
{provider === 'claude' ? (
<ClaudeIcon size={18} />
) : (
<OpenAIIcon size={18} color={colors.textPrimary} />
)}
</View>
<View style={styles.accountsInfo}>
<Text style={styles.accountsEmail} numberOfLines={1}>
{active?.email ?? 'System default'}
</Text>
<View style={styles.accountsBars}>
<UsageBar
label="5h"
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
/>
<UsageBar
label="7d"
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
/>
</View>
</View>
</View>
)
})}
</Pressable>
)
})}
</>
) : null}
</View>
}
/>
)}
{/* ─── Action sheets (shared by both states) ─── */}
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.name}
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
})}
onClose={() => setActionTarget(null)}
/>
<ConfirmModal
visible={confirmRemove != null}
title="Remove Host"
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
confirmLabel="Remove"
destructive
onConfirm={() => void handleRemove()}
onCancel={() => setConfirmRemove(null)}
/>
</SafeAreaView>
)
}
function CardGap() {
return <View style={styles.cardGap} />
}
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
},
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
},
/* ─── Quick actions ─── */
quickActions: {
flexDirection: 'row',
gap: spacing.sm
},
quickAction: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
gap: 10
},
cardDisabled: {
opacity: 0.45
},
quickActionIcon: {
width: 28,
height: 28,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center'
},
quickActionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary
},
/* ─── 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
}
})