mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
* Add host removal lifecycle safeguards and credential cleanup retry UI - Sequence host removal so metadata commits before the client socket closes, avoiding a stranded host when storage fails, and add a cancellable open-registry to stop races between host-client opens and closes/unmounts. - Queue AsyncStorage host-list mutations (rename/removal/lastConnected) to prevent concurrent writers from clobbering each other's changes. - Track keychain credential cleanups that fail or time out as durable pending intents, surfaced with a manual retry affordance in Settings. * Fix host removal error handling to reopen confirm dialog and alert user Previously a failed host removal silently closed the confirm dialog, leaving the host listed with no feedback and no easy retry path. Now the confirm modal reopens and an alert surfaces the failure so the user can retry. * test: reconcile settings tests with universal right-click paste and promoted worktree symlinks Merging main surfaced two semantic conflicts against this branch's tests: - #8322 exposed right-click paste on every platform, so the settings navigation metadata now indexes it even when only the terminal host is Windows. Update the stale assertion accordingly. - #8318 promoted APFS worktree shared paths by dropping the experimentalWorktreeSymlinks gate, so WorktreeSymlinksSection now always mounts inside RepositoryPane and reads window.api.fs. Stub a minimal renderer fs bridge in the pane test, matching the AppearancePane pattern. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
1582 lines
51 KiB
TypeScript
1582 lines
51 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 {
|
|
Monitor,
|
|
QrCode,
|
|
Settings,
|
|
ChevronRight,
|
|
Terminal,
|
|
Plus,
|
|
RefreshCw,
|
|
PowerOff,
|
|
Edit3,
|
|
ListTodo
|
|
} from 'lucide-react-native'
|
|
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
|
|
import {
|
|
type AccountsSnapshot,
|
|
type ProviderKey,
|
|
getActiveProviderRateLimits,
|
|
getUsageBarState,
|
|
hasActiveProviderUsage,
|
|
hasRenderableUsage,
|
|
UsageBar
|
|
} from '../src/components/AccountUsage'
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
import { loadHosts, renameHost } from '../src/transport/host-store'
|
|
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
|
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
|
import type { RpcClient } from '../src/transport/rpc-client'
|
|
import {
|
|
useAllHostClients,
|
|
useCloseHost,
|
|
useForceReconnect,
|
|
usePrimeHosts
|
|
} from '../src/transport/client-context'
|
|
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
|
|
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
|
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
|
import { triggerMediumImpact } from '../src/platform/haptics'
|
|
import { OrcaLogo } from '../src/components/OrcaLogo'
|
|
import { StatusDot } from '../src/components/StatusDot'
|
|
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
|
import { TextInputModal } from '../src/components/TextInputModal'
|
|
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
|
|
import { ConfirmModal } from '../src/components/ConfirmModal'
|
|
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
|
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 { useResponsiveLayout } from '../src/layout/responsive-layout'
|
|
|
|
function endpointLabel(endpoint: string): string {
|
|
try {
|
|
const url = new URL(endpoint)
|
|
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
|
|
} catch {
|
|
return endpoint
|
|
}
|
|
}
|
|
|
|
type StatsSummary = {
|
|
totalAgentsSpawned: number
|
|
totalPRsCreated: number
|
|
totalAgentTimeMs: number
|
|
firstEventAt: number | null
|
|
}
|
|
|
|
type WorktreeSummary = {
|
|
worktreeId: string
|
|
repo: string
|
|
branch: string
|
|
displayName: string
|
|
liveTerminalCount: number
|
|
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
|
// The worktree the desktop currently has focused (exactly one is true).
|
|
isActive?: boolean
|
|
// Last terminal-output time (ms); breaks ties when nothing is focused.
|
|
lastOutputAt?: number
|
|
}
|
|
|
|
type HostWorktreeInfo = {
|
|
hostId: string
|
|
totalWorktrees: number
|
|
activeCount: number
|
|
lastActiveWorktree: WorktreeSummary | null
|
|
}
|
|
|
|
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: derive a stable per-instance identity for RpcClient so the wireUp
|
|
// effect's dep key changes when forceReconnect swaps the underlying client
|
|
// for a host (without this, listeners stay attached to the closed client
|
|
// and notifications/accounts subs never re-attach).
|
|
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,
|
|
setStats: (s: StatsSummary) => void,
|
|
disposed: () => boolean
|
|
) {
|
|
client
|
|
.sendRequest('stats.summary')
|
|
.then((response) => {
|
|
if (disposed()) {
|
|
return
|
|
}
|
|
if (response.ok) {
|
|
setStats(response.result as StatsSummary)
|
|
}
|
|
})
|
|
.catch(() => {})
|
|
}
|
|
|
|
function fetchWorktreeInfo(
|
|
client: RpcClient,
|
|
hostId: string,
|
|
setInfo: (
|
|
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
|
|
) => void,
|
|
disposed: () => boolean
|
|
) {
|
|
// Why: only seed an empty zeroed entry when this host has no prior info
|
|
// at all (e.g., first ever load before any cache hydration). On a
|
|
// transient failure for a host that already has cached data, leave the
|
|
// cached entry alone so the Resume card and host-meta line don't
|
|
// momentarily flip to "0 worktrees" / disappear during reconnects.
|
|
const markLoadedIfMissing = () => {
|
|
setInfo((prev) => {
|
|
if (prev[hostId]) {
|
|
return prev
|
|
}
|
|
return {
|
|
...prev,
|
|
[hostId]: {
|
|
hostId,
|
|
totalWorktrees: 0,
|
|
activeCount: 0,
|
|
lastActiveWorktree: null
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
client
|
|
// Why: worktree.ps defaults to 200 and silently truncates; request the full
|
|
// set so the host worktree count and active count are accurate.
|
|
.sendRequest('worktree.ps', { limit: 10000 })
|
|
.then((response) => {
|
|
if (disposed()) {
|
|
return
|
|
}
|
|
if (response.ok) {
|
|
const result = response.result as { worktrees: WorktreeSummary[] }
|
|
const worktrees = result.worktrees ?? []
|
|
setCachedWorktrees(hostId, worktrees)
|
|
const activeStatuses = new Set(['working', 'active', 'permission'])
|
|
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
|
|
// Mirror the desktop's focused workspace (see pickResumeWorktree).
|
|
const lastActive = pickResumeWorktree(worktrees)
|
|
setInfo((prev) => ({
|
|
...prev,
|
|
[hostId]: {
|
|
hostId,
|
|
totalWorktrees: worktrees.length,
|
|
activeCount: active.length,
|
|
lastActiveWorktree: lastActive
|
|
}
|
|
}))
|
|
} else {
|
|
markLoadedIfMissing()
|
|
}
|
|
})
|
|
.catch(() => {
|
|
if (!disposed()) {
|
|
markLoadedIfMissing()
|
|
}
|
|
})
|
|
}
|
|
|
|
function fetchAccountsSnapshot(
|
|
client: RpcClient,
|
|
hostId: string,
|
|
setSnapshots: (
|
|
updater: (prev: Record<string, AccountsSnapshot>) => Record<string, AccountsSnapshot>
|
|
) => void,
|
|
disposed: () => boolean
|
|
) {
|
|
client
|
|
.sendRequest('accounts.list')
|
|
.then((response) => {
|
|
if (disposed()) {
|
|
return
|
|
}
|
|
if (response.ok) {
|
|
const snapshot = response.result as AccountsSnapshot
|
|
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([
|
|
client.sendRequest('settings.get'),
|
|
client.sendRequest('preflight.check'),
|
|
client.sendRequest('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: repo names get a stable color derived from hashing, matching the
|
|
// host detail page's colored dots for visual consistency.
|
|
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 insets = useSafeAreaInsets()
|
|
// Why: cap and center content on wide/tablet canvases so cards don't stretch
|
|
// edge-to-edge on iPad; on phones isWideLayout is false and layout is unchanged.
|
|
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
|
const [hosts, setHosts] = useState<HostProfile[]>([])
|
|
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
|
const [renameTarget, setRenameTarget] = 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 [stats, setStats] = useState<StatsSummary | null>(null)
|
|
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: read shared clients from the per-host store. Replaces the prior
|
|
// pattern of opening N independent WebSockets here. See
|
|
// docs/mobile-shared-client-per-host.md.
|
|
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
|
const allClients = useAllHostClients(hostIds)
|
|
const closeHostClient = useCloseHost()
|
|
const forceReconnectHost = useForceReconnect()
|
|
const primeHosts = usePrimeHosts()
|
|
// Why: feed the loaded HostProfiles into the provider's prime cache as
|
|
// soon as we have them. This avoids a second Keychain pass inside
|
|
// openEntry on cold start (which serialised behind the first one and
|
|
// showed up as multi-second connect latency).
|
|
useEffect(() => {
|
|
if (hosts.length > 0) {
|
|
primeHosts(hosts)
|
|
}
|
|
}, [hosts, primeHosts])
|
|
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
|
|
// Why: the focus callback stays stable to avoid refetching on every
|
|
// client-store render, but it still needs the latest host clients.
|
|
allClientsRef.current = allClients.map((entry) => ({
|
|
hostId: entry.hostId,
|
|
client: entry.client
|
|
}))
|
|
|
|
// Why: hydrate the home page from a persisted snapshot on cold-start so
|
|
// Resume + Account-usage cards paint immediately with last-known data
|
|
// instead of flashing empty for ~1s while the WebSocket reconnects.
|
|
// Stream/list responses overwrite this seed in place when they arrive.
|
|
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: also seed the in-memory worktree cache so resumeWorktree's
|
|
// lastVisited fast-path can find the cached worktree object.
|
|
setCachedWorktrees(hostId, [wt])
|
|
}
|
|
}
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
// Why: persist the merged snapshot whenever either piece updates so the
|
|
// next cold-start has fresh seed data. The cache module debounces writes
|
|
// internally so a flurry of streamed updates doesn't hammer disk.
|
|
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((h) => {
|
|
if (!stale) {
|
|
setHosts(h)
|
|
}
|
|
})
|
|
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
|
if (stale || !raw) {
|
|
return
|
|
}
|
|
try {
|
|
setLastVisited(JSON.parse(raw))
|
|
} catch {}
|
|
})
|
|
for (const entry of allClientsRef.current) {
|
|
if (entry.client.getState() === 'connected') {
|
|
fetchStats(entry.client, setStats, () => stale)
|
|
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
|
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
|
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
|
|
}
|
|
}
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [])
|
|
)
|
|
|
|
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, connecting indicators) 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: when a paired host disappears from allClients (because the
|
|
// user tapped Disconnect, or the host record was invalid) the card
|
|
// must reflect that. We only force-update hosts whose state was
|
|
// already tracked — otherwise the initial-acquire frame (entry not
|
|
// yet materialised) would briefly flip every host 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])
|
|
|
|
// Why: per-host streaming subscriptions (notifications + accounts) and
|
|
// one-shot stats fetches when each host transitions to 'connected'.
|
|
// Runs once per (hostId, client) pair and tears down when that pair
|
|
// changes. The provider keeps the underlying socket open across
|
|
// resubscription cycles so this is cheap.
|
|
useEffect(() => {
|
|
const cleanups: Array<() => void> = []
|
|
for (const entry of allClients) {
|
|
let unsubNotif: (() => void) | null = null
|
|
let unsubAccounts: (() => void) | null = null
|
|
let statsFetched = false
|
|
const wireUp = (state: ConnectionState) => {
|
|
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?: AccountsSnapshot }
|
|
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
|
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: evt.snapshot! }))
|
|
}
|
|
})
|
|
}
|
|
if (!statsFetched) {
|
|
statsFetched = true
|
|
fetchStats(entry.client, setStats, () => false)
|
|
fetchWorktreeInfo(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)
|
|
cleanups.push(() => {
|
|
unsubState()
|
|
unsubNotif?.()
|
|
unsubAccounts?.()
|
|
})
|
|
}
|
|
return () => {
|
|
for (const c of cleanups) {
|
|
c()
|
|
}
|
|
}
|
|
// Why: depend on the host-id set AND each entry's client identity, so
|
|
// resubscriptions don't fire on every render that produces a new
|
|
// array reference, but DO fire when forceReconnect swaps the
|
|
// underlying client for a host (otherwise wireUp would keep firing
|
|
// on a closed client and never re-attach to the fresh one, leaving
|
|
// notifications/accounts subs broken until the user navigates).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
allClients
|
|
.map((e) => `${e.hostId}:${clientKey(e.client)}`)
|
|
.sort()
|
|
.join(',')
|
|
])
|
|
|
|
// Why: prefer the worktree the user last opened on this device so the
|
|
// "Resume" card reflects their mobile session history, not just the
|
|
// desktop's most-recently-outputting worktree.
|
|
// Why: rendering used to be gated on hostStates === 'connected', which
|
|
// caused the Resume card to vanish for ~1s on every cold-start /
|
|
// resume-from-background while the WebSocket reconnected, even though we
|
|
// had perfectly good cached worktree data. Now the card stays visible as
|
|
// long as we have a cached lastActiveWorktree for any known host; the
|
|
// tap target is still the same and a fresher snapshot from the live RPC
|
|
// overwrites the card's contents in place when it lands.
|
|
const resumeWorktree = useMemo(() => {
|
|
// Why: only surface Resume for hosts that are currently connected.
|
|
// Showing a stale cached worktree for a disconnected host is
|
|
// misleading — the user would tap into a session route that can't
|
|
// load anything until the host reconnects. Once the host reconnects,
|
|
// the card reappears with fresh data.
|
|
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
|
const cached = getCachedWorktrees(lastVisited.hostId) as WorktreeSummary[] | null
|
|
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
|
if (match) {
|
|
return { hostId: lastVisited.hostId, worktree: match }
|
|
}
|
|
}
|
|
for (const host of sortedHosts) {
|
|
if (hostStates[host.id] !== 'connected') {
|
|
continue
|
|
}
|
|
const info = worktreeInfo[host.id]
|
|
if (info?.lastActiveWorktree) {
|
|
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
|
}
|
|
}
|
|
return null
|
|
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
|
|
|
// Why: only show the Account usage section for hosts that are currently
|
|
// connected. Showing stale cached usage for a disconnected host implies
|
|
// live data; better to hide until the host reconnects and we can refresh.
|
|
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
|
|
// (no Orca-managed accounts but live rate-limit data for the active
|
|
// target), otherwise system-default users see no usage section at all.
|
|
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
|
|
}
|
|
const suffix = provider ? `?taskSource=${provider}` : ''
|
|
router.push(`/h/${primaryConnectedHost.id}/tasks${suffix}`)
|
|
},
|
|
[primaryConnectedHost, router]
|
|
)
|
|
const renderTaskHomeCard = () => (
|
|
<Pressable
|
|
disabled={!primaryConnectedHost}
|
|
style={({ pressed }) => [
|
|
styles.taskHomeCard,
|
|
!primaryConnectedHost && styles.quickActionDisabled,
|
|
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 handleRename(newName: string) {
|
|
if (!renameTarget) {
|
|
return
|
|
}
|
|
try {
|
|
await renameHost(renameTarget.id, newName)
|
|
setRenameTarget(null)
|
|
setHosts(await loadHosts())
|
|
} catch {
|
|
setRenameTarget(null)
|
|
}
|
|
}
|
|
|
|
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 and surface the
|
|
// failure instead of silently leaving the host listed.
|
|
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: edge-to-edge — let the list scroll under the system nav bar
|
|
// but reserve insets.bottom so the last row stays reachable above
|
|
// the Samsung 3-button nav / iOS 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 connected = state === 'connected'
|
|
const info = worktreeInfo[item.id]
|
|
const verdict = classifyConnection({
|
|
state,
|
|
reconnectAttempts: attempts,
|
|
lastConnectedAt,
|
|
endpoint: item.endpoint
|
|
})
|
|
const isError =
|
|
verdict.kind === 'warning' ||
|
|
verdict.kind === 'unreachable' ||
|
|
verdict.kind === 'auth-failed'
|
|
return (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
|
onPress={() => router.push(`/h/${item.id}`)}
|
|
onLongPress={() => {
|
|
triggerMediumImpact()
|
|
setActionTarget(item)
|
|
}}
|
|
delayLongPress={400}
|
|
>
|
|
<View style={styles.hostIcon}>
|
|
<Monitor
|
|
size={20}
|
|
color={connected ? colors.textPrimary : colors.textSecondary}
|
|
/>
|
|
</View>
|
|
<View style={styles.hostMain}>
|
|
<Text
|
|
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
|
|
numberOfLines={1}
|
|
>
|
|
{item.name}
|
|
</Text>
|
|
<View style={styles.hostMeta}>
|
|
<StatusDot state={state} verdict={verdict} />
|
|
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
|
{verdictDisplayLabel(verdict)}
|
|
{connected && info
|
|
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
|
: ''}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<ChevronRight size={16} color={colors.textMuted} />
|
|
</Pressable>
|
|
)
|
|
}}
|
|
ListFooterComponent={
|
|
<View>
|
|
{/* ─── Resume card ─── */}
|
|
{resumeWorktree ? (
|
|
<>
|
|
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
|
onPress={() =>
|
|
router.push(
|
|
`/h/${resumeWorktree.hostId}/session/${encodeURIComponent(resumeWorktree.worktree.worktreeId)}`
|
|
)
|
|
}
|
|
>
|
|
<View style={styles.resumeIcon}>
|
|
<Terminal size={18} color={colors.textSecondary} />
|
|
</View>
|
|
<View style={styles.resumeMain}>
|
|
<Text style={styles.resumeTitle} numberOfLines={1}>
|
|
{resumeWorktree.worktree.displayName}
|
|
</Text>
|
|
<View style={styles.resumeSub}>
|
|
<View
|
|
style={[
|
|
styles.repoDot,
|
|
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
|
|
]}
|
|
/>
|
|
<Text style={styles.resumeSubText} numberOfLines={1}>
|
|
{resumeWorktree.worktree.repo}
|
|
{' · '}
|
|
{resumeWorktree.worktree.branch}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<ChevronRight size={16} color={colors.textMuted} />
|
|
</Pressable>
|
|
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
|
|
{renderTaskHomeCard()}
|
|
</>
|
|
) : (
|
|
<>
|
|
<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.quickActionDisabled,
|
|
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={() => router.push(`/h/${host.id}/accounts`)}
|
|
>
|
|
{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 a
|
|
// "System default" row when the active target has
|
|
// live usage data; the row label already falls back
|
|
// to "System default" below.
|
|
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={(() => {
|
|
const host = actionTarget
|
|
if (!host) {
|
|
return []
|
|
}
|
|
const state = hostStates[host.id] ?? 'connecting'
|
|
const isLive =
|
|
state === 'connected' ||
|
|
state === 'connecting' ||
|
|
state === 'handshaking' ||
|
|
state === 'reconnecting'
|
|
// Why: "Reconnect" implies "you were connected, try again". If
|
|
// the client has never reached 'connected' this session (cold
|
|
// start, unreachable host, or after Disconnect) the action is
|
|
// functionally a fresh Connect — using the right verb makes
|
|
// the affordance match what tapping it actually does.
|
|
const hasEverConnected = (hostLastConnected[host.id] ?? null) != null
|
|
const items: ActionSheetAction[] = []
|
|
items.push({
|
|
label: hasEverConnected && isLive ? 'Reconnect' : 'Connect',
|
|
icon: RefreshCw,
|
|
onPress: () => {
|
|
setActionTarget(null)
|
|
void forceReconnectHost(host.id)
|
|
}
|
|
})
|
|
if (isLive) {
|
|
items.push({
|
|
label: 'Disconnect',
|
|
icon: PowerOff,
|
|
onPress: () => {
|
|
setActionTarget(null)
|
|
closeHostClient(host.id)
|
|
}
|
|
})
|
|
}
|
|
items.push({
|
|
label: 'Rename',
|
|
icon: Edit3,
|
|
onPress: () => {
|
|
setActionTarget(null)
|
|
setRenameTarget(host)
|
|
}
|
|
})
|
|
items.push({
|
|
label: 'Remove',
|
|
destructive: true,
|
|
onPress: () => {
|
|
setActionTarget(null)
|
|
setConfirmRemove(host)
|
|
}
|
|
})
|
|
return items
|
|
})()}
|
|
onClose={() => setActionTarget(null)}
|
|
/>
|
|
|
|
<TextInputModal
|
|
visible={renameTarget != null}
|
|
title="Rename Host"
|
|
message="Enter a new name for this host."
|
|
defaultValue={renameTarget?.name ?? ''}
|
|
placeholder="Host name"
|
|
onSubmit={(name) => void handleRename(name)}
|
|
onCancel={() => setRenameTarget(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 ─── */
|
|
hostCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingLeft: spacing.md,
|
|
paddingRight: spacing.md,
|
|
paddingVertical: 12,
|
|
borderRadius: radii.card,
|
|
backgroundColor: colors.bgPanel,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
hostCardPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
hostIcon: {
|
|
width: 46,
|
|
height: 46,
|
|
borderRadius: 13,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgRaised,
|
|
marginRight: 14,
|
|
position: 'relative'
|
|
},
|
|
hostMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
marginRight: spacing.sm
|
|
},
|
|
hostName: {
|
|
color: colors.textPrimary,
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
lineHeight: 20
|
|
},
|
|
hostMeta: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
marginTop: 3
|
|
},
|
|
hostMetaItem: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary
|
|
},
|
|
hostMetaDot: {
|
|
width: 3,
|
|
height: 3,
|
|
borderRadius: 1.5,
|
|
backgroundColor: colors.textMuted,
|
|
marginHorizontal: 8
|
|
},
|
|
statusDot: {
|
|
width: 7,
|
|
height: 7,
|
|
borderRadius: 3.5
|
|
},
|
|
|
|
/* ─── 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
|
|
},
|
|
quickActionDisabled: {
|
|
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
|
|
}
|
|
})
|