fix(mobile): isolate offline structured chat transcripts

This commit is contained in:
Merge Sim
2026-09-01 19:28:53 -07:00
parent 3d8cb9f520
commit 8cbade2c56
4 changed files with 136 additions and 41 deletions
@@ -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
@@ -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([])
})
})
@@ -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<string, string>())
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 <TValue>(
@@ -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<TValue>({
@@ -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<AgentSessionCancelResult>({
@@ -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<StructuredAgentSessionState>(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<Map<string, StructuredAgentSessionState>>(
() => 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<AgentSessionHistoryResult>(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 }
}