mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
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:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user