mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
* 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>
1352 lines
45 KiB
TypeScript
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
|
|
}
|
|
})
|