diff --git a/mobile/app.json b/mobile/app.json
index d75ebba09e0..7764de41217 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -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",
{
diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx
index 4b6d6417119..742addac1cb 100644
--- a/mobile/app/_layout.tsx
+++ b/mobile/app/_layout.tsx
@@ -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 (
-
-
-
- )
- }
-
return (
- <>
+
- >
+
)
}
const styles = StyleSheet.create({
- splash: {
+ root: {
flex: 1,
- backgroundColor: colors.bgBase,
- alignItems: 'center',
- justifyContent: 'center'
+ backgroundColor: colors.bgBase
}
})
diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx
index 752da1874af..d59d0de55dd 100644
--- a/mobile/app/h/[hostId]/index.tsx
+++ b/mobile/app/h/[hostId]/index.tsx
@@ -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(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [showNewWorktree, setShowNewWorktree] = useState(false)
+ const [sleptIds, setSleptIds] = useState>(new Set())
// Persisted pin state
const [pinnedIds, setPinnedIds] = useState>(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()
+ 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()
@@ -831,83 +859,99 @@ export default function HostScreen() {
)}
- {/* Worktree long-press action sheet */}
- {
- 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 */}
- {
- void handleDeleteWorktree(confirmDelete)
- setConfirmDelete(null)
- setActionTarget(null)
- }
- }
- ]
- : []
- }
+ {/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
+ {
setConfirmDelete(null)
setActionTarget(null)
}}
- />
+ >
+ {confirmDelete ? (
+
+
+ Delete Worktree
+
+ Delete "{confirmDelete.displayName || confirmDelete.repo}" ({confirmDelete.branch})?
+
+
+
+ [
+ styles.confirmBtn,
+ styles.confirmBtnCancel,
+ pressed && styles.confirmBtnPressed
+ ]}
+ onPress={() => setConfirmDelete(null)}
+ >
+ Cancel
+
+ [
+ styles.confirmBtn,
+ styles.confirmBtnDestructive,
+ pressed && styles.confirmBtnPressed
+ ]}
+ onPress={() => {
+ if (confirmDelete) {
+ void handleDeleteWorktree(confirmDelete)
+ }
+ setConfirmDelete(null)
+ setActionTarget(null)
+ }}
+ >
+ Delete
+
+
+
+ ) : (
+ {
+ 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)
+ }
+ ]
+ : []
+ }
+ />
+ )}
+
{/* Host remove confirmation */}
- void handleRemoveHost()
- }
- ]}
- onClose={() => setConfirmRemoveHost(false)}
+ confirmLabel="Remove"
+ destructive
+ onConfirm={() => void handleRemoveHost()}
+ onCancel={() => setConfirmRemoveHost(false)}
/>
{
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
+ 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 }) => (
[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)}
/>
- void handleRemove()
- }
- ]}
- onClose={() => setConfirmRemove(null)}
+ confirmLabel="Remove"
+ destructive
+ onConfirm={() => void handleRemove()}
+ onCancel={() => setConfirmRemove(null)}
/>
router.push('/settings')}>
diff --git a/mobile/assets/adaptive-icon.png b/mobile/assets/adaptive-icon.png
index a70d317f595..b807d6ac1c4 100644
Binary files a/mobile/assets/adaptive-icon.png and b/mobile/assets/adaptive-icon.png differ
diff --git a/mobile/assets/icon.png b/mobile/assets/icon.png
index a70d317f595..d4bbb5268ee 100644
Binary files a/mobile/assets/icon.png and b/mobile/assets/icon.png differ
diff --git a/mobile/assets/splash-icon.png b/mobile/assets/splash-icon.png
index a70d317f595..0943070d71e 100644
Binary files a/mobile/assets/splash-icon.png and b/mobile/assets/splash-icon.png differ
diff --git a/mobile/src/components/ActionSheetModal.tsx b/mobile/src/components/ActionSheetModal.tsx
index d48b42b5ea2..a68b08ea637 100644
--- a/mobile/src/components/ActionSheetModal.tsx
+++ b/mobile/src/components/ActionSheetModal.tsx
@@ -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 (
-
+ <>
{(title || message) && (
{title ? (
@@ -47,8 +55,10 @@ export function ActionSheetModal({ visible, title, message, actions, onClose }:
[styles.action, pressed && styles.actionPressed]}
onPress={() => {
- onClose()
action.onPress()
+ if (!action.skipAutoClose && onClose) {
+ onClose()
+ }
}}
>
+ >
+ )
+}
+
+export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
+ return (
+
+
)
}
diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx
index 3ba4af3e2e9..4d7d317d1ec 100644
--- a/mobile/src/components/BottomDrawer.tsx
+++ b/mobile/src/components/BottomDrawer.tsx
@@ -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 (
-
+
@@ -133,11 +151,15 @@ export function BottomDrawer({ visible, onClose, children }: Props) {
-
+
)
}
const styles = StyleSheet.create({
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 1000
+ },
root: {
flex: 1
},
diff --git a/mobile/src/components/ConfirmModal.tsx b/mobile/src/components/ConfirmModal.tsx
new file mode 100644
index 00000000000..a393593b5cf
--- /dev/null
+++ b/mobile/src/components/ConfirmModal.tsx
@@ -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 (
+
+
+ {title}
+ {message ? {message} : null}
+
+
+ [styles.button, styles.cancelButton, pressed && styles.pressed]}
+ onPress={onCancel}
+ >
+ {cancelLabel}
+
+ [
+ styles.button,
+ destructive ? styles.destructiveButton : styles.confirmButton,
+ pressed && styles.pressed
+ ]}
+ onPress={() => {
+ onConfirm()
+ onCancel()
+ }}
+ >
+
+ {confirmLabel}
+
+
+
+
+ )
+}
+
+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'
+ }
+})
diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx
index 8431a63d236..82a8f7ef63f 100644
--- a/mobile/src/components/NewWorktreeModal.tsx
+++ b/mobile/src/components/NewWorktreeModal.tsx
@@ -676,7 +676,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
- minWidth: 72,
+ minWidth: 160,
alignItems: 'center'
},
createButtonDisabled: {
diff --git a/src/main/runtime/fit-override-integration.test.ts b/src/main/runtime/fit-override-integration.test.ts
index 66e7b7d9048..763b7d8cbf9 100644
--- a/src/main/runtime/fit-override-integration.test.ts
+++ b/src/main/runtime/fit-override-integration.test.ts
@@ -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()
})
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index 0209bca549d..b8546ecd7d3 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -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 })
}
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index a9677dfb785..500560edae2 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -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
diff --git a/src/main/runtime/rpc/methods/worktree.ts b/src/main/runtime/rpc/methods/worktree.ts
index a97f1b0d6f9..ec0a5c8da29 100644
--- a/src/main/runtime/rpc/methods/worktree.ts
+++ b/src/main/runtime/rpc/methods/worktree.ts
@@ -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,
diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts
index 03735d3ddee..eb601eaa01a 100644
--- a/src/main/window/attach-main-window-services.ts
+++ b/src/main/window/attach-main-window-services.ts
@@ -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 })
})
diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts
index 51f9c375c1c..deb4d6c88fe 100644
--- a/src/preload/api-types.ts
+++ b/src/preload/api-types.ts
@@ -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
saveClipboardImageAsTempFile: () => Promise
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 474982246b7..c2d99f2ead7 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -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)
diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts
index cfab03a62d7..649b9d04424 100644
--- a/src/renderer/src/hooks/useIpcEvents.test.ts
+++ b/src/renderer/src/hooks/useIpcEvents.test.ts
@@ -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: () => {},
diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts
index d6f3f187120..91f38c3e0e7 100644
--- a/src/renderer/src/hooks/useIpcEvents.ts
+++ b/src/renderer/src/hooks/useIpcEvents.ts
@@ -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)