mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
fix(mobile): splash/icon sizing, worktree sleep, host removal persistence
- Add worktree.sleep RPC method with full notifier chain (runtime → IPC → renderer) - Optimistic UI update for sleeping worktrees in mobile client - Fix splash screen: configure expo-splash-screen plugin, hide splash only after navigator layout - Regenerate app icon and splash assets at correct density sizes - Fix host removal not persisting by disabling Android auto-backup (allowBackup=false) - Guard against invalid host credentials (base64 crash) and prevent navigating into auth-failed hosts - Extract ActionSheetContent for inline drawer content switching - Rewrite BottomDrawer as always-mounted Animated.View overlay (replaces Modal) - Add ConfirmModal component with inline confirm/cancel buttons Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+10
-1
@@ -9,6 +9,8 @@
|
||||
"scheme": "orca",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"ios": {
|
||||
@@ -20,11 +22,18 @@
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"allowBackup": false,
|
||||
"package": "com.stably.orca.mobile"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-splash-screen",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
|
||||
+10
-21
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
@@ -27,25 +27,16 @@ Notifications.setNotificationHandler({
|
||||
})
|
||||
|
||||
export default function RootLayout() {
|
||||
const [appReady, setAppReady] = useState(false)
|
||||
|
||||
// Why: onLayout fires once the root view has been measured and rendered,
|
||||
// which is the earliest safe moment to hide the native splash.
|
||||
const onLayoutRootView = useCallback(async () => {
|
||||
setAppReady(true)
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
// Previously the splash hid when a placeholder View rendered, leaving a
|
||||
// grey gap before the real screen appeared.
|
||||
const onNavigatorLayout = useCallback(async () => {
|
||||
await SplashScreen.hideAsync()
|
||||
}, [])
|
||||
|
||||
if (!appReady) {
|
||||
return (
|
||||
<View style={styles.splash} onLayout={onLayoutRootView}>
|
||||
<OrcaLogo size={56} color={colors.textPrimary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -70,15 +61,13 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
splash: {
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
|
||||
+160
-73
@@ -34,7 +34,8 @@ import { StatusDot } from '../../../src/components/StatusDot'
|
||||
import { NewWorktreeModal } from '../../../src/components/NewWorktreeModal'
|
||||
import { AgentSpinner } from '../../../src/components/AgentSpinner'
|
||||
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
||||
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
||||
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { colors, spacing, typography } from '../../../src/theme/mobile-theme'
|
||||
import {
|
||||
@@ -257,6 +258,7 @@ export default function HostScreen() {
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [showNewWorktree, setShowNewWorktree] = useState(false)
|
||||
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
|
||||
|
||||
// Persisted pin state
|
||||
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
|
||||
@@ -316,6 +318,22 @@ export default function HostScreen() {
|
||||
setLastKnownWorktrees(result.worktrees)
|
||||
setWorktreesLoaded(true)
|
||||
|
||||
// Clear optimistic sleep overrides once the server confirms the
|
||||
// worktree is actually inactive (liveTerminalCount dropped to 0).
|
||||
setSleptIds((prev) => {
|
||||
if (prev.size === 0) {
|
||||
return prev
|
||||
}
|
||||
const still = new Set<string>()
|
||||
for (const id of prev) {
|
||||
const wt = result.worktrees.find((w) => w.worktreeId === id)
|
||||
if (wt && wt.liveTerminalCount > 0) {
|
||||
still.add(id)
|
||||
}
|
||||
}
|
||||
return still.size === prev.size ? prev : still
|
||||
})
|
||||
|
||||
// Sync local pin state from server so desktop-initiated pins/unpins
|
||||
// are reflected without relying on stale AsyncStorage.
|
||||
const serverPinned = new Set(
|
||||
@@ -490,10 +508,20 @@ export default function HostScreen() {
|
||||
[hostId]
|
||||
)
|
||||
|
||||
const displayWorktrees =
|
||||
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
|
||||
? lastKnownWorktrees
|
||||
: worktrees
|
||||
const displayWorktrees = useMemo(() => {
|
||||
const base =
|
||||
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
|
||||
? lastKnownWorktrees
|
||||
: worktrees
|
||||
if (sleptIds.size === 0) {
|
||||
return base
|
||||
}
|
||||
return base.map((w) =>
|
||||
sleptIds.has(w.worktreeId)
|
||||
? { ...w, liveTerminalCount: 0, hasAttachedPty: false, status: 'inactive' as const }
|
||||
: w
|
||||
)
|
||||
}, [connState, worktrees, lastKnownWorktrees, sleptIds])
|
||||
|
||||
const uniqueRepos = useMemo(() => {
|
||||
const repos = new Map<string, string>()
|
||||
@@ -831,83 +859,99 @@ export default function HostScreen() {
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Worktree long-press action sheet */}
|
||||
<ActionSheetModal
|
||||
visible={actionTarget != null && confirmDelete == null}
|
||||
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
|
||||
message={actionTarget?.branch}
|
||||
actions={
|
||||
actionTarget
|
||||
? [
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
void client.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
label: isWorktreePinned(actionTarget, pinnedIds) ? 'Unpin' : 'Pin',
|
||||
onPress: () => togglePin(actionTarget.worktreeId)
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onPress: () => setConfirmDelete(actionTarget)
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Worktree delete confirmation */}
|
||||
<ActionSheetModal
|
||||
visible={confirmDelete != null}
|
||||
title="Delete Worktree"
|
||||
message={
|
||||
confirmDelete
|
||||
? `Delete "${confirmDelete.displayName || confirmDelete.repo}" (${confirmDelete.branch})?`
|
||||
: undefined
|
||||
}
|
||||
actions={
|
||||
confirmDelete
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onPress: () => {
|
||||
void handleDeleteWorktree(confirmDelete)
|
||||
setConfirmDelete(null)
|
||||
setActionTarget(null)
|
||||
}
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
{/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
|
||||
<BottomDrawer
|
||||
visible={actionTarget != null}
|
||||
onClose={() => {
|
||||
setConfirmDelete(null)
|
||||
setActionTarget(null)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{confirmDelete ? (
|
||||
<View>
|
||||
<View style={styles.confirmContent}>
|
||||
<Text style={styles.confirmTitle}>Delete Worktree</Text>
|
||||
<Text style={styles.confirmMessage}>
|
||||
Delete "{confirmDelete.displayName || confirmDelete.repo}" ({confirmDelete.branch})?
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.confirmButtons}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.confirmBtn,
|
||||
styles.confirmBtnCancel,
|
||||
pressed && styles.confirmBtnPressed
|
||||
]}
|
||||
onPress={() => setConfirmDelete(null)}
|
||||
>
|
||||
<Text style={styles.confirmBtnCancelText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.confirmBtn,
|
||||
styles.confirmBtnDestructive,
|
||||
pressed && styles.confirmBtnPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
if (confirmDelete) {
|
||||
void handleDeleteWorktree(confirmDelete)
|
||||
}
|
||||
setConfirmDelete(null)
|
||||
setActionTarget(null)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.confirmBtnDestructiveText}>Delete</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<ActionSheetContent
|
||||
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
|
||||
message={actionTarget?.branch}
|
||||
actions={
|
||||
actionTarget
|
||||
? [
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
onPress: () => {
|
||||
if (client) {
|
||||
setSleptIds((prev) => new Set(prev).add(actionTarget.worktreeId))
|
||||
void client.sendRequest('worktree.sleep', {
|
||||
worktree: `id:${actionTarget.worktreeId}`
|
||||
})
|
||||
}
|
||||
setActionTarget(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: isWorktreePinned(actionTarget, pinnedIds) ? 'Unpin' : 'Pin',
|
||||
onPress: () => {
|
||||
togglePin(actionTarget.worktreeId)
|
||||
setActionTarget(null)
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
destructive: true,
|
||||
onPress: () => setConfirmDelete(actionTarget)
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</BottomDrawer>
|
||||
|
||||
{/* Host remove confirmation */}
|
||||
<ActionSheetModal
|
||||
<ConfirmModal
|
||||
visible={confirmRemoveHost}
|
||||
title="Remove Host"
|
||||
message={`Remove "${hostName}"? You can re-pair later.`}
|
||||
actions={[
|
||||
{
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
onPress: () => void handleRemoveHost()
|
||||
}
|
||||
]}
|
||||
onClose={() => setConfirmRemoveHost(false)}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void handleRemoveHost()}
|
||||
onCancel={() => setConfirmRemoveHost(false)}
|
||||
/>
|
||||
|
||||
<NewWorktreeModal
|
||||
@@ -1251,5 +1295,48 @@ const styles = StyleSheet.create({
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4
|
||||
},
|
||||
confirmContent: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
confirmTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
confirmMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
confirmButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
confirmBtn: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center'
|
||||
},
|
||||
confirmBtnCancel: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmBtnDestructive: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
confirmBtnPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
confirmBtnCancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmBtnDestructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
|
||||
+23
-15
@@ -20,6 +20,7 @@ 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 { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
@@ -100,15 +101,25 @@ export default function HomeScreen() {
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
const notifCleanups: Array<() => void> = []
|
||||
const clients = hosts.map((host) => {
|
||||
const clients = hosts.flatMap((host) => {
|
||||
if (!host.publicKeyB64 || !host.deviceToken) {
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
|
||||
return []
|
||||
}
|
||||
setHostStates((prev) => ({
|
||||
...prev,
|
||||
[host.id]: prev[host.id] ?? 'connecting'
|
||||
}))
|
||||
const client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
|
||||
if (disposed) return
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: state }))
|
||||
})
|
||||
let client: ReturnType<typeof connect>
|
||||
try {
|
||||
client = connect(host.endpoint, host.deviceToken, host.publicKeyB64, (state) => {
|
||||
if (disposed) return
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: state }))
|
||||
})
|
||||
} catch {
|
||||
setHostStates((prev) => ({ ...prev, [host.id]: 'auth-failed' }))
|
||||
return []
|
||||
}
|
||||
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let statsFetched = false
|
||||
@@ -131,7 +142,7 @@ export default function HomeScreen() {
|
||||
unsubNotif?.()
|
||||
})
|
||||
|
||||
return client
|
||||
return [client]
|
||||
})
|
||||
|
||||
clientsRef.current = clients
|
||||
@@ -221,6 +232,7 @@ export default function HomeScreen() {
|
||||
renderItem={({ item }) => (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
disabled={hostStates[item.id] === 'auth-failed'}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
@@ -301,18 +313,14 @@ export default function HomeScreen() {
|
||||
onCancel={() => setRenameTarget(null)}
|
||||
/>
|
||||
|
||||
<ActionSheetModal
|
||||
<ConfirmModal
|
||||
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)}
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
onConfirm={() => void handleRemove()}
|
||||
onCancel={() => setConfirmRemove(null)}
|
||||
/>
|
||||
|
||||
<Pressable style={styles.settingsButton} onPress={() => router.push('/settings')}>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 353 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -7,6 +7,7 @@ export type ActionSheetAction = {
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
destructive?: boolean
|
||||
skipAutoClose?: boolean
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
@@ -24,9 +25,16 @@ function iconForAction(label: string, destructive?: boolean, icon?: LucideIcon):
|
||||
return Edit3
|
||||
}
|
||||
|
||||
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
|
||||
type ContentProps = {
|
||||
title?: string
|
||||
message?: string
|
||||
actions: ActionSheetAction[]
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export function ActionSheetContent({ title, message, actions, onClose }: ContentProps) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<>
|
||||
{(title || message) && (
|
||||
<View style={styles.header}>
|
||||
{title ? (
|
||||
@@ -47,8 +55,10 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }:
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.action, pressed && styles.actionPressed]}
|
||||
onPress={() => {
|
||||
onClose()
|
||||
action.onPress()
|
||||
if (!action.skipAutoClose && onClose) {
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
@@ -65,6 +75,14 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }:
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose}>
|
||||
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type ReactNode, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
@@ -28,6 +27,8 @@ const SPRING_CONFIG = { damping: 28, stiffness: 400 }
|
||||
// so the drawer resists upward dragging — a subtle polish touch that signals
|
||||
// the drawer cannot expand further.
|
||||
const RUBBER_BAND_FACTOR = 0.25
|
||||
const SHOW_DURATION = 180
|
||||
const HIDE_DURATION = 150
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
@@ -37,7 +38,7 @@ type Props = {
|
||||
|
||||
export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
const translateY = useSharedValue(0)
|
||||
const backdropOpacity = useSharedValue(0)
|
||||
const progress = useSharedValue(0)
|
||||
const keyboardOffset = useSharedValue(0)
|
||||
const { height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -45,7 +46,9 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
translateY.value = 0
|
||||
backdropOpacity.value = withTiming(1, { duration: 200 })
|
||||
progress.value = withTiming(1, { duration: SHOW_DURATION })
|
||||
} else {
|
||||
progress.value = withTiming(0, { duration: HIDE_DURATION })
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
@@ -91,25 +94,40 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
const velocity = Math.max(e.velocityY, 800)
|
||||
const remaining = screenHeight - e.translationY
|
||||
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
|
||||
translateY.value = withTiming(screenHeight, { duration }, () => {
|
||||
translateY.value = withTiming(screenHeight, { duration })
|
||||
progress.value = withTiming(0, { duration }, () => {
|
||||
runOnJS(dismiss)()
|
||||
})
|
||||
backdropOpacity.value = withTiming(0, { duration })
|
||||
} else {
|
||||
translateY.value = withSpring(0, SPRING_CONFIG)
|
||||
}
|
||||
})
|
||||
|
||||
const drawerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value - keyboardOffset.value }]
|
||||
transform: [
|
||||
{
|
||||
translateY:
|
||||
interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
|
||||
translateY.value -
|
||||
keyboardOffset.value
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
const backdropStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
|
||||
}))
|
||||
const backdropStyle = useAnimatedStyle(() => {
|
||||
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
|
||||
return { opacity: progress.value * dragFade }
|
||||
})
|
||||
|
||||
const pointerStyle = useAnimatedStyle(
|
||||
() =>
|
||||
({
|
||||
pointerEvents: progress.value > 0 ? 'auto' : 'none'
|
||||
}) as { pointerEvents: 'auto' | 'none' }
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<Animated.View style={[styles.overlay, pointerStyle]}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
@@ -133,11 +151,15 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
|
||||
</GestureDetector>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
zIndex: 1000
|
||||
},
|
||||
root: {
|
||||
flex: 1
|
||||
},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
||||
import { BottomDrawer } from './BottomDrawer'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
title: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
destructive?: boolean
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
visible,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
destructive = false,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props) {
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onCancel}>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
{message ? <Text style={styles.message}>{message}</Text> : null}
|
||||
</View>
|
||||
<View style={styles.buttons}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.button, styles.cancelButton, pressed && styles.pressed]}
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text style={styles.cancelText}>{cancelLabel}</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.button,
|
||||
destructive ? styles.destructiveButton : styles.confirmButton,
|
||||
pressed && styles.pressed
|
||||
]}
|
||||
onPress={() => {
|
||||
onConfirm()
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<Text style={destructive ? styles.destructiveText : styles.confirmText}>
|
||||
{confirmLabel}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
content: {
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
message: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: spacing.xs,
|
||||
lineHeight: 20
|
||||
},
|
||||
buttons: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
button: {
|
||||
flex: 1,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: colors.textPrimary
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: colors.statusRed
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
confirmText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.bgBase
|
||||
},
|
||||
destructiveText: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: '#fff'
|
||||
}
|
||||
})
|
||||
@@ -676,7 +676,7 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
minWidth: 72,
|
||||
minWidth: 160,
|
||||
alignItems: 'center'
|
||||
},
|
||||
createButtonDisabled: {
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('fit override integration', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||||
notifications.push({ ptyId, mode, cols, rows })
|
||||
}
|
||||
@@ -198,6 +199,7 @@ describe('fit override integration', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
@@ -258,6 +260,7 @@ describe('fit override integration', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
|
||||
@@ -1249,6 +1249,7 @@ describe('OrcaRuntimeService', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(TEST_WINDOW_ID)
|
||||
@@ -1274,6 +1275,7 @@ describe('OrcaRuntimeService', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
@@ -1442,6 +1444,7 @@ describe('OrcaRuntimeService', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn()
|
||||
})
|
||||
|
||||
@@ -1575,6 +1578,7 @@ describe('OrcaRuntimeService', () => {
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) => {
|
||||
notifications.push({ ptyId, mode, cols, rows })
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ type RuntimeNotifier = {
|
||||
renameTerminal(tabId: string, title: string | null): void
|
||||
focusTerminal(tabId: string, worktreeId: string): void
|
||||
closeTerminal(tabId: string, paneRuntimeId?: number): void
|
||||
sleepWorktree(worktreeId: string): void
|
||||
terminalFitOverrideChanged(
|
||||
ptyId: string,
|
||||
mode: 'mobile-fit' | 'desktop-fit',
|
||||
@@ -1568,6 +1569,15 @@ export class OrcaRuntimeService {
|
||||
return await this.resolveWorktreeSelector(worktreeSelector)
|
||||
}
|
||||
|
||||
async sleepManagedWorktree(worktreeSelector: string): Promise<{ worktreeId: string }> {
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
// Why: sleep is renderer-initiated on desktop (it tears down tab state
|
||||
// before killing PTYs). The notifier tells the renderer to run its own
|
||||
// sleep flow so all cleanup happens in the correct order.
|
||||
this.notifier?.sleepWorktree(worktree.id)
|
||||
return { worktreeId: worktree.id }
|
||||
}
|
||||
|
||||
async activateManagedWorktree(worktreeSelector: string): Promise<{
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
|
||||
@@ -71,6 +71,11 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
worktree: await runtime.showManagedWorktree(params.worktree)
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.sleep',
|
||||
params: WorktreeSelector,
|
||||
handler: async (params, { runtime }) => runtime.sleepManagedWorktree(params.worktree)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'worktree.activate',
|
||||
params: WorktreeSelector,
|
||||
|
||||
@@ -234,6 +234,7 @@ function registerRuntimeWindowLifecycle(
|
||||
renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }),
|
||||
focusTerminal: (tabId, worktreeId) => send('ui:focusTerminal', { tabId, worktreeId }),
|
||||
closeTerminal: (tabId, paneRuntimeId) => send('ui:closeTerminal', { tabId, paneRuntimeId }),
|
||||
sleepWorktree: (worktreeId) => send('ui:sleepWorktree', { worktreeId }),
|
||||
terminalFitOverrideChanged: (ptyId, mode, cols, rows) =>
|
||||
send('runtime:terminalFitOverrideChanged', { ptyId, mode, cols, rows })
|
||||
})
|
||||
|
||||
@@ -828,6 +828,7 @@ export type PreloadApi = {
|
||||
onCloseTerminal: (
|
||||
callback: (data: { tabId: string; paneRuntimeId?: number }) => void
|
||||
) => () => void
|
||||
onSleepWorktree: (callback: (data: { worktreeId: string }) => void) => () => void
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
|
||||
readClipboardText: () => Promise<string>
|
||||
saveClipboardImageAsTempFile: () => Promise<string | null>
|
||||
|
||||
@@ -1505,6 +1505,12 @@ const api = {
|
||||
ipcRenderer.on('ui:closeTerminal', listener)
|
||||
return () => ipcRenderer.removeListener('ui:closeTerminal', listener)
|
||||
},
|
||||
onSleepWorktree: (callback: (data: { worktreeId: string }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) =>
|
||||
callback(data)
|
||||
ipcRenderer.on('ui:sleepWorktree', listener)
|
||||
return () => ipcRenderer.removeListener('ui:sleepWorktree', listener)
|
||||
},
|
||||
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, direction: 'in' | 'out' | 'reset') =>
|
||||
callback(direction)
|
||||
|
||||
@@ -163,6 +163,7 @@ describe('useIpcEvents updater integration', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -357,6 +358,7 @@ describe('useIpcEvents updater integration', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -549,6 +551,7 @@ describe('useIpcEvents updater integration', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -744,6 +747,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -933,6 +937,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -1117,6 +1122,7 @@ describe('useIpcEvents browser tab close routing', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -1319,6 +1325,7 @@ describe('useIpcEvents shortcut hint clearing', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
@@ -1519,6 +1526,7 @@ describe('useIpcEvents CLI-created worktree activation', () => {
|
||||
onRenameTerminal: () => () => {},
|
||||
onFocusTerminal: () => () => {},
|
||||
onCloseTerminal: () => () => {},
|
||||
onSleepWorktree: () => () => {},
|
||||
onNewBrowserTab: () => () => {},
|
||||
onRequestTabCreate: () => () => {},
|
||||
replyTabCreate: () => {},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from 'react'
|
||||
import { useAppStore } from '../store'
|
||||
import { applyUIZoom } from '@/lib/ui-zoom'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { runSleepWorktree } from '@/components/sidebar/sleep-worktree-flow'
|
||||
import { SPLIT_TERMINAL_PANE_EVENT, CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
|
||||
import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constants/terminal'
|
||||
import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees'
|
||||
@@ -283,6 +284,12 @@ export function useIpcEvents(): void {
|
||||
})
|
||||
)
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onSleepWorktree(({ worktreeId }) => {
|
||||
void runSleepWorktree(worktreeId)
|
||||
})
|
||||
)
|
||||
|
||||
// Hydrate initial update status then subscribe to changes
|
||||
window.api.updater.getStatus().then((status) => {
|
||||
useAppStore.getState().setUpdateStatus(status as UpdateStatus)
|
||||
|
||||
Reference in New Issue
Block a user