fix(mobile): restore pending native chat transcripts

Restore the existing transcriptPending capability and preserve pending snapshots through the shared chat presentation seam. A new agent can show its empty chat while keeping draft reconciliation gated until the real transcript arrives. Exercise capability negotiation and the pending-to-ready transition through the native adapter and React hook.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-04 17:24:47 -04:00
parent aba1566848
commit e6abf0e4ee
4 changed files with 63 additions and 2 deletions
@@ -10,6 +10,7 @@ export type MobileNativeChatStreamFrame = {
messages?: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
pending?: boolean
error?: string
message?: string
lifecycle?: NativeChatTurnLifecycle
@@ -26,6 +27,7 @@ export type AppliedMobileNativeChatFrame =
cursorInvalidated?: boolean
lifecycle?: NativeChatTurnLifecycle
windowReplaced?: boolean
pending?: boolean
}
function replayRetainedTailStart(
@@ -82,6 +84,7 @@ export function applyMobileNativeChatStreamFrame(args: {
if (!Array.isArray(frame.messages)) {
return { kind: 'ignored' }
}
const pending = frame.type === 'snapshot' && frame.pending === true
const replayStartIndex =
frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0
? replayRetainedTailStart(merger, frame.messages, frame.hasMore)
@@ -93,6 +96,7 @@ export function applyMobileNativeChatStreamFrame(args: {
messages: merger.list,
hasMore: frame.hasMore,
windowReplaced: true,
...(pending ? { pending: true } : {}),
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }),
...(frame.lifecycle === undefined ? {} : { lifecycle: frame.lifecycle })
}
@@ -104,6 +108,7 @@ export function applyMobileNativeChatStreamFrame(args: {
return {
kind: 'messages',
messages,
...(pending ? { pending: true } : {}),
// Why: once the bounded live window drops its oldest row, the snapshot's
// byte cursor no longer describes the oldest retained message.
...(cursorInvalidated ? { cursorInvalidated: true } : {}),
@@ -0,0 +1,55 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { nativeHostSessionNativeChatOperations } from './native-host-session-native-chat-operations'
import {
useMobileNativeChatSession,
type MobileNativeChatSession
} from './use-mobile-native-chat-session'
describe('native chat before the first transcript flush', () => {
it('negotiates pending snapshots and waits for the authoritative transcript', async () => {
let state: MobileNativeChatSession | null = null
let emit: (frame: unknown) => void = () => {}
const subscribe: RpcClient['subscribe'] = vi.fn((_method, params, onData) => {
emit = onData
if (
(params as { capabilities?: { transcriptPending?: number } }).capabilities
?.transcriptPending === 1
) {
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
}
return () => {}
})
const operations = nativeHostSessionNativeChatOperations({ subscribe } as unknown as RpcClient)
function Harness() {
state = useMobileNativeChatSession({
operations,
workspaceId: 'workspace',
agent: 'claude',
sessionId: 'session',
transcriptPath: null,
terminalId: 'terminal',
clientId: 'device'
})
return null
}
let renderer: ReturnType<typeof create> | undefined
try {
await act(async () => {
renderer = create(createElement(Harness))
})
expect(state).toMatchObject({
status: 'awaiting-transcript',
transcriptLoading: true,
messages: []
})
await act(async () => emit({ type: 'snapshot', messages: [], hasMore: false }))
expect(state).toMatchObject({ status: 'ready', transcriptLoading: false, messages: [] })
expect(subscribe).toHaveBeenCalledTimes(1)
} finally {
act(() => renderer?.unmount())
}
})
})
@@ -46,6 +46,7 @@ export function nativeHostSessionNativeChatOperations(
'nativeChat.subscribe',
{
...nativeChatReadParams(target, limit),
capabilities: { transcriptPending: 1 },
subscriptionId: buildNativeChatSubscriptionId(target.agent, target.sessionId)
},
(value) => onEvent(value as Parameters<typeof onEvent>[0])
@@ -218,7 +218,7 @@ export function useMobileNativeChatSession(args: {
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setRead({ operations, identity, status: 'ready' })
setRead({ operations, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' })
},
() => {
if (!cancelled) {
@@ -317,7 +317,7 @@ export function useMobileNativeChatSession(args: {
messages: visibleMessages,
lifecycle,
status,
transcriptLoading: status === 'loading',
transcriptLoading: status === 'loading' || status === 'awaiting-transcript',
error,
hasMore,
loadingEarlier,