From 810f780713f3cf41d8a88c6293dd4c169deb211e Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Wed, 29 Apr 2026 21:02:24 -0400 Subject: [PATCH] feat(mobile): polish home screen UI and improve navigation UX - Move status dot inline with status text, remove icon overlay ring - Fix worktree.ps response parsing (returns {worktrees:[...]} not array) - Fix Resume card route, sizing, and text alignment to match host cards - Rename stat labels to be more explicit (Agents spawned, PRs created) - Wire New Worktree button to navigate to host detail with auto-open drawer - Cache worktrees for instant host detail page loads - Defer RPC connection until after navigation animation - Remove desktop icon and greeting from onboarding screen - Request OS notification permissions when toggle is enabled Co-authored-by: Orca --- mobile/app/h/[hostId]/index.tsx | 53 +++++++++----- mobile/app/index.tsx | 108 +++++++++++------------------ mobile/app/notifications.tsx | 5 ++ mobile/src/cache/worktree-cache.ts | 26 +++++++ 4 files changed, 106 insertions(+), 86 deletions(-) create mode 100644 mobile/src/cache/worktree-cache.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index d59d0de55dd..81fb02a001c 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -37,6 +37,7 @@ import { PickerModal, type PickerOption } from '../../../src/components/PickerMo import { ActionSheetContent } from '../../../src/components/ActionSheetModal' import { ConfirmModal } from '../../../src/components/ConfirmModal' import { BottomDrawer } from '../../../src/components/BottomDrawer' +import { getCachedWorktrees } from '../../../src/cache/worktree-cache' import { colors, spacing, typography } from '../../../src/theme/mobile-theme' import { loadPinnedIds, @@ -232,15 +233,16 @@ function buildSections( } export default function HostScreen() { - const { hostId } = useLocalSearchParams<{ hostId: string }>() + const { hostId, action } = useLocalSearchParams<{ hostId: string; action?: string }>() const router = useRouter() + const cached = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null const [client, setClient] = useState(null) const [connState, setConnState] = useState('disconnected') - const [worktrees, setWorktrees] = useState([]) - const [worktreesLoaded, setWorktreesLoaded] = useState(false) + const [worktrees, setWorktrees] = useState(cached ?? []) + const [worktreesLoaded, setWorktreesLoaded] = useState(cached != null) const [hostName, setHostName] = useState('') const [error, setError] = useState('') - const [lastKnownWorktrees, setLastKnownWorktrees] = useState([]) + const [lastKnownWorktrees, setLastKnownWorktrees] = useState(cached ?? []) const [search, setSearch] = useState('') const [showSearch, setShowSearch] = useState(false) const [sortMode, setSortMode] = useState('smart') @@ -265,6 +267,10 @@ export default function HostScreen() { const [_prefsLoaded, setPrefsLoaded] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) + useEffect(() => { + if (action === 'newWorktree') setShowNewWorktree(true) + }, [action]) + // Load persisted pins and preferences useEffect(() => { if (!hostId) return @@ -284,25 +290,36 @@ export default function HostScreen() { useEffect(() => { let rpcClient: RpcClient | null = null - setWorktreesLoaded(false) - setWorktrees([]) + const hasCached = hostId ? getCachedWorktrees(hostId) != null : false + if (!hasCached) { + setWorktreesLoaded(false) + setWorktrees([]) + } - void (async () => { - const hosts = await loadHosts() - const host = hosts.find((h) => h.id === hostId) - if (!host) { - setError('Host not found') - return - } + // Why: defer the RPC connection until after the navigation animation + // completes. Without this, connect() and loadHosts() block the JS + // thread during mount, delaying the screen transition by ~200-400ms. + // With cached worktrees the user sees content instantly; the live + // connection starts once the animation settles. + const rafId = requestAnimationFrame(() => { + void (async () => { + const hosts = await loadHosts() + const host = hosts.find((h) => h.id === hostId) + if (!host) { + setError('Host not found') + return + } - setHostName(host.name) - rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) - setClient(rpcClient) + setHostName(host.name) + rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState) + setClient(rpcClient) - await updateLastConnected(host.id) - })() + await updateLastConnected(host.id) + })() + }) return () => { + cancelAnimationFrame(rafId) rpcClient?.close() } }, [hostId]) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 593aca783a2..96aefc353e5 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -24,6 +24,7 @@ import { StatusDot } from '../src/components/StatusDot' import { TextInputModal } from '../src/components/TextInputModal' import { ActionSheetModal } from '../src/components/ActionSheetModal' import { ConfirmModal } from '../src/components/ConfirmModal' +import { setCachedWorktrees } from '../src/cache/worktree-cache' import { colors, spacing, radii, typography } from '../src/theme/mobile-theme' function endpointLabel(endpoint: string): string { @@ -100,7 +101,9 @@ function fetchWorktreeInfo( .sendRequest('worktree.ps') .then((response) => { if (response.ok) { - const worktrees = response.result as WorktreeSummary[] + const result = response.result as { worktrees: WorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) const activeStatuses = new Set(['working', 'active', 'permission']) const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) const lastActive = active.length > 0 ? active[0] : (worktrees[0] ?? null) @@ -263,14 +266,7 @@ export default function HomeScreen() { {hosts.length === 0 ? ( /* ─── Empty state: onboarding ─── */ - - Welcome to Orca - - - - - Connect your desktop Pair with Orca on your computer to monitor worktrees, watch agents work, and manage @@ -318,7 +314,7 @@ export default function HomeScreen() { {stats.totalAgentsSpawned.toLocaleString()} - Agents + Agents spawned @@ -332,7 +328,7 @@ export default function HomeScreen() { {stats.totalPRsCreated.toLocaleString()} - PRs + PRs created )} @@ -360,12 +356,6 @@ export default function HomeScreen() { size={20} color={connected ? colors.textPrimary : colors.textSecondary} /> - - {connected && info ? ( - <> - - {info.totalWorktrees} worktree{info.totalWorktrees !== 1 ? 's' : ''} - - {info.activeCount > 0 && ( - <> - - - {info.activeCount} active - - - )} - - ) : ( - {STATUS_LABELS[state]} - )} + + + {STATUS_LABELS[state]} + {connected && info + ? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}` + : ''} + @@ -408,7 +393,7 @@ export default function HomeScreen() { style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]} onPress={() => router.push( - `/h/${resumeWorktree.hostId}/terminal/${resumeWorktree.worktree.worktreeId}` + `/h/${resumeWorktree.hostId}/session/${encodeURIComponent(resumeWorktree.worktree.worktreeId)}` ) } > @@ -667,16 +652,6 @@ const styles = StyleSheet.create({ marginRight: 14, position: 'relative' }, - hostStatusRing: { - position: 'absolute', - bottom: -2, - right: -2, - width: 14, - height: 14, - borderRadius: 7, - borderWidth: 2, - borderColor: colors.bgPanel - }, hostMain: { flex: 1, minWidth: 0, @@ -691,6 +666,7 @@ const styles = StyleSheet.create({ hostMeta: { flexDirection: 'row', alignItems: 'center', + gap: 6, marginTop: 3 }, hostMetaItem: { @@ -704,6 +680,11 @@ const styles = StyleSheet.create({ backgroundColor: colors.textMuted, marginHorizontal: 8 }, + statusDot: { + width: 7, + height: 7, + borderRadius: 3.5 + }, /* ─── Resume card ─── */ resumeCard: { @@ -713,16 +694,18 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: colors.borderSubtle, borderRadius: radii.card, - padding: 14, - gap: 12 + paddingLeft: spacing.md, + paddingRight: spacing.md, + paddingVertical: 14 }, resumeIcon: { - width: 40, - height: 40, - borderRadius: 10, - backgroundColor: 'rgba(255,255,255,0.04)', + width: 46, + height: 46, + borderRadius: 13, + backgroundColor: colors.bgRaised, alignItems: 'center', - justifyContent: 'center' + justifyContent: 'center', + marginRight: 14 }, resumeMain: { flex: 1, @@ -737,16 +720,16 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 6, - marginTop: 2 + marginTop: 3 }, repoDot: { - width: 6, - height: 6, - borderRadius: 3 + width: 7, + height: 7, + borderRadius: 3.5 }, resumeSubText: { - fontSize: 11, - color: colors.textMuted, + fontSize: 12, + color: colors.textSecondary, flex: 1 }, @@ -796,17 +779,6 @@ const styles = StyleSheet.create({ paddingHorizontal: 32, paddingBottom: 40 }, - emptyGlyph: { - width: 80, - height: 80, - borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.04)', - borderWidth: 1, - borderColor: colors.borderSubtle, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 28 - }, emptyTitle: { fontSize: 22, fontWeight: '700', diff --git a/mobile/app/notifications.tsx b/mobile/app/notifications.tsx index 9b1dde58145..7b0fdf9cd43 100644 --- a/mobile/app/notifications.tsx +++ b/mobile/app/notifications.tsx @@ -8,6 +8,7 @@ import { loadPushNotificationsEnabled, savePushNotificationsEnabled } from '../src/storage/preferences' +import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications' export default function NotificationsScreen() { const router = useRouter() @@ -21,6 +22,10 @@ export default function NotificationsScreen() { ) const togglePush = async (value: boolean) => { + if (value) { + const granted = await ensureNotificationPermissions() + if (!granted) return + } setPushEnabled(value) await savePushNotificationsEnabled(value) } diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts new file mode 100644 index 00000000000..1f59e369874 --- /dev/null +++ b/mobile/src/cache/worktree-cache.ts @@ -0,0 +1,26 @@ +// Why: module-level cache lets the home screen pre-populate worktree data +// so the host detail page can render instantly on navigation instead of +// waiting for a fresh RPC connection + fetch cycle. + +type CachedWorktrees = { + worktrees: unknown[] + at: number +} + +const cache = new Map() + +const MAX_AGE_MS = 30_000 + +export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void { + cache.set(hostId, { worktrees, at: Date.now() }) +} + +export function getCachedWorktrees(hostId: string): unknown[] | null { + const entry = cache.get(hostId) + if (!entry) return null + if (Date.now() - entry.at > MAX_AGE_MS) { + cache.delete(hostId) + return null + } + return entry.worktrees +}