feat(mobile): account switcher and rate-limit usage on mobile (#1467)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-05 16:09:16 -07:00 committed by GitHub
parent ddb732cf34
commit f938ff185f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1335 additions and 82 deletions

View File

@ -0,0 +1,423 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
RefreshControl,
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts } from '../../../src/transport/host-store'
import type { ConnectionState, RpcSuccess } from '../../../src/transport/types'
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
getInactiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
export default function AccountsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const [client, setClient] = useState<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('connecting')
const [hostName, setHostName] = useState<string>('')
const [snapshot, setSnapshot] = useState<AccountsSnapshot | null>(null)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
// Why: connect to the host's WebSocket on mount and tear down on
// unmount. Mirrors the connection lifecycle used in
// /h/[hostId]/index.tsx so reconnect/auth-failed states are handled
// identically.
useEffect(() => {
if (!hostId) return
let cancelled = false
let rpcClient: RpcClient | null = null
void (async () => {
const hosts = await loadHosts()
const host = hosts.find((h) => h.id === hostId)
if (!host) {
if (!cancelled) setError('Host not found')
return
}
if (!cancelled) setHostName(host.name)
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
if (!cancelled) setConnState(state)
})
clientRef.current = rpcClient
if (!cancelled) setClient(rpcClient)
})()
return () => {
cancelled = true
if (rpcClient) rpcClient.close()
clientRef.current = null
}
}, [hostId])
// Why: subscribe to streaming snapshot updates so usage bars refresh in
// place when the desktop's rate-limit poll completes (every 5 min) or
// when the user switches accounts. Falls back to a one-shot accounts.list
// if the subscription stream errors.
useEffect(() => {
if (!client || connState !== 'connected') return
const unsubscribe = 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) {
setSnapshot(evt.snapshot)
setError(null)
}
})
return unsubscribe
}, [client, connState])
const refresh = useCallback(async () => {
if (!client) return
setRefreshing(true)
try {
const res = await client.sendRequest('accounts.list')
if (res.ok) {
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
setError(null)
} else {
setError(res.error.message)
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setRefreshing(false)
}
}, [client])
const selectAccount = useCallback(
async (provider: ProviderKey, accountId: string | null) => {
if (!client) return
setBusyAccountId(accountId ?? `${provider}:default`)
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
try {
const res = await client.sendRequest(method, { accountId })
if (!res.ok) {
Alert.alert('Could not switch account', res.error.message)
} else {
// Why: optimistic refresh — the streaming subscription will also
// emit, but a one-shot keeps the UI responsive even if the stream
// is temporarily disconnected.
await refresh()
}
} catch (e) {
Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e))
} finally {
setBusyAccountId(null)
}
},
[client, refresh]
)
const renderProviderSection = (provider: ProviderKey, title: string) => {
if (!snapshot) return null
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Icon size={14} />
<Text style={styles.sectionHeading}>{title}</Text>
</View>
<View style={styles.card}>
{/* System default row */}
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, null)}
disabled={busyAccountId !== null || connState !== 'connected'}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle}>System default</Text>
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
</View>
<View style={styles.rowTrailing}>
{state.activeAccountId === null ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === `${provider}:default` ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</Pressable>
{state.accounts.map((account) => {
const isActive = state.activeAccountId === account.id
const inactiveEntry = !isActive
? getInactiveProviderUsage(snapshot, provider, account.id)
: null
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
const isFetching =
(isActive && usage?.status === 'fetching') ||
(!isActive && inactiveEntry?.isFetching === true)
const session = usage?.session
const weekly = usage?.weekly
return (
<View key={account.id}>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => selectAccount(provider, account.id)}
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
>
<View style={styles.rowMain}>
<Text style={styles.rowTitle} numberOfLines={1}>
{account.email}
</Text>
<View style={styles.usageRow}>
<UsageBar
label="5h"
usedPercent={session?.usedPercent ?? null}
unavailable={!session && !isFetching}
loading={isFetching && !session}
/>
<UsageBar
label="7d"
usedPercent={weekly?.usedPercent ?? null}
unavailable={!weekly && !isFetching}
loading={isFetching && !weekly}
/>
</View>
{usage?.error ? (
<Text style={styles.errorText} numberOfLines={1}>
{usage.error}
</Text>
) : null}
</View>
<View style={styles.rowTrailing}>
{isActive ? (
<Check size={16} color={colors.accentBlue} />
) : busyAccountId === account.id ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</View>
</Pressable>
</View>
)
})}
</View>
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<View style={styles.titleWrap}>
<Text style={styles.heading}>Accounts</Text>
{hostName ? (
<Text style={styles.subheading} numberOfLines={1}>
{hostName}
</Text>
) : null}
</View>
<Pressable
style={styles.iconButton}
onPress={refresh}
disabled={!client || refreshing || connState !== 'connected'}
>
{refreshing ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<RefreshCw size={18} color={colors.textSecondary} />
)}
</Pressable>
</View>
<ScrollView
contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + spacing.xl }]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={refresh}
tintColor={colors.textSecondary}
/>
}
>
{connState !== 'connected' && !snapshot ? (
<View style={styles.placeholder}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.placeholderText}>Connecting to {hostName || 'host'}</Text>
</View>
) : error && !snapshot ? (
<View style={styles.placeholder}>
<Text style={styles.errorText}>{error}</Text>
</View>
) : !snapshot ? (
<View style={styles.placeholder}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.placeholderText}>Loading accounts</Text>
</View>
) : (
<>
{renderProviderSection('claude', 'Claude')}
{renderProviderSection('codex', 'Codex')}
<View style={styles.footerHint}>
<User size={14} color={colors.textMuted} />
<Text style={styles.footerHintText}>
Add or re-authenticate accounts from desktop Settings Accounts.
</Text>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
iconButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center'
},
titleWrap: {
flex: 1
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
subheading: {
fontSize: typography.metaSize,
color: colors.textSecondary,
marginTop: 1
},
scroll: {
paddingHorizontal: spacing.lg,
paddingTop: spacing.sm
},
section: {
marginBottom: spacing.xl
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.sm
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 0.5
},
card: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden'
},
row: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowMain: {
flex: 1,
gap: 4
},
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
// same width whether or not the row is currently selected (otherwise the
// checkmark on the active account squeezes the bars narrower than the
// inactive rows above/below it).
rowTrailing: {
width: 24,
alignItems: 'flex-end',
justifyContent: 'center',
marginLeft: spacing.sm
},
rowTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowSubtitle: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
usageRow: {
flexDirection: 'row',
gap: spacing.md,
marginTop: 4
},
errorText: {
fontSize: typography.metaSize,
color: colors.statusRed
},
placeholder: {
paddingVertical: spacing.xl * 2,
alignItems: 'center',
gap: spacing.sm
},
placeholderText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
footerHint: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm,
paddingHorizontal: spacing.sm,
paddingTop: spacing.sm
},
footerHintText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})

