mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
- BottomDrawer: shift drawer up when keyboard opens using Keyboard events, add ScrollView for content overflow - NewWorktreeModal: replace FlatList with plain map to avoid nested VirtualizedList error - Home: replace Server icon with Monitor, hide IP from cards (show in action sheet), add connection status subtitle, update hero copy - About: remove redundant link labels, use neutral text color for URLs Co-authored-by: Orca <help@stably.ai>
412 lines
12 KiB
TypeScript
412 lines
12 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo } from 'react'
|
|
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
|
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
import { useRouter, useFocusEffect } from 'expo-router'
|
|
import { Monitor, MoreHorizontal, QrCode, Settings } from 'lucide-react-native'
|
|
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
|
|
import { connect } from '../src/transport/rpc-client'
|
|
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
|
import { triggerMediumImpact } from '../src/platform/haptics'
|
|
import { OrcaLogo } from '../src/components/OrcaLogo'
|
|
import { StatusDot } from '../src/components/StatusDot'
|
|
import { TextInputModal } from '../src/components/TextInputModal'
|
|
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
|
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
|
|
|
function endpointLabel(endpoint: string): string {
|
|
try {
|
|
const url = new URL(endpoint)
|
|
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
|
|
} catch {
|
|
return endpoint
|
|
}
|
|
}
|
|
|
|
const STATUS_LABELS: Record<ConnectionState, string> = {
|
|
connected: 'Connected',
|
|
connecting: 'Connecting…',
|
|
disconnected: 'Disconnected',
|
|
reconnecting: 'Reconnecting…',
|
|
handshaking: 'Connecting…',
|
|
'auth-failed': 'Auth failed'
|
|
}
|
|
|
|
export default function HomeScreen() {
|
|
const router = useRouter()
|
|
const [hosts, setHosts] = useState<HostProfile[]>([])
|
|
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
|
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
|
|
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
|
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
void loadHosts().then(setHosts)
|
|
}, [])
|
|
)
|
|
|
|
const sortedHosts = useMemo(
|
|
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
|
|
[hosts]
|
|
)
|
|
|
|
useEffect(() => {
|
|
let disposed = false
|
|
const clients = hosts.map((host) => {
|
|
setHostStates((prev) => ({
|
|
...prev,
|
|
[host.id]: prev[host.id] ?? 'connecting'
|
|
}))
|
|
return connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
|
|
if (disposed) return
|
|
setHostStates((prev) => ({ ...prev, [host.id]: state }))
|
|
})
|
|
})
|
|
|
|
return () => {
|
|
disposed = true
|
|
for (const client of clients) {
|
|
client.close()
|
|
}
|
|
}
|
|
}, [hosts])
|
|
|
|
async function handleRename(newName: string) {
|
|
if (!renameTarget) return
|
|
await renameHost(renameTarget.id, newName)
|
|
setRenameTarget(null)
|
|
setHosts(await loadHosts())
|
|
}
|
|
|
|
async function handleRemove() {
|
|
if (!confirmRemove) return
|
|
await removeHost(confirmRemove.id)
|
|
setConfirmRemove(null)
|
|
setHosts(await loadHosts())
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
<View style={styles.topBar}>
|
|
<View style={styles.brandLockup}>
|
|
<View style={styles.logoMark}>
|
|
<OrcaLogo size={18} />
|
|
</View>
|
|
<Text style={styles.brandName}>Orca</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{hosts.length === 0 ? (
|
|
<View style={styles.emptyContent}>
|
|
<Text style={styles.emptyTitle}>Pair your first desktop</Text>
|
|
<Text style={styles.emptySubtitle}>
|
|
Scan the QR code from Orca desktop to see live worktrees, terminals, and agent output
|
|
from your phone.
|
|
</Text>
|
|
<Pressable style={styles.primaryButton} onPress={() => router.push('/pair-scan')}>
|
|
<QrCode size={17} color={colors.bgBase} />
|
|
<Text style={styles.primaryButtonText}>Scan Pairing Code</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<FlatList
|
|
data={sortedHosts}
|
|
keyExtractor={(h) => h.id}
|
|
contentContainerStyle={styles.list}
|
|
ListHeaderComponent={
|
|
<View style={styles.hero}>
|
|
<Text style={styles.heroTitle}>Paired Desktops</Text>
|
|
<Text style={styles.heroSubtitle}>Monitor workspaces and terminals remotely.</Text>
|
|
</View>
|
|
}
|
|
ItemSeparatorComponent={() => <View style={styles.cardGap} />}
|
|
renderItem={({ item }) => (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
|
onPress={() => router.push(`/h/${item.id}`)}
|
|
onLongPress={() => {
|
|
triggerMediumImpact()
|
|
setActionTarget(item)
|
|
}}
|
|
delayLongPress={400}
|
|
>
|
|
<View style={styles.hostIcon}>
|
|
<Monitor size={18} color={colors.textPrimary} />
|
|
</View>
|
|
<View style={styles.hostMain}>
|
|
<View style={styles.hostTitleRow}>
|
|
<StatusDot state={hostStates[item.id] ?? 'connecting'} />
|
|
<Text style={styles.hostName} numberOfLines={1}>
|
|
{item.name}
|
|
</Text>
|
|
</View>
|
|
<Text style={styles.hostStatus} numberOfLines={1}>
|
|
{STATUS_LABELS[hostStates[item.id] ?? 'connecting']}
|
|
</Text>
|
|
</View>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.moreButton, pressed && styles.iconButtonPressed]}
|
|
onPress={() => setActionTarget(item)}
|
|
accessibilityLabel={`Manage ${item.name}`}
|
|
>
|
|
<MoreHorizontal size={18} color={colors.textSecondary} />
|
|
</Pressable>
|
|
</Pressable>
|
|
)}
|
|
ListFooterComponent={
|
|
<Pressable style={styles.pairCard} onPress={() => router.push('/pair-scan')}>
|
|
<View style={styles.pairIcon}>
|
|
<QrCode size={18} color={colors.textSecondary} />
|
|
</View>
|
|
<View style={styles.pairTextBlock}>
|
|
<Text style={styles.pairTitle}>Pair another desktop</Text>
|
|
<Text style={styles.pairSubtitle}>Scan a QR code from Orca desktop</Text>
|
|
</View>
|
|
</Pressable>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<ActionSheetModal
|
|
visible={actionTarget != null}
|
|
title={actionTarget?.name}
|
|
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
|
|
actions={[
|
|
{
|
|
label: 'Rename',
|
|
onPress: () => {
|
|
const host = actionTarget
|
|
setActionTarget(null)
|
|
if (host) setRenameTarget(host)
|
|
}
|
|
},
|
|
{
|
|
label: 'Remove',
|
|
destructive: true,
|
|
onPress: () => {
|
|
const host = actionTarget
|
|
setActionTarget(null)
|
|
if (host) setConfirmRemove(host)
|
|
}
|
|
}
|
|
]}
|
|
onClose={() => setActionTarget(null)}
|
|
/>
|
|
|
|
<TextInputModal
|
|
visible={renameTarget != null}
|
|
title="Rename Host"
|
|
message="Enter a new name for this host."
|
|
defaultValue={renameTarget?.name ?? ''}
|
|
placeholder="Host name"
|
|
onSubmit={(name) => void handleRename(name)}
|
|
onCancel={() => setRenameTarget(null)}
|
|
/>
|
|
|
|
<ActionSheetModal
|
|
visible={confirmRemove != null}
|
|
title="Remove Host"
|
|
message={`Remove "${confirmRemove?.name}"? You can re-pair later.`}
|
|
actions={[
|
|
{
|
|
label: 'Remove',
|
|
destructive: true,
|
|
onPress: () => void handleRemove()
|
|
}
|
|
]}
|
|
onClose={() => setConfirmRemove(null)}
|
|
/>
|
|
|
|
<Pressable style={styles.settingsButton} onPress={() => router.push('/settings')}>
|
|
<Settings size={16} color={colors.textMuted} />
|
|
<Text style={styles.settingsText}>Settings</Text>
|
|
</Pressable>
|
|
</SafeAreaView>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.bgBase
|
|
},
|
|
topBar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: spacing.lg,
|
|
paddingTop: spacing.sm,
|
|
paddingBottom: spacing.md
|
|
},
|
|
brandLockup: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
minWidth: 0
|
|
},
|
|
logoMark: {
|
|
marginRight: spacing.sm
|
|
},
|
|
brandName: {
|
|
color: colors.textPrimary,
|
|
fontSize: 17,
|
|
fontWeight: '700'
|
|
},
|
|
iconButtonPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
emptyContent: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: spacing.xl,
|
|
paddingBottom: spacing.xl
|
|
},
|
|
emptyTitle: {
|
|
fontSize: 23,
|
|
fontWeight: '700',
|
|
color: colors.textPrimary,
|
|
textAlign: 'center'
|
|
},
|
|
emptySubtitle: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary,
|
|
textAlign: 'center',
|
|
lineHeight: 20,
|
|
marginTop: spacing.sm,
|
|
marginBottom: spacing.xl
|
|
},
|
|
primaryButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
backgroundColor: colors.textPrimary,
|
|
paddingHorizontal: spacing.xl,
|
|
paddingVertical: spacing.md,
|
|
borderRadius: radii.button
|
|
},
|
|
primaryButtonText: {
|
|
color: colors.bgBase,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '700'
|
|
},
|
|
list: {
|
|
paddingHorizontal: spacing.lg,
|
|
paddingBottom: spacing.xl
|
|
},
|
|
hero: {
|
|
paddingTop: spacing.md,
|
|
paddingBottom: spacing.lg
|
|
},
|
|
heroTitle: {
|
|
color: colors.textPrimary,
|
|
fontSize: 24,
|
|
fontWeight: '800'
|
|
},
|
|
heroSubtitle: {
|
|
color: colors.textMuted,
|
|
fontSize: typography.bodySize,
|
|
marginTop: spacing.xs
|
|
},
|
|
cardGap: {
|
|
height: spacing.sm
|
|
},
|
|
hostCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingLeft: spacing.md,
|
|
paddingRight: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
minHeight: 70,
|
|
borderRadius: radii.row,
|
|
backgroundColor: colors.bgPanel
|
|
},
|
|
hostCardPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
hostIcon: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 11,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgRaised,
|
|
marginRight: spacing.md
|
|
},
|
|
hostMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
marginRight: spacing.sm
|
|
},
|
|
hostTitleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
minWidth: 0
|
|
},
|
|
hostName: {
|
|
flex: 1,
|
|
color: colors.textPrimary,
|
|
fontSize: 16,
|
|
fontWeight: '700'
|
|
},
|
|
moreButton: {
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginLeft: spacing.xs
|
|
},
|
|
hostStatus: {
|
|
color: colors.textMuted,
|
|
fontSize: 13,
|
|
marginTop: spacing.xs
|
|
},
|
|
pairCard: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
minHeight: 70,
|
|
marginTop: spacing.md,
|
|
paddingLeft: spacing.md,
|
|
paddingRight: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radii.row,
|
|
backgroundColor: colors.bgPanel
|
|
},
|
|
pairIcon: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 11,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgRaised,
|
|
marginRight: spacing.md
|
|
},
|
|
pairTextBlock: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
pairTitle: {
|
|
color: colors.textPrimary,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '700'
|
|
},
|
|
pairSubtitle: {
|
|
color: colors.textMuted,
|
|
fontSize: typography.metaSize,
|
|
marginTop: 2
|
|
},
|
|
settingsButton: {
|
|
flexDirection: 'row',
|
|
alignSelf: 'center',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.sm,
|
|
paddingHorizontal: spacing.md,
|
|
marginBottom: spacing.xl
|
|
},
|
|
settingsText: {
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
fontWeight: '500'
|
|
}
|
|
})
|