mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(mobile): isolate offline structured chat transcripts
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user