View File

@ -24,7 +24,8 @@ import {
Plus,
Moon,
Filter,
Check
Check,
UserCircle
} from 'lucide-react-native'
import { connect, type RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
@ -682,6 +683,17 @@ export default function HostScreen() {
<View style={styles.toolbarSpacer} />
<Pressable
style={styles.searchToggle}
onPress={() => router.push(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.newButton}
onPress={() => setShowNewWorktree(true)}

View File

@ -10,6 +10,7 @@ export default function HostGroupLayout() {
}}
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
</Stack>
)

View File

@ -13,6 +13,13 @@ import {
Terminal,
Plus
} from 'lucide-react-native'
import { ClaudeIcon, OpenAIIcon } from '../src/components/AgentIcons'
import {
type AccountsSnapshot,
type ProviderKey,
getActiveProviderRateLimits,
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
import { connect, type RpcClient } from '../src/transport/rpc-client'
@ -24,6 +31,7 @@ import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal } 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'
function endpointLabel(endpoint: string): string {
@ -102,16 +110,24 @@ function fetchWorktreeInfo(
) => void,
disposed: () => boolean
) {
const markLoaded = () => {
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null
// 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
@ -135,14 +151,34 @@ function fetchWorktreeInfo(
}
}))
} else {
markLoaded()
markLoadedIfMissing()
}
})
.catch(() => {
if (!disposed()) markLoaded()
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(() => {})
}
// 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']
@ -164,11 +200,53 @@ export default function HomeScreen() {
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
null
)
const clientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
// 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
@ -185,6 +263,7 @@ export default function HomeScreen() {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
}
}
return () => {
@ -222,25 +301,43 @@ export default function HomeScreen() {
}
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const unsubState = client.onStateChange((state) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(client)
}
if (!unsubAccounts) {
unsubAccounts = client.subscribe('accounts.subscribe', null, (payload) => {
if (disposed || !payload || typeof payload !== 'object') return
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
const snap = evt.snapshot
setAccountsByHost((prev) => ({ ...prev, [host.id]: snap }))
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(client, setStats, () => disposed)
fetchWorktreeInfo(client, host.id, setWorktreeInfo, () => disposed)
}
} else if (unsubNotif) {
unsubNotif()
unsubNotif = null
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
})
notifCleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
return [{ hostId: host.id, client }]
@ -259,12 +356,20 @@ export default function HomeScreen() {
// 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(() => {
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
if (lastVisited && sortedHosts.some((h) => h.id === lastVisited.hostId)) {
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 }
}
// Prefer a currently-connected host's data when we have it.
for (const host of sortedHosts) {
if (hostStates[host.id] !== 'connected') continue
const info = worktreeInfo[host.id]
@ -272,6 +377,14 @@ export default function HomeScreen() {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
// Fall back to whichever known host has cached data, regardless of
// current connection state.
for (const host of sortedHosts) {
const info = worktreeInfo[host.id]
if (info?.lastActiveWorktree) {
return { hostId: host.id, worktree: info.lastActiveWorktree }
}
}
return null
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
@ -289,6 +402,23 @@ export default function HomeScreen() {
[sortedHosts, hostStates, worktreeInfo]
)
// Why: only show the Account usage section for hosts that have at least
// one Claude or Codex account configured. Render whenever cached data
// exists, regardless of current connection state, so the cards don't
// disappear for ~1s on resume while the WebSocket reconnects. Streamed
// updates from the live RPC overwrite the snapshot in place when ready.
const accountsHosts = useMemo(() => {
const items: Array<{ host: HostProfile; snapshot: AccountsSnapshot }> = []
for (const host of sortedHosts) {
const snap = accountsByHost[host.id]
if (!snap) continue
const hasClaude = snap.claude.accounts.length > 0
const hasCodex = snap.codex.accounts.length > 0
if (hasClaude || hasCodex) items.push({ host, snapshot: snap })
}
return items
}, [sortedHosts, hostStates, accountsByHost])
async function handleRename(newName: string) {
if (!renameTarget) return
try {
@ -503,6 +633,85 @@ export default function HomeScreen() {
</>
) : null}
{/* ─── 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
if (accounts.length === 0) return null
const limits = getActiveProviderRateLimits(snapshot, provider)
const isFetching =
limits?.status === 'fetching' || limits?.status === 'idle'
const unavailable =
limits == null ||
limits.status === 'unavailable' ||
limits.status === 'error'
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={limits?.session?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.session == null}
/>
<UsageBar
label="7d"
usedPercent={limits?.weekly?.usedPercent ?? null}
unavailable={unavailable}
loading={isFetching && limits?.weekly == null}
/>
</View>
</View>
</View>
)
})}
</Pressable>
)
})}
</>
) : null}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
@ -511,7 +720,7 @@ export default function HomeScreen() {
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={20} color={colors.textSecondary} />
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
@ -525,7 +734,7 @@ export default function HomeScreen() {
}}
>
<View style={styles.quickActionIcon}>
<Plus size={20} color={colors.textSecondary} />
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Worktree</Text>
</Pressable>
@ -667,20 +876,21 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: 10,
padding: spacing.md
paddingVertical: 10,
paddingHorizontal: spacing.md
},
statIcon: {
width: 30,
height: 30,
borderRadius: 7,
width: 26,
height: 26,
borderRadius: 6,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 10
marginBottom: 6
},
statValue: {
color: colors.textPrimary,
fontSize: 20,
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3
},
@ -688,7 +898,7 @@ const styles = StyleSheet.create({
color: colors.textMuted,
fontSize: 11,
fontWeight: '500',
marginTop: 3
marginTop: 2
},
/* ─── Section heading ─── */
@ -817,6 +1027,53 @@ const styles = StyleSheet.create({
flex: 1
},
/* ─── 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
},
/* ─── Skeleton ─── */
skeletonBlock: {
backgroundColor: colors.bgRaised,
@ -836,18 +1093,20 @@ const styles = StyleSheet.create({
},
quickAction: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
padding: spacing.lg,
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'center',
gap: 10
},
quickActionIcon: {
width: 40,
height: 40,
borderRadius: 12,
width: 28,
height: 28,
borderRadius: 9,
backgroundColor: 'rgba(255,255,255,0.04)',
alignItems: 'center',
justifyContent: 'center'
@ -855,8 +1114,7 @@ const styles = StyleSheet.create({
quickActionLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
textAlign: 'center'
color: colors.textSecondary
},
/* ─── Empty state ─── */

66
mobile/src/cache/home-snapshot-cache.ts vendored Normal file
View File

@ -0,0 +1,66 @@
// Why: persist the data needed to render the home page so cold-start /
// resume-from-background paints instantly with the last known good
// values, then updates in place when fresh RPC data arrives. Without
// this, Resume and Account-usage cards flash empty for ~1s while the
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
const STORAGE_KEY = 'orca:home-snapshot:v1'
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
export type HomeSnapshot = {
worktreeInfo: Record<string, HostWorktreeInfo>
accountsByHost: Record<string, AccountsSnapshot>
savedAt: number
}
let memoryCache: HomeSnapshot | null = null
let writeTimer: ReturnType<typeof setTimeout> | null = null
export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
if (memoryCache) return memoryCache
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as HomeSnapshot
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof parsed.worktreeInfo !== 'object' ||
typeof parsed.accountsByHost !== 'object'
) {
return null
}
memoryCache = parsed
return parsed
} catch {
return null
}
}
// Why: throttle writes so a flurry of streamed account-snapshot updates
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
memoryCache = snapshot
if (writeTimer) clearTimeout(writeTimer)
writeTimer = setTimeout(() => {
writeTimer = null
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
}, 250)
}

View File

@ -0,0 +1,155 @@
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
// Why: keep these shapes in lockstep with src/shared/types.ts and
// src/shared/rate-limit-types.ts. We don't import from desktop here because
// the mobile bundle must not pull in Electron-coupled type files.
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
resetsAt: number | null
resetDescription: string | null
}
export type ProviderRateLimits = {
provider: 'claude' | 'codex' | 'gemini' | 'opencode-go'
session: RateLimitWindow | null
weekly: RateLimitWindow | null
monthly?: RateLimitWindow | null
updatedAt: number
error: string | null
status: 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable'
}
export type InactiveAccountUsage = {
accountId: string
claude: ProviderRateLimits | null
updatedAt: number
isFetching: boolean
}
export type ClaudeAccountSummary = {
id: string
email: string
organizationName?: string | null
}
export type CodexAccountSummary = {
id: string
email: string
workspaceLabel?: string | null
}
export type AccountsSnapshot = {
claude: { accounts: ClaudeAccountSummary[]; activeAccountId: string | null }
codex: { accounts: CodexAccountSummary[]; activeAccountId: string | null }
rateLimits: {
claude: ProviderRateLimits | null
codex: ProviderRateLimits | null
inactiveClaudeAccounts: InactiveAccountUsage[]
inactiveCodexAccounts: InactiveAccountUsage[]
}
}
export type ProviderKey = 'claude' | 'codex'
export function getActiveProviderRateLimits(
snapshot: AccountsSnapshot,
provider: ProviderKey
): ProviderRateLimits | null {
return provider === 'claude' ? snapshot.rateLimits.claude : snapshot.rateLimits.codex
}
export function getInactiveProviderUsage(
snapshot: AccountsSnapshot,
provider: ProviderKey,
accountId: string
): InactiveAccountUsage | null {
const list =
provider === 'claude'
? snapshot.rateLimits.inactiveClaudeAccounts
: snapshot.rateLimits.inactiveCodexAccounts
return list.find((u) => u.accountId === accountId) ?? null
}
// Why: matches desktop StatusBar convention — bars show percent remaining
// (so a fresh account renders full, a depleted one renders empty), not
// percent used. Color thresholds invert accordingly.
export function UsageBar({
label,
usedPercent,
unavailable,
loading
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
}) {
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
const barColor =
remaining == null
? colors.textMuted
: remaining <= 10
? colors.statusRed
: remaining <= 30
? colors.statusAmber
: colors.statusGreen
return (
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${remaining ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
usageBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flex: 1
},
usageLabel: {
fontSize: typography.metaSize,
color: colors.textMuted,
width: 22
},
usageTrack: {
flex: 1,
height: 6,
borderRadius: 3,
backgroundColor: colors.bgRaised,
overflow: 'hidden'
},
usageFill: {
height: '100%',
borderRadius: 3
},
usageValue: {
fontSize: typography.metaSize,
color: colors.textSecondary,
width: 36,
textAlign: 'right'
},
usageSpinner: {
width: 36
}
})

View File

@ -0,0 +1,30 @@
import Svg, { Path } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
// Why: SVG paths sourced from the desktop codebase
// (src/renderer/src/components/status-bar/icons.tsx) so mobile and desktop
// stay visually identical for Claude/Codex branding.
export function ClaudeIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fill="#D97757"
fillRule="nonzero"
/>
</Svg>
)
}
export function OpenAIIcon({ size = 16, color }: { size?: number; color?: string }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
fill={color ?? colors.textPrimary}
fillRule="evenodd"
/>
</Svg>
)
}

View File

@ -16,6 +16,7 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
type Repo = {
@ -91,34 +92,10 @@ const AGENT_COMMANDS: Record<string, string> = {
// ── Agent icons ─────────────────────────────────────────────────────
// SVG paths sourced from the desktop codebase:
// Claude & OpenAI: src/renderer/src/components/status-bar/icons.tsx
// Claude & OpenAI: shared in ./AgentIcons.tsx
// Pi & Aider: src/renderer/src/lib/agent-catalog.tsx
// Agents with a faviconDomain use Google's favicon service (same as desktop).
function ClaudeIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
fill="#D97757"
fillRule="nonzero"
/>
</Svg>
)
}
function OpenAIIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 24 24">
<Path
d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"
fill={colors.textPrimary}
fillRule="evenodd"
/>
</Svg>
)
}
function PiIcon({ size = 16 }: { size?: number }) {
return (
<Svg width={size} height={size} viewBox="0 0 800 800">

View File

@ -439,6 +439,7 @@ app.whenReady().then(async () => {
// and defeat the teardown helper's prefix sweep (design §4.3 wire-up).
getLocalProvider: () => getLocalPtyProvider()
})
runtime.setAccountServices({ claudeAccounts, codexAccounts, rateLimits })
starNag = new StarNagService(store, stats)
starNag.start()
starNag.registerIpcHandlers()

View File

@ -74,9 +74,17 @@ export class RateLimitService {
private inactiveCodexFetching = new Set<string>()
private lastInactiveClaudeFetchAt = 0
private lastInactiveCodexFetchAt = 0
private stateListeners = new Set<(state: RateLimitState) => void>()
constructor() {}
onStateChange(listener: (state: RateLimitState) => void): () => void {
this.stateListeners.add(listener)
return () => {
this.stateListeners.delete(listener)
}
}
setCodexHomePathResolver(resolver: () => string | null): void {
this.codexHomePathResolver = resolver
}
@ -757,9 +765,17 @@ export class RateLimitService {
}
private pushToRenderer(): void {
const state = this.getState()
for (const listener of this.stateListeners) {
try {
listener(state)
} catch {
// ignore — one bad listener must not break the others
}
}
if (!this.mainWindow || this.mainWindow.isDestroyed()) {
return
}
this.mainWindow.webContents.send('rateLimits:update', this.getState())
this.mainWindow.webContents.send('rateLimits:update', state)
}
}

View File

@ -134,6 +134,23 @@ import { HeadlessEmulator } from '../daemon/headless-emulator'
import { killAllProcessesForWorktree } from './worktree-teardown'
import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits'
import type { IPtyProvider } from '../providers/types'
import type { ClaudeAccountService } from '../claude-accounts/service'
import type { CodexAccountService } from '../codex-accounts/service'
import type { RateLimitService } from '../rate-limits/service'
import type { ClaudeRateLimitAccountsState, CodexRateLimitAccountsState } from '../../shared/types'
import type { RateLimitState } from '../../shared/rate-limit-types'
type RuntimeAccountServices = {
claudeAccounts: ClaudeAccountService
codexAccounts: CodexAccountService
rateLimits: RateLimitService
}
export type AccountsSnapshot = {
claude: ClaudeRateLimitAccountsState
codex: CodexRateLimitAccountsState
rateLimits: RateLimitState
}
type RuntimeStore = {
getRepos: Store['getRepos']
@ -357,6 +374,12 @@ export class OrcaRuntimeService {
Set<(event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void>
>()
private subscriptionCleanups = new Map<string, () => void>()
// Why: index of subscriptionIds by per-WebSocket connectionId so the
// server can sweep all subscriptions for a closing socket without
// touching subscriptions on other live sockets that share the same
// deviceToken (multi-screen mobile).
private subscriptionsByConnection = new Map<string, Set<string>>()
private subscriptionConnectionByEntry = new Map<string, string>()
// Why: mobile clients subscribe to desktop notifications via
// notifications.subscribe. This set enables fan-out — each connected
// mobile client gets its own listener, and dispatchMobileNotification
@ -511,6 +534,7 @@ export class OrcaRuntimeService {
private fetchInflight = new Map<string, Promise<void>>()
private fetchLastCompletedAt = new Map<string, number>()
private readonly getLocalProviderFn: (() => IPtyProvider) | null
private accountServices: RuntimeAccountServices | null = null
constructor(
store: RuntimeStore | null = null,
@ -1130,7 +1154,11 @@ export class OrcaRuntimeService {
return { ptyId: leaf.ptyId }
}
registerSubscriptionCleanup(subscriptionId: string, cleanup: () => void): void {
registerSubscriptionCleanup(
subscriptionId: string,
cleanup: () => void,
connectionId?: string
): void {
// Why: mobile clients reconnect frequently (phone lock, network switch).
// The RPC client re-sends terminal.subscribe on reconnect, creating a new
// handler before the old one is cleaned up. Without this, the old data
@ -1138,18 +1166,57 @@ export class OrcaRuntimeService {
const existing = this.subscriptionCleanups.get(subscriptionId)
if (existing) {
existing()
// Why: existing() already evicts itself from the per-connection index
// via cleanupSubscription, so no extra bookkeeping is needed here.
}
this.subscriptionCleanups.set(subscriptionId, cleanup)
if (connectionId) {
let set = this.subscriptionsByConnection.get(connectionId)
if (!set) {
set = new Set()
this.subscriptionsByConnection.set(connectionId, set)
}
set.add(subscriptionId)
this.subscriptionConnectionByEntry.set(subscriptionId, connectionId)
}
}
cleanupSubscription(subscriptionId: string): void {
const cleanup = this.subscriptionCleanups.get(subscriptionId)
if (cleanup) {
this.subscriptionCleanups.delete(subscriptionId)
const connectionId = this.subscriptionConnectionByEntry.get(subscriptionId)
if (connectionId) {
this.subscriptionConnectionByEntry.delete(subscriptionId)
const set = this.subscriptionsByConnection.get(connectionId)
if (set) {
set.delete(subscriptionId)
if (set.size === 0) {
this.subscriptionsByConnection.delete(connectionId)
}
}
}
cleanup()
}
}
// Why: invoked from the WebSocket transport's on-close hook so streaming
// listeners registered for this exact socket get torn down even when other
// sockets sharing the same deviceToken are still alive (multi-screen
// mobile). Without this sweep, listeners leak across every reconnect.
cleanupSubscriptionsForConnection(connectionId: string): void {
const set = this.subscriptionsByConnection.get(connectionId)
if (!set) {
return
}
// Why: snapshot the ids before iterating because cleanupSubscription
// mutates both the set and the index map.
const ids = Array.from(set)
for (const id of ids) {
this.cleanupSubscription(id)
}
}
// Why: mobile clients subscribe via notifications.subscribe streaming RPC.
// Each subscriber gets its own listener. Returns an unsubscribe function
// that the subscription cleanup mechanism calls on disconnect.
@ -1170,6 +1237,73 @@ export class OrcaRuntimeService {
}
}
// ─── Account Services (mobile RPC bridge) ─────────────────────
setAccountServices(services: RuntimeAccountServices): void {
this.accountServices = services
}
private requireAccountServices(): RuntimeAccountServices {
if (!this.accountServices) {
throw new Error('Account services are not configured on this runtime')
}
return this.accountServices
}
getAccountsSnapshot(): AccountsSnapshot {
const { claudeAccounts, codexAccounts, rateLimits } = this.requireAccountServices()
return {
claude: claudeAccounts.listAccounts(),
codex: codexAccounts.listAccounts(),
rateLimits: rateLimits.getState()
}
}
// Why: RateLimitService polls only when the Electron window is visible AND
// focused, and the inactive-account caches fill lazily when the user opens
// the desktop AccountsPane. Mobile has neither trigger, so without this the
// phone shows 0% / "—" against a backgrounded desktop. Errors swallowed
// because partial usage is still useful for the rest of the snapshot.
async refreshAccountsForMobile(): Promise<void> {
const { rateLimits } = this.requireAccountServices()
await Promise.allSettled([
rateLimits.refresh(),
rateLimits.fetchInactiveClaudeAccountsOnOpen(),
rateLimits.fetchInactiveCodexAccountsOnOpen()
])
}
selectClaudeAccount(accountId: string | null): Promise<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.selectAccount(accountId)
}
selectCodexAccount(accountId: string | null): Promise<CodexRateLimitAccountsState> {
return this.requireAccountServices().codexAccounts.selectAccount(accountId)
}
removeClaudeAccount(accountId: string): Promise<ClaudeRateLimitAccountsState> {
return this.requireAccountServices().claudeAccounts.removeAccount(accountId)
}
removeCodexAccount(accountId: string): Promise<CodexRateLimitAccountsState> {
return this.requireAccountServices().codexAccounts.removeAccount(accountId)
}
// Why: rate-limit polling fires every 5 minutes and on account switch.
// Mobile clients subscribe to receive a fresh AccountsSnapshot whenever
// RateLimitService pushes new usage data, mirroring the existing
// `rateLimits:update` IPC channel desktop already uses.
onAccountsChanged(listener: (snapshot: AccountsSnapshot) => void): () => void {
const services = this.requireAccountServices()
return services.rateLimits.onStateChange(() => {
listener({
claude: services.claudeAccounts.listAccounts(),
codex: services.codexAccounts.listAccounts(),
rateLimits: services.rateLimits.getState()
})
})
}
// ─── Mobile Fit Override Management ─────────────────────────
resizeForClient(

View File

@ -46,6 +46,12 @@ export type RpcContext = {
// runtime-rpc transport (direct in-process callers don't need it).
// See design doc §3.1 counter-lifecycle.
signal?: AbortSignal
// Why: streaming handlers (notifications/accounts/terminal subscribe)
// register cleanup callbacks against the runtime so reconnects don't leak
// listeners. Keying those cleanups by per-WebSocket connectionId lets the
// server reap all subscriptions for a closing socket, even when other
// sockets for the same deviceToken stay alive (multi-screen mobile).
connectionId?: string
}
export type RpcHandler<TParams> = (params: TParams, ctx: RpcContext) => Promise<unknown> | unknown

View File

@ -74,7 +74,11 @@ export class RpcDispatcher {
// Why: streaming dispatch sends multiple responses through the reply callback
// instead of returning a single Promise. This enables terminal.subscribe and
// other subscription-style methods that push data over time.
async dispatchStreaming(request: RpcRequest, reply: (response: string) => void): Promise<void> {
async dispatchStreaming(
request: RpcRequest,
reply: (response: string) => void,
options?: { connectionId?: string }
): Promise<void> {
const meta = this.meta()
const method = this.registry.get(request.method)
if (!method) {
@ -94,7 +98,10 @@ export class RpcDispatcher {
if (!isStreamingMethod(method)) {
try {
const result = await method.handler(parsedParams.value, { runtime: this.runtime })
const result = await method.handler(parsedParams.value, {
runtime: this.runtime,
connectionId: options?.connectionId
})
reply(JSON.stringify(successResponse(request.id, meta, result)))
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
@ -109,7 +116,11 @@ export class RpcDispatcher {
}
try {
await method.handler(parsedParams.value, { runtime: this.runtime }, emit)
await method.handler(
parsedParams.value,
{ runtime: this.runtime, connectionId: options?.connectionId },
emit
)
} catch (error) {
reply(JSON.stringify(this.mapError(request, meta, error)))
}

View File

@ -0,0 +1,111 @@
import { z } from 'zod'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core'
// Why: monotonically increasing per-process counter avoids the Date.now()
// collision that fired when two near-simultaneous accounts.subscribe calls
// collided on the same millisecond and one evicted the other through
// registerSubscriptionCleanup's existing-key eviction path.
let accountsSubscriptionSeq = 0
const SelectAccountParams = z.object({
accountId: z
.union([z.string().min(1, 'Missing accountId'), z.null()])
.transform((v) => (v === null ? null : v))
})
const RemoveAccountParams = z.object({
accountId: z.string().min(1, 'Missing accountId')
})
const AccountsUnsubscribeParams = z.object({
subscriptionId: z
.unknown()
.transform((value) => (typeof value === 'string' && value.length > 0 ? value : ''))
.pipe(z.string().min(1, 'Missing subscriptionId'))
})
// Why: bridges the desktop ClaudeAccountService / CodexAccountService /
// RateLimitService into the mobile WebSocket RPC. Read + switch + remove
// only — interactive add/re-auth flows spawn `claude login` / `codex login`
// PTYs that need a desktop browser, so they intentionally remain
// desktop-only. See plan in spec doc for issue #1438.
export const ACCOUNT_METHODS: readonly RpcAnyMethod[] = [
defineMethod({
name: 'accounts.list',
params: null,
handler: async (_params, { runtime }) => {
// Why: ensure the snapshot reflects the latest provider state before
// returning. Desktop polling pauses when the window is unfocused and
// inactive-account caches only fill on AccountsPane open, so without
// this the mobile UI would render stale nulls / zeroes.
await runtime.refreshAccountsForMobile()
return runtime.getAccountsSnapshot()
}
}),
defineMethod({
name: 'accounts.selectClaude',
params: SelectAccountParams,
handler: async (params, { runtime }) => runtime.selectClaudeAccount(params.accountId)
}),
defineMethod({
name: 'accounts.selectCodex',
params: SelectAccountParams,
handler: async (params, { runtime }) => runtime.selectCodexAccount(params.accountId)
}),
defineMethod({
name: 'accounts.removeClaude',
params: RemoveAccountParams,
handler: async (params, { runtime }) => runtime.removeClaudeAccount(params.accountId)
}),
defineMethod({
name: 'accounts.removeCodex',
params: RemoveAccountParams,
handler: async (params, { runtime }) => runtime.removeCodexAccount(params.accountId)
}),
// Why: streaming counterpart so mobile usage bars refresh in place when the
// desktop's 5-minute rate-limit poll completes or when the user switches
// accounts on either side. Mirrors the notifications.subscribe pattern.
defineStreamingMethod({
name: 'accounts.subscribe',
params: null,
handler: async (_params, { runtime, connectionId }, emit) => {
await new Promise<void>((resolve) => {
const unsubscribe = runtime.onAccountsChanged((snapshot) => {
emit({ type: 'snapshot', snapshot })
})
// Why: scope the id by connectionId so two sockets from the same
// device (host + accounts screen) cannot evict each other through
// registerSubscriptionCleanup's "existing key" branch, and append a
// per-process counter so two concurrent subscribes on the same
// socket also can't collide.
const seq = ++accountsSubscriptionSeq
const subscriptionId = `accounts-${connectionId ?? 'inproc'}-${seq}`
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
unsubscribe()
emit({ type: 'end' })
resolve()
},
connectionId
)
// Why: emit the current snapshot synchronously so the phone has
// something to render immediately, then kick a forced refresh that
// will broadcast a fresh snapshot through the listener once each
// provider fetch completes.
emit({ type: 'ready', subscriptionId, snapshot: runtime.getAccountsSnapshot() })
void runtime.refreshAccountsForMobile()
})
}
}),
defineMethod({
name: 'accounts.unsubscribe',
params: AccountsUnsubscribeParams,
handler: async (params, { runtime }) => {
runtime.cleanupSubscription(params.subscriptionId)
return { unsubscribed: true }
}
})
]

View File

@ -8,6 +8,7 @@ import { BROWSER_EXTRA_METHODS } from './browser-extras'
import { ORCHESTRATION_METHODS } from './orchestration'
import { NOTIFICATION_METHODS } from './notifications'
import { STATS_METHODS } from './stats'
import { ACCOUNT_METHODS } from './accounts'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@ -21,5 +22,6 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [
...BROWSER_EXTRA_METHODS,
...ORCHESTRATION_METHODS,
...NOTIFICATION_METHODS,
...STATS_METHODS
...STATS_METHODS,
...ACCOUNT_METHODS
]

View File

@ -1,6 +1,11 @@
import { z } from 'zod'
import { defineStreamingMethod, defineMethod, type RpcAnyMethod } from '../core'
// Why: monotonically increasing per-process counter eliminates the
// Date.now() collision that could fire when two near-simultaneous
// notifications.subscribe calls landed on the same millisecond.
let notificationsSubscriptionSeq = 0
const NotificationUnsubscribeParams = z.object({
subscriptionId: z
.unknown()
@ -16,18 +21,25 @@ export const NOTIFICATION_METHODS: readonly RpcAnyMethod[] = [
defineStreamingMethod({
name: 'notifications.subscribe',
params: null,
handler: async (_params, { runtime }, emit) => {
handler: async (_params, { runtime, connectionId }, emit) => {
await new Promise<void>((resolve) => {
const unsubscribe = runtime.onNotificationDispatched((event) => {
emit({ type: 'notification', ...event })
})
const subscriptionId = `notifications-${Date.now()}`
runtime.registerSubscriptionCleanup(subscriptionId, () => {
unsubscribe()
emit({ type: 'end' })
resolve()
})
// Why: scope by per-ws connectionId + per-process counter so
// concurrent subscribes never collide on the cleanup map.
const seq = ++notificationsSubscriptionSeq
const subscriptionId = `notifications-${connectionId ?? 'inproc'}-${seq}`
runtime.registerSubscriptionCleanup(
subscriptionId,
() => {
unsubscribe()
emit({ type: 'end' })
resolve()
},
connectionId
)
emit({ type: 'ready', subscriptionId })
})

View File

@ -28,7 +28,9 @@ export class WebSocketTransport implements RpcTransport {
private messageHandler:
| ((msg: string, reply: (response: string) => void, ws: WebSocket) => void)
| null = null
private connectionCloseHandler: ((clientId: string) => void) | null = null
private connectionCloseHandler:
| ((clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void)
| null = null
// Why: maps each WebSocket to the clientId (deviceToken) that authenticated it,
// so ws.on('close') can notify the runtime which mobile client disconnected.
private wsClientIds = new Map<WebSocket, string>()
@ -46,7 +48,15 @@ export class WebSocketTransport implements RpcTransport {
this.messageHandler = handler
}
onConnectionClose(handler: (clientId: string) => void): void {
// Why: handlers receive the closing `ws` so per-connection state can be
// targeted exactly (one paired device may hold multiple concurrent sockets,
// e.g. host screen + accounts screen). `hasOtherConnections` tells the
// runtime whether other sockets for the same deviceToken are still alive,
// so client-scoped teardown (mobile-fit overrides, etc.) only fires on the
// last disconnect.
onConnectionClose(
handler: (clientId: string, ws: WebSocket, hasOtherConnections: boolean) => void
): void {
this.connectionCloseHandler = handler
}
@ -178,7 +188,13 @@ export class WebSocketTransport implements RpcTransport {
const clientId = this.wsClientIds.get(ws)
this.wsClientIds.delete(ws)
if (clientId) {
this.connectionCloseHandler?.(clientId)
// Why: a paired device may have multiple concurrent sockets open
// (e.g. one per app screen). Per-client teardown must only fire when
// the last socket for this token closes — otherwise closing the
// accounts-screen socket would clobber the host-screen socket's
// state and strand it in a non-functional state until re-paired.
const hasOtherConnections = Array.from(this.wsClientIds.values()).includes(clientId)
this.connectionCloseHandler?.(clientId, ws, hasOtherConnections)
}
})

View File

@ -88,6 +88,10 @@ export class OrcaRuntimeRpcServer {
// Why: each WebSocket connection has its own E2EE channel that manages the
// handshake and encrypt/decrypt lifecycle. Keyed by WebSocket instance.
private e2eeChannels = new Map<WebSocket, E2EEChannel>()
// Why: stable per-WebSocket id used as the cleanup key for streaming
// subscriptions, so the server can reap a closing socket's subscriptions
// without affecting other live sockets that share the same deviceToken.
private wsConnectionIds = new Map<WebSocket, string>()
// Why: separate from Node's server.maxConnections because we need to count
// only long-running dispatches, not every in-flight short RPC. See §3.1 +
// §7 risk #2.
@ -220,6 +224,11 @@ export class OrcaRuntimeRpcServer {
wsTransport.onMessage((msg, _reply, ws) => {
let channel = this.e2eeChannels.get(ws)
if (!channel) {
// Why: stable per-ws id used as the cleanup-index key for
// streaming subscriptions, so the server can reap them exactly
// when this socket closes (without affecting other live sockets
// that share the same deviceToken).
this.wsConnectionIds.set(ws, randomBytes(8).toString('hex'))
channel = new E2EEChannel(ws, {
serverSecretKey: this.e2eeKeypair!.secretKey,
validateToken: (token) => this.deviceRegistry?.validateToken(token) != null,
@ -251,16 +260,28 @@ export class OrcaRuntimeRpcServer {
// Why: when a mobile client disconnects, the runtime must clean up
// connection-scoped state like mobile-fit overrides and the E2EE
// channel to prevent orphaned state.
wsTransport.onConnectionClose((clientId) => {
for (const [ws, channel] of this.e2eeChannels) {
if (channel.deviceToken === clientId) {
channel.destroy()
this.e2eeChannels.delete(ws)
break
}
// channel to prevent orphaned state. A single paired device can hold
// multiple concurrent sockets (host screen + accounts screen, etc.),
// so destroy the channel for THIS exact ws and skip the per-client
// teardown when other sockets for the same token are still alive.
wsTransport.onConnectionClose((clientId, ws, hasOtherConnections) => {
// Why: sweep streaming subscriptions for THIS ws regardless of
// hasOtherConnections, so per-ws listeners (notifications,
// accounts, terminal) don't leak across reconnects. This is
// independent of the deviceToken-scoped onClientDisconnected.
const connectionId = this.wsConnectionIds.get(ws)
if (connectionId) {
this.runtime.cleanupSubscriptionsForConnection(connectionId)
this.wsConnectionIds.delete(ws)
}
const channel = this.e2eeChannels.get(ws)
if (channel) {
channel.destroy()
this.e2eeChannels.delete(ws)
}
if (!hasOtherConnections) {
this.runtime.onClientDisconnected(clientId)
}
this.runtime.onClientDisconnected(clientId)
})
await wsTransport.start()
@ -429,7 +450,8 @@ export class OrcaRuntimeRpcServer {
wsTransport.setClientId(ws, token)
}
await this.dispatcher.dispatchStreaming(request, reply)
const connectionId = ws ? this.wsConnectionIds.get(ws) : undefined
await this.dispatcher.dispatchStreaming(request, reply, { connectionId })
}
private buildError(id: string, code: string, message: string): RpcResponse {