From af62015ff49a28368ea63fa4b06c99c887a41cb8 Mon Sep 17 00:00:00 2001 From: Shahar Mor Date: Mon, 24 Aug 2026 12:03:06 +0300 Subject: [PATCH] fix(mobile): restore terminal input when reopening worktrees --- .../mobile-session-startup-source.test.ts | 21 +++++++++++++++++++ .../session/use-mobile-session-foundation.ts | 3 ++- .../session/use-mobile-session-lifecycle.ts | 4 +--- .../use-mobile-session-terminal-runtime.ts | 9 ++++++-- ...se-mobile-session-terminal-subscription.ts | 9 ++++++-- mobile/src/transport/client-context.test.ts | 10 ++++++--- mobile/src/transport/client-context.tsx | 9 +++++++- .../transport/host-client-context-state.ts | 1 + mobile/src/transport/host-entry-opener.ts | 2 ++ .../transport/rpc-client-context-contract.ts | 1 + 10 files changed, 57 insertions(+), 12 deletions(-) diff --git a/mobile/src/session/mobile-session-startup-source.test.ts b/mobile/src/session/mobile-session-startup-source.test.ts index acab1d5e7c6..83b2021b95e 100644 --- a/mobile/src/session/mobile-session-startup-source.test.ts +++ b/mobile/src/session/mobile-session-startup-source.test.ts @@ -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(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')" diff --git a/mobile/src/session/use-mobile-session-foundation.ts b/mobile/src/session/use-mobile-session-foundation.ts index 9a7889e10cb..fa2f9607bbc 100644 --- a/mobile/src/session/use-mobile-session-foundation.ts +++ b/mobile/src/session/use-mobile-session-foundation.ts @@ -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, diff --git a/mobile/src/session/use-mobile-session-lifecycle.ts b/mobile/src/session/use-mobile-session-lifecycle.ts index 912ebdb4921..7c58e84a3e5 100644 --- a/mobile/src/session/use-mobile-session-lifecycle.ts +++ b/mobile/src/session/use-mobile-session-lifecycle.ts @@ -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) } }) diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index 8ef472fb845..5086efe1ba1 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -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>(new Set()) const terminalCwdRef = useRef>(new Map()) const initialModesSeenRef = useRef>(new Set()) - const deviceTokenRef = useRef(null) + const deviceTokenRef = useRef(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(null) const clientRef = useRef(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, diff --git a/mobile/src/session/use-mobile-session-terminal-subscription.ts b/mobile/src/session/use-mobile-session-terminal-subscription.ts index 8ad0bb7383a..333d472a30e 100644 --- a/mobile/src/session/use-mobile-session-terminal-subscription.ts +++ b/mobile/src/session/use-mobile-session-terminal-subscription.ts @@ -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, diff --git a/mobile/src/transport/client-context.test.ts b/mobile/src/transport/client-context.test.ts index a311f362f85..4a7d5d7b0e8 100644 --- a/mobile/src/transport/client-context.test.ts +++ b/mobile/src/transport/client-context.test.ts @@ -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 { diff --git a/mobile/src/transport/client-context.tsx b/mobile/src/transport/client-context.tsx index f83519940c1..d618fc1e9fd 100644 --- a/mobile/src/transport/client-context.tsx +++ b/mobile/src/transport/client-context.tsx @@ -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. diff --git a/mobile/src/transport/host-client-context-state.ts b/mobile/src/transport/host-client-context-state.ts index 972a810af21..859e5d847f9 100644 --- a/mobile/src/transport/host-client-context-state.ts +++ b/mobile/src/transport/host-client-context-state.ts @@ -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 => diff --git a/mobile/src/transport/host-entry-opener.ts b/mobile/src/transport/host-entry-opener.ts index 6e29f55d985..03d6363c7c7 100644 --- a/mobile/src/transport/host-entry-opener.ts +++ b/mobile/src/transport/host-entry-opener.ts @@ -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, diff --git a/mobile/src/transport/rpc-client-context-contract.ts b/mobile/src/transport/rpc-client-context-contract.ts index 9eb9efd4444..54e25973c7f 100644 --- a/mobile/src/transport/rpc-client-context-contract.ts +++ b/mobile/src/transport/rpc-client-context-contract.ts @@ -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