From e6abf0e4ee6491fd31ace9351bdfd6c7c8c46ef3 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Fri, 4 Sep 2026 17:24:47 -0400 Subject: [PATCH] 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 --- .../mobile-native-chat-stream-frame.ts | 5 ++ .../native-chat-transcript-pending.test.ts | 55 +++++++++++++++++++ ...ive-host-session-native-chat-operations.ts | 1 + .../session/use-mobile-native-chat-session.ts | 4 +- 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 mobile/src/session/native-chat-transcript-pending.test.ts diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index 6e9a8a8f767..ec3c29b6258 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -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 } : {}), diff --git a/mobile/src/session/native-chat-transcript-pending.test.ts b/mobile/src/session/native-chat-transcript-pending.test.ts new file mode 100644 index 00000000000..088ff2221ec --- /dev/null +++ b/mobile/src/session/native-chat-transcript-pending.test.ts @@ -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 | 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()) + } + }) +}) diff --git a/mobile/src/session/native-host-session-native-chat-operations.ts b/mobile/src/session/native-host-session-native-chat-operations.ts index 89c36a176b0..198b1d53d1d 100644 --- a/mobile/src/session/native-host-session-native-chat-operations.ts +++ b/mobile/src/session/native-host-session-native-chat-operations.ts @@ -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[0]) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 1e9cb948385..31bf90b6205 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -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,