diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index c54fa295556..50746c61940 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -92,6 +92,7 @@ export function useMobileNativeChatController(args: { const structuredNativeChat = useMobileStructuredAgentSession({ client, sessionId: activeChatStructured ? activeChatSessionId : null, + sourceIdentity, enabled: showNativeChat, // Holds are connection-scoped; dropping this on transport loss lets the hook // reacquire the provider when the client authenticates again. Deliberately @@ -159,8 +160,6 @@ export function useMobileNativeChatController(args: { const nativeChatTranscriptSettled = nativeChatSession.status === 'ready' || (nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0) - const nativeChatAskObservable = - showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) const { askKey: nativeChatAskKey, showAsk: showNativeChatAsk, @@ -170,7 +169,7 @@ export function useMobileNativeChatController(args: { detectedAsk: nativeChatDetectedAsk, scopeKey: activeSessionTabId, sessionKey: activeChatSessionId, - observing: nativeChatAskObservable + observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled) }) // Every chat write gates on both: the lease proves the input floor is ours, and diff --git a/mobile/src/session/use-mobile-structured-agent-session.test.tsx b/mobile/src/session/use-mobile-structured-agent-session.test.tsx index 43b3c2a833d..8ab7ee81a47 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.test.tsx +++ b/mobile/src/session/use-mobile-structured-agent-session.test.tsx @@ -231,15 +231,18 @@ describe('useMobileStructuredAgentSession', () => { function Harness({ sessionId = 'session-1', agent = 'codex', - connected = true + connected = true, + sourceIdentity = 'host-a\u0000workspace-a' }: { sessionId?: string | null agent?: string | null connected?: boolean + sourceIdentity?: string }): null { hook = useMobileStructuredAgentSession({ client, sessionId, + sourceIdentity, enabled: true, connected, agent, @@ -821,4 +824,52 @@ describe('useMobileStructuredAgentSession', () => { expect(hook?.session.messages).toHaveLength(1) }) + + it('does not show session A under session B when switching tabs offline', async () => { + await act(async () => { + renderer = create(createElement(Harness, { connected: true, sessionId: 'session-1' })) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-2' })) + }) + + expect(hook?.session.messages).toEqual([]) + expect(hook?.session.status).toBe('idle') + + await act(async () => { + renderer?.update(createElement(Harness, { connected: false, sessionId: 'session-1' })) + }) + expect(hook?.session.messages).toHaveLength(1) + }) + + it('keeps same provider ids isolated across host/workspace sources', async () => { + await act(async () => { + renderer = create( + createElement(Harness, { + connected: true, + sessionId: 'session-1', + sourceIdentity: 'host-a\u0000workspace-a' + }) + ) + }) + await vi.waitFor(() => expect(listener).toEqual(expect.any(Function))) + act(() => listener?.(snapshotWithMessage())) + expect(hook?.session.messages).toHaveLength(1) + + await act(async () => { + renderer?.update( + createElement(Harness, { + connected: false, + sessionId: 'session-1', + sourceIdentity: 'host-b\u0000workspace-b' + }) + ) + }) + + expect(hook?.session.messages).toEqual([]) + }) }) diff --git a/mobile/src/session/use-mobile-structured-agent-session.ts b/mobile/src/session/use-mobile-structured-agent-session.ts index 64c761b9dff..f9ec54d4ae2 100644 --- a/mobile/src/session/use-mobile-structured-agent-session.ts +++ b/mobile/src/session/use-mobile-structured-agent-session.ts @@ -13,6 +13,7 @@ import { structuredAgentSessionSendBody, type StructuredAgentSessionAttachment } from '../../../src/shared/structured-agent-session-outbox' +import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { projectStructuredAgentSessionMessages } from '../../../src/shared/structured-agent-session-message-projection' import { activeStructuredAgentSessionTurnId } from '../../../src/shared/structured-agent-session-projection' @@ -64,20 +65,19 @@ type StructuredMobileSession = { export function useMobileStructuredAgentSession(args: { client: RpcClient | null sessionId: string | null + /** Host/workspace scope used to keep same provider ids isolated. */ + sourceIdentity?: string enabled: boolean /** Live transport only; gates the connection-scoped hold, nothing else. */ connected: boolean agent: string | null onSendError: (message: string) => void }): StructuredMobileSession { - const { agent, client, connected, sessionId, enabled, onSendError } = args + const { agent, client, connected, sessionId, sourceIdentity = '', enabled, onSendError } = args + const sessionKey = encodeNativeChatTranscriptIdentity([sourceIdentity, agent, sessionId]) const operationIdsRef = useRef(new Map()) - const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState({ - client, - sessionId, - enabled, - connected - }) + const stateArgs = { client, sessionId, sessionKey, enabled, connected } + const { state, stateRef, loadingOlder, loadEarlier } = useMobileStructuredAgentState(stateArgs) const mutate = useCallback( async ( @@ -90,7 +90,7 @@ export function useMobileStructuredAgentSession(args: { return { status: 'rejected' } } const targetFence = current.fence - const key = `${sessionId}:${fingerprintMethod}:${JSON.stringify(fields)}` + const key = `${sessionKey}:${fingerprintMethod}:${JSON.stringify(fields)}` const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId() operationIdsRef.current.set(key, clientOperationId) const result = await requestStructuredAgentSessionMutation({ @@ -166,7 +166,7 @@ export function useMobileStructuredAgentSession(args: { return 'rejected' } const fields = { body } - const key = `${sessionId}:agentSession.send:${JSON.stringify(fields)}` + const key = `${sessionKey}:agentSession.send:${JSON.stringify(fields)}` const priorOperationId = operationIdsRef.current.get(key) const clientOperationId = priorOperationId ?? structuredSessionOperationId() operationIdsRef.current.set(key, clientOperationId) @@ -249,7 +249,7 @@ export function useMobileStructuredAgentSession(args: { return } const fields = { turnId } - const key = `${sessionId}:agentSession.cancel:${JSON.stringify(fields)}` + const key = `${sessionKey}:agentSession.cancel:${JSON.stringify(fields)}` const clientOperationId = operationIdsRef.current.get(key) ?? structuredSessionOperationId() operationIdsRef.current.set(key, clientOperationId) void requestStructuredAgentSessionMutation({ diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index a0397949cef..52aefab24aa 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -15,6 +15,8 @@ import { import type { RpcClient } from '../transport/rpc-client' import { callAgentSession } from './mobile-structured-agent-session-rpc' +const MAX_RETAINED_SESSION_STATES = 32 + function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { if (typeof value !== 'object' || value === null) { return false @@ -26,6 +28,7 @@ function isSubscribeEvent(value: unknown): value is AgentSessionSubscribeEvent { export function useMobileStructuredAgentState(args: { client: RpcClient | null sessionId: string | null + sessionKey: string | null enabled: boolean /** Live transport only. The hold dies with the connection and has to be retaken, * but the transcript must survive the outage rather than blank out with it. */ @@ -36,40 +39,64 @@ export function useMobileStructuredAgentState(args: { loadingOlder: boolean loadEarlier: () => void } { - const { client, connected, enabled, sessionId } = args - const [state, setState] = useState(EMPTY_STRUCTURED_AGENT_SESSION) + const { client, connected, enabled, sessionId, sessionKey } = args + // Keep a bounded cache so offline tab switches select the right transcript + // synchronously without growing for the lifetime of the app. + const [sessionStates, setSessionStates] = useState>( + () => new Map() + ) + const state = + enabled && sessionKey + ? (sessionStates.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION) + : EMPTY_STRUCTURED_AGENT_SESSION const [loadingOlder, setLoadingOlder] = useState(false) const stateRef = useRef(state) - const sessionIdentityRef = useRef<{ client: RpcClient; sessionId: string } | null>(null) + const sessionKeyRef = useRef(sessionKey) + const streamGenerationRef = useRef(0) useLayoutEffect(() => { stateRef.current = state - }, [state]) + sessionKeyRef.current = sessionKey + }, [sessionKey, state]) - const apply = useCallback((action: StructuredAgentSessionAction) => { - setState((current) => reduceStructuredAgentSession(current, action)) - }, []) + const apply = useCallback( + (action: StructuredAgentSessionAction) => { + if (!sessionKey) { + return + } + setSessionStates((current) => { + const previous = current.get(sessionKey) ?? EMPTY_STRUCTURED_AGENT_SESSION + const next = reduceStructuredAgentSession(previous, action) + if (next === previous) { + return current + } + const updated = new Map(current) + updated.delete(sessionKey) + updated.set(sessionKey, next) + while (updated.size > MAX_RETAINED_SESSION_STATES) { + const oldest = updated.keys().next().value + if (oldest === undefined) { + break + } + updated.delete(oldest) + } + return updated + }) + }, + [sessionKey] + ) useEffect(() => { + streamGenerationRef.current += 1 + sessionKeyRef.current = sessionKey + setLoadingOlder(false) if (!client || !sessionId || !enabled) { - sessionIdentityRef.current = null - setState(EMPTY_STRUCTURED_AGENT_SESSION) - setLoadingOlder(false) return } if (!connected) { - // The cleanup above already dropped the dead hold and stream. Hold the last - // transcript on screen for the outage: clearing it renders neither spinner - // nor empty-state copy, so the chat reads as gone rather than offline. + // The cleanup above drops the dead hold and stream; keyed state keeps this + // session's transcript visible while another tab can be selected. return } - const sessionChanged = - sessionIdentityRef.current?.client !== client || - sessionIdentityRef.current?.sessionId !== sessionId - sessionIdentityRef.current = { client, sessionId } - if (sessionChanged) { - // Do not leak the previous tab's transcript while the new session loads. - setState(EMPTY_STRUCTURED_AGENT_SESSION) - } apply({ type: 'loading' }) const holderId = structuredAgentSessionHolderId('mobile-chat') let cancelled = false @@ -120,17 +147,19 @@ export function useMobileStructuredAgentState(args: { ) .catch(() => undefined) } - }, [apply, client, connected, enabled, sessionId]) + }, [apply, client, connected, enabled, sessionId, sessionKey]) const loadEarlier = useCallback(() => { const current = stateRef.current - if (!client || !sessionId || loadingOlder || !current.hasOlder) { + if (!client || !sessionId || !sessionKey || loadingOlder || !current.hasOlder) { return } const cursor = oldestStructuredAgentSessionCursor(current) if (!cursor) { return } + const requestSessionKey = sessionKey + const requestGeneration = streamGenerationRef.current setLoadingOlder(true) void callAgentSession(client, 'agentSession.history', { sessionId, @@ -139,15 +168,31 @@ export function useMobileStructuredAgentState(args: { limit: AGENT_SESSION_HISTORY_MAX_LIMIT }) .then((result) => { - if (result.ok) { + if ( + result.ok && + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { apply({ type: 'older-page', requestedEpoch: cursor.epoch, page: result.page }) } }) .catch((error: unknown) => { - apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + } }) - .finally(() => setLoadingOlder(false)) - }, [apply, client, loadingOlder, sessionId]) + .finally(() => { + if ( + sessionKeyRef.current === requestSessionKey && + streamGenerationRef.current === requestGeneration + ) { + setLoadingOlder(false) + } + }) + }, [apply, client, loadingOlder, sessionId, sessionKey]) return { state, stateRef, loadingOlder, loadEarlier } }