feat(mobile): account switcher and rate-limit usage on mobile (#1467)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
ddb732cf34
commit
f938ff185f
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 ─── */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
})
|
||||
]
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue