mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(mobile): restore terminal input when reopening worktrees
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user