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 <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-04-30 18:03:55 -04:00
co-authored by Orca
parent a51561548c
commit 810f780713
4 changed files with 106 additions and 86 deletions
+35 -18
View File
@@ -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<RpcClient | null>(null)
const [connState, setConnState] = useState<ConnectionState>('disconnected')
const [worktrees, setWorktrees] = useState<Worktree[]>([])
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
const [worktrees, setWorktrees] = useState<Worktree[]>(cached ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(cached != null)
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>([])
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(cached ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
const [sortMode, setSortMode] = useState<SortMode>('smart')
@@ -265,6 +267,10 @@ export default function HostScreen() {
const [_prefsLoaded, setPrefsLoaded] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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])
+40 -68
View File
@@ -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 ─── */
<View style={styles.emptyContainer}>
<View style={styles.emptyGreeting}>
<Text style={styles.heroTitle}>Welcome to Orca</Text>
</View>
<View style={styles.emptyHero}>
<View style={styles.emptyGlyph}>
<Monitor size={36} color={colors.textSecondary} strokeWidth={1.5} />
</View>
<Text style={styles.emptyTitle}>Connect your desktop</Text>
<Text style={styles.emptyBody}>
Pair with Orca on your computer to monitor worktrees, watch agents work, and manage
@@ -318,7 +314,7 @@ export default function HomeScreen() {
<Text style={styles.statValue}>
{stats.totalAgentsSpawned.toLocaleString()}
</Text>
<Text style={styles.statLabel}>Agents</Text>
<Text style={styles.statLabel}>Agents spawned</Text>
</View>
<View style={styles.statCard}>
<View style={styles.statIcon}>
@@ -332,7 +328,7 @@ export default function HomeScreen() {
<GitPullRequest size={14} color={colors.textMuted} />
</View>
<Text style={styles.statValue}>{stats.totalPRsCreated.toLocaleString()}</Text>
<Text style={styles.statLabel}>PRs</Text>
<Text style={styles.statLabel}>PRs created</Text>
</View>
</View>
)}
@@ -360,12 +356,6 @@ export default function HomeScreen() {
size={20}
color={connected ? colors.textPrimary : colors.textSecondary}
/>
<View
style={[
styles.hostStatusRing,
{ backgroundColor: connected ? colors.statusGreen : colors.textMuted }
]}
/>
</View>
<View style={styles.hostMain}>
<Text
@@ -375,23 +365,18 @@ export default function HomeScreen() {
{item.name}
</Text>
<View style={styles.hostMeta}>
{connected && info ? (
<>
<Text style={styles.hostMetaItem}>
{info.totalWorktrees} worktree{info.totalWorktrees !== 1 ? 's' : ''}
</Text>
{info.activeCount > 0 && (
<>
<View style={styles.hostMetaDot} />
<Text style={[styles.hostMetaItem, { color: colors.statusGreen }]}>
{info.activeCount} active
</Text>
</>
)}
</>
) : (
<Text style={styles.hostMetaItem}>{STATUS_LABELS[state]}</Text>
)}
<View
style={[
styles.statusDot,
{ backgroundColor: connected ? colors.statusGreen : colors.textMuted }
]}
/>
<Text style={styles.hostMetaItem}>
{STATUS_LABELS[state]}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
</Text>
</View>
</View>
<ChevronRight size={16} color={colors.textMuted} />
@@ -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',
+5
View File
@@ -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)
}
+26
View File
@@ -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<string, CachedWorktrees>()
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
}