fix(mobile): restore terminal input when reopening worktrees

This commit is contained in:
Shahar Mor
2026-09-03 15:12:31 -07:00
committed by Merge Sim
parent b1186c6beb
commit af62015ff4
10 changed files with 57 additions and 12 deletions
@@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource(
const autoCreateHookSource = readMobileSessionRouteSource(
'./use-initial-session-terminal-autocreate.ts'
)
const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts')
const terminalRuntimeSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-runtime.ts'
)
const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource(
'./use-mobile-session-terminal-subscription.ts'
)
const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts')
function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string {
const start = targetSource.indexOf(startPattern)
@@ -106,6 +114,19 @@ describe('mobile session startup', () => {
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
})
it('binds terminal identity to the shared client before subscription effects run', () => {
expect(foundationSource).toContain('const { client, clientId, state: connState }')
expect(foundationSource).toContain(' clientId,')
expect(terminalRuntimeSource).toContain('useRef<string | null>(clientId)')
expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId')
expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null')
expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)')
expect(terminalSubscriptionSourceForIdentity).toContain(
"client: { id: clientId, type: 'mobile' as const }"
)
expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken')
})
it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => {
expect(terminalSubscriptionSource).toContain(
"if (data.type === 'end' || data.type === 'error')"
@@ -35,7 +35,7 @@ export function useMobileSessionFoundation() {
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, state: connState } = useHostClient(hostId)
const { client, clientId, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
@@ -96,6 +96,7 @@ export function useMobileSessionFoundation() {
router,
insets,
client,
clientId,
connState,
reconnectAttempts,
lastConnectedAt,
@@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
connState,
setCustomKeys,
setVisibleBuiltInIds,
deviceTokenRef,
setHostEndpoint,
connStateRef,
terminalRefs,
@@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
unsubscribeTerminal,
subscribeToTerminal
} = scope
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
// Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata.
useEffect(() => {
if (!hostId) {
return
@@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
}
const host = hosts.find((h) => h.id === hostId)
if (host) {
deviceTokenRef.current = host.deviceToken
setHostEndpoint(host.endpoint)
}
})
@@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
worktreeId,
connState,
client,
clientId,
sessionTabs,
setLiveInputCapture,
liveInputTerminalHandles,
@@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const deviceTokenRef = useRef<string | null>(clientId)
// Keep the authenticated identity synchronous with the client exposed to downstream hooks.
deviceTokenRef.current = clientId
// Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear.
const [hostEndpoint, setHostEndpoint] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
@@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
sendLiveTerminalInputRef,
setLiveInputCapture
})
const { canCompose, canSend } = resolveMobileTerminalInputGate({
const inputGate = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const canCompose = inputGate.canCompose
const canSend = inputGate.canSend && clientId !== null
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
@@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription(
) {
const {
client,
clientId,
setTerminalModes,
terminalCwdRef,
deviceTokenRef,
viewportRef,
viewportMeasuredRef,
terminalUnsubsRef,
@@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription(
logSkippedGate('no-client')
return
}
if (clientId === null) {
logSkippedGate('no-client-identity')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
@@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription(
client,
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
client: { id: clientId, type: 'mobile' as const },
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
@@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription(
},
[
client,
clientId,
getTerminalRef,
markNativeChatInputLeaseReady,
scheduleDelayedAction,
+7 -3
View File
@@ -146,8 +146,8 @@ beforeEach(() => {
})
describe('useHostClient', () => {
it('rebinds when Expo reuses a screen between two connected cached hosts', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
it('rebinds the client and its authenticated identity together across cached hosts', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' }
const client1 = makeFakeClient('connected')
const client2 = makeFakeClient('connected')
connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2)
@@ -155,11 +155,13 @@ describe('useHostClient', () => {
let selectedHostId = HOST.id
let selectedClient: RpcClient | null = null
let selectedClientId: string | null = null
let selectedState: ConnectionState = 'disconnected'
let renderer: ReactTestRenderer | null = null
function Probe(): null {
const selected = useHostClient(selectedHostId)
selectedClient = selected.client
selectedClientId = selected.clientId
selectedState = selected.state
useHostClient(host2.id)
return null
@@ -171,16 +173,18 @@ describe('useHostClient', () => {
await Promise.resolve()
})
expect(selectedClient).toBe(client1)
expect(selectedClientId).toBe(HOST.deviceToken)
expect(selectedState).toBe('connected')
selectedHostId = host2.id
client2.emitState('disconnected')
await act(async () => {
client2.emitState('disconnected')
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(selectedClient).toBe(client2)
expect(selectedClientId).toBe(host2.deviceToken)
expect(selectedState).toBe('disconnected')
expect(connectMock).toHaveBeenCalledTimes(2)
} finally {
+8 -1
View File
@@ -368,6 +368,7 @@ export function useRpcClientContext(): RpcClientContextValue {
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
clientId: string | null
state: ConnectionState
} {
const ctx = useRpcClientContext()
@@ -425,12 +426,18 @@ export function useHostClient(hostId: string | undefined): {
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
const bound = clientHostIdRef.current === hostId
const boundClient = bound ? clientRef.current : null
const boundState = bound
? state
: hostId
? (ctx.getKnownState(hostId) ?? 'connecting')
: 'disconnected'
return { client: bound ? clientRef.current : null, state: boundState }
// Why: publish identity from the same entry as the client so consumers cannot race a second Keychain read.
return {
client: boundClient,
clientId: boundClient && hostId ? ctx.getClientId(hostId) : null,
state: boundState
}
}
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
@@ -79,6 +79,7 @@ export function createHostClientSelectors(
return {
getKnownState,
getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null,
getReconnectAttempt: (hostId: string): number =>
entries.get(hostId)?.client.getReconnectAttempt() ?? 0,
getLastConnectedAt: (hostId: string): number | null =>
@@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types'
export type HostClientStoreEntry = {
client: RpcClient
clientId: string
state: ConnectionState
refCount: number
unsubState: () => void
@@ -130,6 +131,7 @@ export async function openHostClientEntry(
}) ?? (() => {})
const entry: HostClientStoreEntry = {
client,
clientId: host.deviceToken,
state: client.getState(),
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
unsubState,
@@ -18,6 +18,7 @@ export type RpcClientContextValue = {
disconnectHostClient: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getKnownState: (hostId: string) => ConnectionState | null
getClientId: (hostId: string) => string | null
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath