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)