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:
Jinwoo-H
2026-04-30 18:03:55 -04:00
co-authored by Orca
parent 36005a7af6
commit e668dce4ec
20 changed files with 414 additions and 125 deletions
+10 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

+21 -3
View File
@@ -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>
)
}
+33 -11
View File
@@ -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
},
+111
View File
@@ -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'
}
})
+1 -1
View File
@@ -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()
})
+4
View File
@@ -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 })
}
+10
View File
@@ -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
+5
View File
@@ -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 })
})
+1
View File
@@ -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>
+6
View File
@@ -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: () => {},
+7
View File
@@ -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)