Files
orca/mobile/src/session/use-initial-session-terminal-autocreate.ts
T
Brennan Benson 292626eebb fix(mobile): keep closed sessions empty (#11251)
* fix(mobile): stop re-creating a terminal when the session tab list empties

The session route treated "zero session tabs" as "this workspace has never
had anything" and auto-created a terminal. Closing the last tab prunes
sessionTabs and nulls activeHandle locally, which is exactly that state, so
the close was immediately followed by a brand-new terminal — and the guard
re-arms on every route mount, so it recurs across visits (#9717, #7345).

Gate the auto-create on whether this route has ever published a non-empty tab
list for the workspace. A cold hydrate still gets its first terminal; an
emptied list gets the empty state and its create button.

Extracted to a hook because the route file sits at its max-lines cap; the
call site is 4 counted lines smaller than the effect it replaces.

* fix(mobile): keep emptied workspaces empty across visits

* fix(mobile): reach the auto-create callbacks without a render-time ref write

The hook kept `consumeCreationRoute`/`createTerminal` out of the effect deps by
writing latest-refs during render. React can replay or discard a render, so the
write can leak from UI that never commits — React Doctor flags it as a blocking
"Ref mutated during render" error, which failed PR Checks' static analysis.

useEffectEvent (React 19.2, already used in SourceControl.tsx) gives the same
stable-callback-outside-deps behaviour with no render-time mutation. Retire the
deprecated `MutableRefObject` for `RefObject` in the same pass.

Retargets the source pin at the new wiring; test counts unchanged.

* docs(mobile): document the two per-route reset contracts

Both exported helpers exist for a non-obvious reason — they must be re-created or
re-derived per worktree, or a reused route inherits the previous workspace's
hydration state and the resurrection guard silently disarms.

* fix(mobile): preserve terminal creation through reconnect
2026-07-30 12:58:24 -07:00

107 lines
3.1 KiB
TypeScript

import { useEffect, useEffectEvent, useReducer, type RefObject } from 'react'
import { shouldAutoCreateInitialSessionTerminal } from './initial-session-terminal'
type InitialSessionTerminalAutoCreateState = {
autoCreatedForWorktree: string | null
sawSessionTabs: boolean
}
/**
* Fresh auto-create bookkeeping. The route must re-create this on every worktree
* change, or a revisit inherits the previous workspace's `sawSessionTabs`.
*/
export function createInitialSessionAutoCreateState(): InitialSessionTerminalAutoCreateState {
return { autoCreatedForWorktree: null, sawSessionTabs: false }
}
type InitialSessionTerminalAutoCreateArgs = {
client: unknown
newlyCreatedWorkspace: boolean
connState: string
terminalsLoaded: boolean
visibleTabCount: number
activeHandle: string | null
createInFlight: boolean
stateRef: RefObject<InitialSessionTerminalAutoCreateState>
worktreeId: string
consumeCreationRoute: () => void
createTerminal: () => void
}
/**
* Creates the first terminal of a newly created mobile workspace that hydrates
* empty, at most once per route. See initial-session-terminal.
*/
export function useInitialSessionTerminalAutoCreate(
args: InitialSessionTerminalAutoCreateArgs
): void {
const {
client,
newlyCreatedWorkspace,
connState,
terminalsLoaded,
visibleTabCount,
activeHandle,
createInFlight,
stateRef,
worktreeId
} = args
// Why: the route re-creates both callbacks every render; useEffectEvent keeps them
// out of the deps without mutating a ref during render.
const consumeCreationRoute = useEffectEvent(args.consumeCreationRoute)
const createTerminal = useEffectEvent(args.createTerminal)
useEffect(() => {
if (
newlyCreatedWorkspace &&
client &&
connState === 'connected' &&
terminalsLoaded &&
(visibleTabCount > 0 || activeHandle !== null)
) {
consumeCreationRoute()
}
if (
!client ||
!shouldAutoCreateInitialSessionTerminal({
newlyCreatedWorkspace,
connected: connState === 'connected',
tabsLoaded: terminalsLoaded,
visibleTabCount,
hasActiveTerminalHandle: activeHandle !== null,
createInFlight,
sawSessionTabs: stateRef.current.sawSessionTabs,
autoCreatedForWorktree: stateRef.current.autoCreatedForWorktree === worktreeId
})
) {
return
}
stateRef.current.autoCreatedForWorktree = worktreeId
createTerminal()
}, [
activeHandle,
client,
connState,
createInFlight,
newlyCreatedWorkspace,
stateRef,
terminalsLoaded,
visibleTabCount,
worktreeId
])
}
/**
* Tracks "tabs hydrated" per worktree so a reused route reports `false` until the
* new workspace's own snapshot lands, rather than inheriting the old one's.
*/
export function useWorktreeSessionTabsLoaded(
worktreeId: string
): readonly [boolean, (loaded: boolean) => void] {
const [loadedForWorktree, setLoaded] = useReducer(
(_current: string | null, loaded: boolean) => (loaded ? worktreeId : null),
null
)
return [loadedForWorktree === worktreeId, setLoaded]
}