From c082f21d2e0c3e05efb09315fe45dfd43dfd5705 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 19:15:42 -0700 Subject: [PATCH] Resolve host new-worktree route action before commit (#3242) --- mobile/app/h/[hostId]/index.tsx | 26 ++++++++--- mobile/src/host-route-action-state.test.ts | 50 ++++++++++++++++++++++ mobile/src/host-route-action-state.ts | 36 ++++++++++++++++ 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 mobile/src/host-route-action-state.test.ts create mode 100644 mobile/src/host-route-action-state.ts diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index fc4765448..4f194ec81 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -62,6 +62,11 @@ import { loadPreferences, savePreferences } from '../../../src/storage/preferences' +import { + createInitialHostRouteActionState, + resolveHostRouteActionState, + setHostRouteNewWorktreeVisible +} from '../../../src/host-route-action-state' // Why: locally-typed subset of the desktop's RuntimeStatus we read from // `status.get`. Only the version fields matter to mobile today; everything @@ -340,7 +345,9 @@ export default function HostScreen() { const [actionTarget, setActionTarget] = useState(null) const [confirmDelete, setConfirmDelete] = useState(null) const [confirmRemoveHost, setConfirmRemoveHost] = useState(false) - const [showNewWorktree, setShowNewWorktree] = useState(false) + const [routeActionState, setRouteActionState] = useState(() => + createInitialHostRouteActionState(action) + ) const [sleptIds, setSleptIds] = useState>(new Set()) // Persisted pin state @@ -348,9 +355,16 @@ export default function HostScreen() { const [_prefsLoaded, setPrefsLoaded] = useState(false) const [collapsedGroups, setCollapsedGroups] = useState>(new Set()) - useEffect(() => { - if (action === 'newWorktree') setShowNewWorktree(true) - }, [action]) + const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action) + // Why: `action=newWorktree` is a route-derived open edge. Resolve it before + // commit, but don't reopen after the user closes while the same URL remains. + if (resolvedRouteActionState !== routeActionState) { + setRouteActionState(resolvedRouteActionState) + } + const showNewWorktree = resolvedRouteActionState.showNewWorktree + const setShowNewWorktreeVisible = useCallback((visible: boolean) => { + setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible)) + }, []) // Load persisted pins and preferences useEffect(() => { @@ -871,7 +885,7 @@ export default function HostScreen() { setShowNewWorktree(true)} + onPress={() => setShowNewWorktreeVisible(true)} disabled={connState !== 'connected'} > setShowNewWorktree(false)} + onClose={() => setShowNewWorktreeVisible(false)} /> ) diff --git a/mobile/src/host-route-action-state.test.ts b/mobile/src/host-route-action-state.test.ts new file mode 100644 index 000000000..c662d2d34 --- /dev/null +++ b/mobile/src/host-route-action-state.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' + +import { + createInitialHostRouteActionState, + resolveHostRouteActionState, + setHostRouteNewWorktreeVisible +} from './host-route-action-state' + +describe('host route action state', () => { + it('opens new worktree modal on an initial newWorktree action', () => { + expect(createInitialHostRouteActionState('newWorktree')).toEqual({ + routeAction: 'newWorktree', + showNewWorktree: true + }) + }) + + it('keeps initial non-action routes closed', () => { + expect(createInitialHostRouteActionState(undefined)).toEqual({ + routeAction: undefined, + showNewWorktree: false + }) + }) + + it('opens once when route action changes to newWorktree', () => { + expect( + resolveHostRouteActionState({ routeAction: undefined, showNewWorktree: false }, 'newWorktree') + ).toEqual({ + routeAction: 'newWorktree', + showNewWorktree: true + }) + }) + + it('does not reopen after user closes while route action is unchanged', () => { + const closed = setHostRouteNewWorktreeVisible( + { routeAction: 'newWorktree', showNewWorktree: true }, + false + ) + + expect(resolveHostRouteActionState(closed, 'newWorktree')).toBe(closed) + }) + + it('preserves an already-open modal when route action changes away', () => { + expect( + resolveHostRouteActionState({ routeAction: 'newWorktree', showNewWorktree: true }, undefined) + ).toEqual({ + routeAction: undefined, + showNewWorktree: true + }) + }) +}) diff --git a/mobile/src/host-route-action-state.ts b/mobile/src/host-route-action-state.ts new file mode 100644 index 000000000..bdcb7a524 --- /dev/null +++ b/mobile/src/host-route-action-state.ts @@ -0,0 +1,36 @@ +export type HostRouteActionState = { + routeAction: string | undefined + showNewWorktree: boolean +} + +export function createInitialHostRouteActionState( + routeAction: string | undefined +): HostRouteActionState { + return { + routeAction, + showNewWorktree: routeAction === 'newWorktree' + } +} + +export function resolveHostRouteActionState( + current: HostRouteActionState, + routeAction: string | undefined +): HostRouteActionState { + if (current.routeAction === routeAction) { + return current + } + return { + routeAction, + showNewWorktree: current.showNewWorktree || routeAction === 'newWorktree' + } +} + +export function setHostRouteNewWorktreeVisible( + current: HostRouteActionState, + showNewWorktree: boolean +): HostRouteActionState { + if (current.showNewWorktree === showNewWorktree) { + return current + } + return { ...current, showNewWorktree } +}