fix(native-chat): stop the spinner on a not-yet-flushed transcript (#16493)

* fix(native-chat): stop the spinner on a not-yet-flushed transcript

A brand-new agent session can take minutes to write its first JSONL line,
and one that is never prompted never writes it at all. The host emitted no
stream frame until the file resolved, so every native-chat client sat on a
bare spinner with the composer enabled but the transcript blank -- forever,
in the never-prompted case.

The resolve poll now reports the transcript as pending after a short grace,
and both host handlers emit a `pending: true` snapshot. It is deliberately
not a plain empty snapshot: an empty window sold as a settled read would
capture over retained history and unblock consumers that require a
trustworthy transcript (the launch-draft adoption would re-offer a prompt
the agent may already have taken).

Clients render it as the "start a chat" empty state while keeping the read
unsettled -- `awaiting-transcript` on mobile, an `awaiting` read phase on
desktop, which also stops the seed loop expiring into an error card for a
session that is simply new. New optional field only, so older clients
ignore it and still stop spinning.

* fix(native-chat): negotiate pending transcript frames
This commit is contained in:
Brennan Benson
2026-08-25 15:06:47 -07:00
committed by GitHub
parent bc98655a39
commit 98bdd653ab
21 changed files with 845 additions and 26 deletions
@@ -32,6 +32,13 @@ describe('mobileNativeChatEmptyState', () => {
expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex')
})
it('invites a first message while the transcript file is still unwritten', () => {
// The spinner is already gone by then, so a bare list would read as broken.
expect(mobileNativeChatEmptyState('awaiting-transcript', 'claude')?.title).toBe(
'Start a chat with Claude'
)
})
it('falls back to "the agent" when the agent is unknown', () => {
expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe(
'Start a chat with the agent'
@@ -20,10 +20,11 @@ export function mobileNativeChatEmptyState(
): NativeChatEmptyStateCopy | null {
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
switch (status) {
// A live agent with no transcript yet — and a loaded-but-empty transcript
// are both "start a chat"; invite the first message instead of implying the
// agent is still starting up.
// A live agent with no transcript yet — an unwritten transcript file, or a
// loaded-but-empty one — is "start a chat"; invite the first message instead
// of implying the agent is still starting up.
case 'waiting-session':
case 'awaiting-transcript':
case 'ready':
return formatNativeChatEmptyStateCopy('empty', agentLabel)
case 'error': {
@@ -37,6 +37,36 @@ describe('applyMobileNativeChatStreamFrame', () => {
})
})
it('marks a pending snapshot so the caller can settle the view but not the read', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [], hasMore: false, pending: true },
limit: 40,
replaceSnapshot: true
})
expect(result).toEqual({
kind: 'messages',
messages: [],
hasMore: false,
windowReplaced: true,
pending: true
})
})
it('leaves an ordinary snapshot unmarked', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [message('a')], hasMore: false },
limit: 40,
replaceSnapshot: true
})
expect(result).not.toHaveProperty('pending')
})
it('merges reconnect snapshots and live appends into the bounded window', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a'), message('b')])
@@ -7,6 +7,10 @@ export type MobileNativeChatStreamFrame = {
messages?: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
/** Snapshot only: no transcript file exists behind this window yet (the agent
* has not flushed, or was never prompted). The empty list is real enough to
* render, but it is not a settled read of the session's history. */
pending?: boolean
error?: string
message?: string
}
@@ -24,6 +28,9 @@ export type AppliedMobileNativeChatFrame =
* snapshot, or a replay snapshot disjoint from local history) — the
* caller must reset its paging window/cursor to the frame's. */
windowReplaced?: boolean
/** The transcript behind this window does not exist yet: show it, but keep
* the read open — the real snapshot follows on the same subscription. */
pending?: boolean
}
function replayRetainedTailStart(
@@ -80,6 +87,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)
@@ -91,6 +99,7 @@ export function applyMobileNativeChatStreamFrame(args: {
messages: merger.list,
hasMore: frame.hasMore,
windowReplaced: true,
...(pending ? { pending: true } : {}),
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
}
@@ -101,6 +110,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 } : {}),
@@ -633,4 +633,81 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
ids: []
})
})
it('settles the view but not the read on a pending snapshot', async () => {
// The host answers with an empty pending window while the transcript file
// does not exist yet. Calling that 'ready' would let the launch-draft seed
// adopt a prefill the agent may already have been sent; leaving it
// 'loading' is the bare forever-spinner this frame exists to end.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
await mountAt({ subscribe } as unknown as RpcClient, 'session-a')
expect(subscribe).toHaveBeenCalledWith(
'nativeChat.subscribe',
expect.objectContaining({ capabilities: { transcriptPending: 1 } }),
expect.any(Function)
)
expect(renders.at(-1)).toMatchObject({
status: 'awaiting-transcript',
transcriptLoading: true,
ids: []
})
})
it('takes the real snapshot after a pending one as this subscriptions base', async () => {
let emitFrame: (frame: unknown) => void = () => {}
const client = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFrame = onData
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
} as unknown as RpcClient
await mountAt(client, 'session-a')
await act(async () =>
emitFrame({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
)
expect(renders.at(-1)).toMatchObject({
status: 'ready',
transcriptLoading: false,
ids: ['a-1']
})
})
it('never captures a pending window over the retained transcript', async () => {
const client = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
})
} as unknown as RpcClient
await mountAt(client, 'session-a')
// Same identity, fresh client: this read finds no transcript file behind the
// session and answers pending.
const reconnected = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [], hasMore: false, pending: true })
return () => {}
})
} as unknown as RpcClient
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
// One more rebind reads retention back: it still holds the real history,
// which it could not had the empty pending window captured over it.
const rebound = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: rebound, sessionId: 'session-a' }))
)
expect(renders.at(-1)).toMatchObject({ status: 'loading', ids: ['a-1'] })
})
})
@@ -12,16 +12,25 @@ import {
type MobileNativeChatStreamFrame
} from './mobile-native-chat-stream-frame'
export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error'
export type MobileNativeChatStatus =
| 'idle'
| 'loading'
| 'waiting-session'
/** The host answered, but the session has no transcript file yet — render the
* empty chat instead of a spinner while the read stays open. */
| 'awaiting-transcript'
| 'ready'
| 'error'
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
/** True while `messages` cannot be trusted as this session's real history:
* the read is in flight, OR the subscription effect has not yet caught up to
* a just-changed agent/session, so `messages`/`status` still describe the
* previous tab. Consumers that decide something from an empty transcript
* (the launch-draft seed) must wait for this to clear. */
* the read is in flight, the transcript has not been written yet, OR the
* subscription effect has not yet caught up to a just-changed agent/session,
* so `messages`/`status` still describe the previous tab. Consumers that
* decide something from an empty transcript (the launch-draft seed) must
* wait for this to clear. */
transcriptLoading: boolean
error?: string
/** True when an older page may exist (the last read filled the window). */
@@ -145,6 +154,7 @@ export function useMobileNativeChatSession(args: {
sessionId,
limit: limitRef.current,
subscriptionId: buildNativeChatSubscriptionId(agent, sessionId),
capabilities: { transcriptPending: 1 },
...(transcriptPath ? { transcriptPath } : {})
},
(raw) => {
@@ -166,7 +176,9 @@ export function useMobileNativeChatSession(args: {
setError(applied.error)
return
}
if (frame.type === 'snapshot') {
if (frame.type === 'snapshot' && !applied.pending) {
// A pending window has no transcript behind it, so the snapshot that
// follows is still this subscription's base, not a reconnect replay.
snapshotSeenRef.current = true
}
if (applied.windowReplaced || frame.type === 'snapshot') {
@@ -198,7 +210,7 @@ export function useMobileNativeChatSession(args: {
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setRead({ client, identity, status: 'ready' })
setRead({ client, identity, status: applied.pending ? 'awaiting-transcript' : 'ready' })
}
)
@@ -286,7 +298,7 @@ export function useMobileNativeChatSession(args: {
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: visibleMessages,
status,
transcriptLoading: status === 'loading',
transcriptLoading: status === 'loading' || status === 'awaiting-transcript',
error,
hasMore,
loadingEarlier,
+48
View File
@@ -252,6 +252,54 @@ describe('nativeChat:readSession handler', () => {
}
})
it('settles the view with a pending frame while the transcript is unflushed', async () => {
// The user-visible bug: a session that has not been prompted never writes
// its JSONL, so with no frame at all the chat view spins indefinitely.
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-unflushed-'))
tempRoots.push(root)
await mkdir(join(root, '.claude', 'projects', '-repo'), { recursive: true })
registerNativeChatHandlers()
const subscribe = listeners.get('nativeChat:subscribe')
expect(subscribe).toBeDefined()
const sent: { channel: string; payload: unknown }[] = []
let destroyedCb: (() => void) | undefined
const sender = {
id: 7,
isDestroyed: () => false,
once: (event: string, cb: () => void) => {
if (event === 'destroyed') {
destroyedCb = cb
}
},
send: (channel: string, payload: unknown) => sent.push({ channel, payload })
}
const previousHome = process.env.HOME
process.env.HOME = root
try {
subscribe!({ sender }, { subscriptionId: 'sub-pending', agent: 'claude', sessionId: 'ghost' })
await waitFor(() => sent.some((s) => s.channel === 'nativeChat:appended'), 6_000)
expect(sent[0]).toMatchObject({
channel: 'nativeChat:appended',
payload: {
subscriptionId: 'sub-pending',
frame: { type: 'snapshot', messages: [], hasMore: false, pending: true }
}
})
destroyedCb!()
} finally {
if (previousHome === undefined) {
delete process.env.HOME
} else {
process.env.HOME = previousHome
}
}
})
it('drops cleanup registration when sender is destroyed before subscribe completes', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-ipc-destroy-race-'))
tempRoots.push(root)
+15
View File
@@ -64,6 +64,9 @@ export type NativeChatAppendedPayload = {
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
/** No transcript exists behind this window yet — render it, but do not
* treat it as a settled read of the session's history. */
pending?: boolean
}
| {
type: 'replacement'
@@ -181,6 +184,18 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg
sessionId,
transcriptPath,
initialLimit: limit,
onTranscriptPending: () => {
if (sender.isDestroyed()) {
return
}
// `pending` marks a window with no transcript behind it yet; clients that
// don't know the flag still stop spinning on the empty snapshot.
const payload: NativeChatAppendedPayload = {
subscriptionId,
frame: { type: 'snapshot', messages: [], hasMore: false, pending: true }
}
sender.send('nativeChat:appended', payload)
},
onInitialSnapshot: (messages, hasMore, _beforeOffset, error, lifecycle) => {
if (sender.isDestroyed()) {
return
@@ -17,6 +17,11 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & {
error?: string,
lifecycle?: NativeChatTurnLifecycle
) => void
/** The transcript file does not exist yet (a session whose agent has not
* flushed, or has not been prompted at all). Fires at most once, before any
* snapshot, so a client can settle its view on the empty window it really
* has instead of spinning — while still knowing the read is not settled. */
onTranscriptPending?: () => void
onReplace?: (
messages: NativeChatMessage[],
hasMore: boolean,
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../shared/native-chat-types'
const mocks = vi.hoisted(() => ({
install: vi.fn(),
resolve: vi.fn()
}))
vi.mock('./session-file-resolver', () => ({
resolveSessionFilePath: mocks.resolve
}))
vi.mock('./transcript-watch-engine', () => ({
getActiveNativeChatWatcherCount: vi.fn(() => 0),
installTranscriptWatcher: mocks.install
}))
import { subscribeNativeChatTranscript } from './transcript-watch'
import { WslTranscriptFsError } from './wsl-transcript-fs-gate'
type Snapshot = [NativeChatMessage[], boolean, number, string | undefined]
const realPlatform = process.platform
function subscribeCollecting(
snapshots: Snapshot[],
onTranscriptPending: () => void = () => {}
): Promise<{ unsubscribe: () => void }> {
return subscribeNativeChatTranscript({
agent: 'claude',
sessionId: 'session-id',
transcriptPath: '/projects/p/session-id.jsonl',
resolvePollIntervalMs: 10,
onAppend: () => {},
onTranscriptPending,
onInitialSnapshot: (messages, hasMore, beforeOffset, error) => {
snapshots.push([messages, hasMore, beforeOffset, error])
}
})
}
// A brand-new agent session flushes its first JSONL line seconds to minutes
// after start — and never at all until it is prompted. Emitting nothing in that
// window leaves every client on an unexplained spinner (mobile native chat
// renders `status === 'loading'` as a bare ActivityIndicator).
describe('unflushed transcript settles the view', () => {
beforeEach(() => {
vi.useFakeTimers()
mocks.install.mockReset().mockResolvedValue(null)
mocks.resolve.mockReset().mockResolvedValue(null)
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
vi.useRealTimers()
})
it('reports the pending transcript once while it stays unflushed', async () => {
const pending = vi.fn()
const snapshots: Snapshot[] = []
const subscription = await subscribeCollecting(snapshots, pending)
await vi.advanceTimersByTimeAsync(1_400)
expect(pending).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(200)
expect(pending).toHaveBeenCalledTimes(1)
// Never a snapshot: an empty window sold as a settled read captures over
// retained history and unblocks consumers that need a real transcript.
expect(snapshots).toEqual([])
// Latched: the still-failing poll must not re-report on every tick.
await vi.advanceTimersByTimeAsync(30_000)
expect(pending).toHaveBeenCalledTimes(1)
subscription.unsubscribe()
})
it('leaves the settle to the real snapshot when the file lands inside the grace window', async () => {
const unsubscribe = vi.fn()
// Missing at subscribe time, flushed by the first poll tick.
mocks.install.mockResolvedValueOnce(null).mockResolvedValue({ unsubscribe, watching: true })
const pending = vi.fn()
const subscription = await subscribeCollecting([], pending)
await vi.advanceTimersByTimeAsync(5_000)
// The engine owns the initial drain; an empty window announced here would
// blank a real transcript for a frame.
expect(pending).not.toHaveBeenCalled()
subscription.unsubscribe()
})
it('does not report a subscription that was torn down first', async () => {
const pending = vi.fn()
const subscription = await subscribeCollecting([], pending)
await vi.advanceTimersByTimeAsync(100)
subscription.unsubscribe()
await vi.advanceTimersByTimeAsync(5_000)
expect(pending).not.toHaveBeenCalled()
})
it('keeps the WSL gate message instead of overwriting it with the empty window', async () => {
const stalled = new WslTranscriptFsError('timeout', 'WSL transcript files are unavailable.')
mocks.install.mockRejectedValue(stalled)
const pending = vi.fn()
const snapshots: Snapshot[] = []
const subscription = await subscribeCollecting(snapshots, pending)
await vi.advanceTimersByTimeAsync(5_000)
expect(snapshots).toEqual([[[], false, 0, stalled.message]])
expect(pending).not.toHaveBeenCalled()
subscription.unsubscribe()
})
})
+40
View File
@@ -49,6 +49,11 @@ async function attemptInstall(
const INITIAL_RESOLVE_POLL_MS = 500
const MAX_RESOLVE_POLL_MS = 5_000
const FALLBACK_RESOLVE_POLL_MS = 5_000
// Why: with no frame at all a client shows a bare spinner for the whole flush
// delay — a fresh session that has yet to be prompted never flushes, so the
// spinner is permanent. Long enough that a merely slow resolve still wins the
// race and paints history directly.
const UNFLUSHED_SETTLE_MS = 1_500
function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string | null {
const path = args.transcriptPath?.trim()
@@ -62,6 +67,8 @@ function exactTranscriptPath(args: SubscribeNativeChatTranscriptArgs): string |
* unsubscribe() cancels it. Reports watching:true — the engine's first drain
* delivers the initial snapshot once the file appears, so subscribers must not
* settle a merely not-yet-flushed transcript into a permanent error (#8401).
* A short grace period in, it reports the transcript as pending once so the
* view can stop spinning while it waits.
*/
function subscribeViaResolvePoll(
args: SubscribeNativeChatTranscriptArgs,
@@ -81,8 +88,36 @@ function subscribeViaResolvePoll(
// Latches only once a frame was actually emitted, so a subscriber without the
// callback can't suppress it for a later one.
let gateErrorEmitted = false
// Whether the subscriber already has an initial frame to render.
let settled = false
let settleTimer: ReturnType<typeof setTimeout> | null = null
const resolveController = new AbortController()
function stopSettleTimer(): void {
if (settleTimer) {
clearTimeout(settleTimer)
settleTimer = null
}
}
/** Report a still-unresolved transcript so the view can leave 'loading' and
* invite a first message instead of spinning. Deliberately not a snapshot:
* an empty window presented as a settled read would capture over retained
* history and unblock consumers that require a trustworthy transcript. */
function settleUnflushed(): void {
settleTimer = null
if (closed || settled || installed || !args.onTranscriptPending) {
return
}
settled = true
args.onTranscriptPending()
}
if (args.onTranscriptPending) {
settleTimer = setTimeout(settleUnflushed, UNFLUSHED_SETTLE_MS)
settleTimer.unref?.()
}
function scheduleAttempt(): void {
if (closed) {
return
@@ -154,6 +189,9 @@ function subscribeViaResolvePoll(
// runAttempt is invoked as `void runAttempt()`.
if (error instanceof WslTranscriptFsError && !gateErrorEmitted && args.onInitialSnapshot) {
gateErrorEmitted = true
// Its retryable message outranks the empty settle; don't overwrite it.
settled = true
stopSettleTimer()
args.onInitialSnapshot([], false, 0, error.message)
}
result = null
@@ -165,6 +203,7 @@ function subscribeViaResolvePoll(
}
if (result) {
installed = result
stopSettleTimer()
return
}
scheduleAttempt()
@@ -180,6 +219,7 @@ function subscribeViaResolvePoll(
}
closed = true
resolveController.abort()
stopSettleTimer()
if (pollTimer) {
clearTimeout(pollTimer)
pollTimer = null
@@ -53,6 +53,7 @@ const watcher = vi.hoisted(() => ({
timestamp: number | null
}
) => void
onTranscriptPending?: () => void
},
watching: true,
setupSignal: undefined as AbortSignal | undefined,
@@ -443,6 +444,44 @@ describe('nativeChat.subscribe initial snapshot', () => {
}
})
it('settles a not-yet-flushed transcript with a pending window, then the real snapshot', async () => {
// A brand-new session's JSONL can be a minute out, or never written at all
// until the agent is prompted. With no frame the client just spins.
watcher.watching = true
watcher.args = null
const emitted: unknown[] = []
await subscribeHandler()(
{ agent: 'claude', sessionId: 's', capabilities: { transcriptPending: 1 } },
streamingContext('mobile'),
(value) => emitted.push(value)
)
const callbacks = activeWatcherArgs()
callbacks.onTranscriptPending?.()
expect(emitted).toEqual([{ type: 'snapshot', messages: [], hasMore: false, pending: true }])
const message = makeTextMessage('first turn')
callbacks.onInitialSnapshot?.([message], false, 123)
expect(emitted).toHaveLength(2)
expect(emitted[1]).toMatchObject({ type: 'snapshot', hasMore: false })
// The real window is authoritative and carries no pending marker.
expect((emitted[1] as { pending?: boolean }).pending).toBeUndefined()
})
it('does not publish pending semantics to a legacy client', async () => {
watcher.watching = true
watcher.args = null
const emitted: unknown[] = []
await subscribeHandler()(
{ agent: 'claude', sessionId: 's' },
streamingContext('mobile'),
(value) => emitted.push(value)
)
expect(activeWatcherArgs().onTranscriptPending).toBeUndefined()
expect(emitted).toEqual([])
})
it('emits one windowed snapshot with pagination state before live appends', async () => {
watcher.watching = true
watcher.args = null
@@ -48,6 +48,10 @@ const NativeChatSession = z.object({
// locate the file directly when the session id no longer names it (recent
// Claude Code). Optional for back-compat with older clients.
transcriptPath: z.string().min(1).optional(),
// A pending snapshot is not authoritative transcript history. Only clients
// that advertise this semantic may receive one; legacy clients treat it as a
// settled empty read and can overwrite retention / unblock launch drafts.
capabilities: z.object({ transcriptPending: z.literal(1).optional() }).optional(),
beforeOffset: z.number().int().nonnegative().optional()
})
@@ -288,6 +292,15 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [
...(lifecycle ? { lifecycle } : {})
})
},
...(params.capabilities?.transcriptPending === 1
? {
onTranscriptPending: () => {
if (!closed) {
emit({ type: 'snapshot', messages: [], hasMore: false, pending: true })
}
}
}
: {}),
onReplace: (messages, hasMore, beforeOffset, lifecycle) => {
if (closed) {
return
+3
View File
@@ -22,6 +22,9 @@ export type NativeChatSubscriptionFrame =
hasMore: boolean
error?: string
lifecycle?: NativeChatTurnLifecycle
/** No transcript exists behind this window yet — render it, but do not
* treat it as a settled read of the session's history. */
pending?: boolean
}
| {
type: 'replacement'
@@ -3,6 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../store'
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
import { isNativeChatTranscriptUnsettled } from './use-native-chat-live-session'
import { selectNativeChatViewState } from './native-chat-view-state'
import { NativeChatMessageList } from './NativeChatMessageList'
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
@@ -142,7 +143,9 @@ function NativeChatResolvedView({
terminalTabId,
agent,
messages: session.messages,
transcriptLoading: session.readPhase === 'loading'
// 'awaiting' counts too: adopting a prefill against a transcript that hasn't
// flushed would re-offer a prompt the user already submitted.
transcriptLoading: isNativeChatTranscriptUnsettled(session.readPhase)
})
// The live-session merge reconciles hooks with replayable transcript turn
// boundaries; all working consumers must use that one lifecycle decision.
@@ -181,6 +181,13 @@ describe('runtime subscribe', () => {
transport.subscribe({ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1' }, onFrame)
await Promise.resolve()
expect(runtimeEnvironmentsSubscribe).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({ capabilities: { transcriptPending: 1 } })
}),
expect.any(Object)
)
deliver({ type: 'appended', messages: [message('m-1')] })
deliver({ type: 'snapshot', messages: [message('m-snapshot')] })
deliver({ type: 'replacement', messages: [message('m-replacement')], hasMore: true })
@@ -287,6 +294,39 @@ describe('runtime subscribe', () => {
})
})
it('forwards the pending flag and still treats the real snapshot as the initial frame', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()
const onFrame = vi.fn()
const transport = getNativeChatSessionTransport(ENV)
transport.subscribe(
{ subscriptionId: 's-1', agent: 'claude', sessionId: 'sess-1', limit: 1 },
onFrame
)
await Promise.resolve()
// The host's unflushed-transcript frame, then the flush that follows it.
deliver({ type: 'snapshot', messages: [], hasMore: false, pending: true })
deliver({ type: 'snapshot', messages: [message('m-1')] })
expect(onFrame).toHaveBeenNthCalledWith(1, {
type: 'snapshot',
messages: [],
hasMore: false,
pending: true
})
// Dropping `pending` would settle an empty read; carrying it onto the real
// snapshot would keep the view unsettled over real history. hasMore proves the
// pending frame did not consume the initial slot — only the initial branch
// infers a filled window from the limit.
expect(onFrame).toHaveBeenNthCalledWith(2, {
type: 'snapshot',
messages: [message('m-1')],
hasMore: true
})
})
it('carries an error on a post-initial reconnect snapshot', async () => {
markRuntimeEnvironmentCompatible(ENV)
const { deliver } = stubSubscribe()
@@ -105,7 +105,14 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
{
selector: environmentId,
method: 'nativeChat.subscribe',
params: { subscriptionId, agent, sessionId, transcriptPath, limit },
params: {
subscriptionId,
agent,
sessionId,
transcriptPath,
limit,
capabilities: { transcriptPending: 1 }
},
timeoutMs: 15_000
},
{
@@ -134,8 +141,12 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
hasMore?: boolean
error?: string
lifecycle?: unknown
pending?: boolean
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle)
// No transcript behind this window yet — forwarded so the view can
// stop spinning, but it is not the settled initial read.
const pending = frame?.pending === true
if (
(frame?.type === 'appended' ||
frame?.type === 'snapshot' ||
@@ -143,13 +154,16 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
Array.isArray(frame.messages)
) {
if (!receivedInitial) {
receivedInitial = true
if (!pending) {
receivedInitial = true
}
onFrame({
type: 'snapshot',
messages: frame.messages,
hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300),
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
...(lifecycle ? { lifecycle } : {}),
...(pending ? { pending: true } : {})
})
} else if (frame.type === 'snapshot') {
onFrame({
@@ -157,7 +171,8 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess
messages: frame.messages,
hasMore: frame.hasMore ?? false,
...(frame.error ? { error: frame.error } : {}),
...(lifecycle ? { lifecycle } : {})
...(lifecycle ? { lifecycle } : {}),
...(pending ? { pending: true } : {})
})
} else {
onFrame(
@@ -0,0 +1,244 @@
// @vitest-environment happy-dom
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { useAppStore } from '@/store'
const { resetTransport, subscriptions, transport } = vi.hoisted(() => {
const subscriptions: { onFrame: (frame: unknown) => void }[] = []
const transport = { readSession: vi.fn(), subscribe: vi.fn() }
const resetTransport = (): void => {
subscriptions.splice(0)
// Default to an in-flight read so only the frames a test emits move the view.
transport.readSession.mockReset().mockImplementation(() => new Promise(() => {}))
transport.subscribe.mockReset().mockImplementation((_args, onFrame) => {
subscriptions.push({ onFrame })
return vi.fn()
})
}
return { resetTransport, subscriptions, transport }
})
vi.mock('./native-chat-session-transport', () => ({
getNativeChatSessionTransport: () => transport
}))
import {
isNativeChatTranscriptUnsettled,
NOTFOUND_RETRY_WINDOW_MS,
useNativeChatLiveSession,
type NativeChatLiveSession,
type UseNativeChatLiveSessionArgs
} from './use-native-chat-live-session'
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
const BASE_ARGS: UseNativeChatLiveSessionArgs = {
paneKey: 'tab-1:leaf-1',
agent: 'claude',
sessionId: 'session-1',
transcriptPath: '/home/agent/session-1.jsonl',
enabled: true
}
/** The frame the host emits once it has waited out the flush grace period. */
const PENDING_FRAME = { type: 'snapshot', messages: [], hasMore: false, pending: true }
function assistant(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 1,
source: 'transcript'
}
}
describe('useNativeChatLiveSession — unflushed transcript (pending frame)', () => {
let root: Root
let latest: NativeChatLiveSession | null = null
function Probe(props: UseNativeChatLiveSessionArgs): null {
latest = useNativeChatLiveSession(props)
return null
}
async function render(props: UseNativeChatLiveSessionArgs = BASE_ARGS): Promise<void> {
await act(async () => {
root.render(createElement(Probe, props))
await Promise.resolve()
await Promise.resolve()
})
}
async function emit(frame: unknown): Promise<void> {
await act(async () => {
subscriptions[0]?.onFrame(frame)
await Promise.resolve()
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
root = createRoot(document.createElement('div'))
latest = null
resetTransport()
useAppStore.setState({ agentStatusByPaneKey: {} })
})
afterEach(() => {
act(() => root.unmount())
vi.useRealTimers()
})
it('spins while neither the read nor the stream has produced anything', async () => {
await render()
expect(latest?.readPhase).toBe('loading')
expect(latest?.status).toBe('loading')
})
it('leaves the loading surface on a pending frame without claiming a settled read', async () => {
await render()
await emit(PENDING_FRAME)
expect(latest?.readPhase).toBe('awaiting')
expect(latest?.status).not.toBe('loading')
expect(latest?.status).not.toBe('error')
expect(latest?.messages).toEqual([])
})
it('keeps the readSession seed live, so the real transcript still backfills', async () => {
// The seed is the only thing that repairs the pane when the flush lands
// between the stream's initial drain and its next event — a pending frame
// must not consume it the way a real snapshot does.
let settleRead = (_result: { messages: NativeChatMessage[] }): void => {}
transport.readSession.mockImplementationOnce(
() => new Promise((resolve) => (settleRead = resolve))
)
await render()
await emit(PENDING_FRAME)
await act(async () => {
settleRead({ messages: [assistant('flushed-later')] })
await Promise.resolve()
})
expect(latest?.readPhase).toBe('ready')
expect(latest?.messages.map((message) => message.id)).toEqual(['flushed-later'])
})
it('settles to ready when the real snapshot arrives on the same subscription', async () => {
await render()
await emit(PENDING_FRAME)
await emit({ type: 'snapshot', messages: [assistant('m-1')], hasMore: false })
expect(latest?.readPhase).toBe('ready')
expect(latest?.messages.map((message) => message.id)).toEqual(['m-1'])
})
it('errors once the not-found window expires with no word from the host', async () => {
// Control for the next case: proves the clock really drives the retry loop,
// so a passing suppression test can't be the timers never firing.
vi.useFakeTimers()
transport.readSession.mockResolvedValue({ error: 'no transcript', notFound: true })
await render()
await act(async () => {
await vi.advanceTimersByTimeAsync(NOTFOUND_RETRY_WINDOW_MS + 30_000)
})
expect(latest?.readPhase).toBe('error')
})
it('stops duplicate seed polling once the live stream owns the pending transcript', async () => {
// The reported bug: a tab that is never prompted never writes a transcript, so
// every read is notFound forever. The host has told us that is expected.
vi.useFakeTimers()
let settleRead = (_result: { error: string; notFound: true }): void => {}
transport.readSession.mockImplementationOnce(
() => new Promise((resolve) => (settleRead = resolve))
)
await render()
await emit(PENDING_FRAME)
await act(async () => {
settleRead({ error: 'no transcript', notFound: true })
await Promise.resolve()
await vi.advanceTimersByTimeAsync(NOTFOUND_RETRY_WINDOW_MS + 30_000)
})
expect(latest?.readPhase).toBe('awaiting')
expect(latest?.status).not.toBe('error')
// The resolve-poll subscription owns the eventual initial drain; continuing
// the seed too would issue one redundant filesystem read every 10 seconds.
expect(transport.readSession).toHaveBeenCalledOnce()
})
it('does not drop live appends already merged into the window', async () => {
await render()
await emit({ type: 'appended', messages: [assistant('appended-1')] })
await emit(PENDING_FRAME)
expect(latest?.readPhase).toBe('awaiting')
expect(latest?.messages.map((message) => message.id)).toEqual(['appended-1'])
})
})
describe('useNativeChatRetainedSession — unflushed transcript', () => {
let root: Root
let latest: NativeChatLiveSession | null = null
function Probe(props: UseNativeChatLiveSessionArgs): null {
latest = useNativeChatRetainedSession(props)
return null
}
async function emit(frame: unknown): Promise<void> {
await act(async () => {
subscriptions[0]?.onFrame(frame)
await Promise.resolve()
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
root = createRoot(document.createElement('div'))
latest = null
resetTransport()
useAppStore.setState({ agentStatusByPaneKey: {} })
})
afterEach(() => {
act(() => root.unmount())
})
it('retains committed history instead of capturing the empty pending window', async () => {
await act(async () => {
root.render(createElement(Probe, BASE_ARGS))
await Promise.resolve()
await Promise.resolve()
})
await emit({ type: 'snapshot', messages: [assistant('committed')], hasMore: false })
await emit(PENDING_FRAME)
expect(latest?.readPhase).toBe('awaiting')
expect(latest?.messages.map((message) => message.id)).toEqual(['committed'])
})
})
describe('isNativeChatTranscriptUnsettled', () => {
it('covers both unsettled phases and neither settled one', () => {
// The launch-draft gate in NativeChatView reads this: treating 'awaiting' as
// settled would re-offer a prefill the user already submitted.
expect(isNativeChatTranscriptUnsettled('loading')).toBe(true)
expect(isNativeChatTranscriptUnsettled('awaiting')).toBe(true)
expect(isNativeChatTranscriptUnsettled('ready')).toBe(false)
expect(isNativeChatTranscriptUnsettled('error')).toBe(false)
})
})
@@ -62,13 +62,25 @@ function nextSubscriptionId(): string {
}
// Why: a new session's transcript can take minutes to appear on disk (#8401).
const NOTFOUND_RETRY_WINDOW_MS = 60_000
// Only a guess at the flush delay — a host that reports the transcript pending
// overrides it outright. Exported for tests.
export const NOTFOUND_RETRY_WINDOW_MS = 60_000
export type ReadState =
| { phase: 'loading' }
/** The host reported no transcript behind this window yet: rendered, but not a
* settled read, so nothing may treat the empty list as real history. */
| { phase: 'awaiting' }
| { phase: 'ready'; messages: NativeChatMessage[] }
| { phase: 'error'; error: string }
/** True while no transcript read has settled — 'loading' and 'awaiting' alike.
* Consumers that must not act on `messages` as real history use this, not a
* bare `!== 'ready'`, which would also swallow the error surface. */
export function isNativeChatTranscriptUnsettled(phase: ReadState['phase']): boolean {
return phase === 'loading' || phase === 'awaiting'
}
/**
* Renderer hook that streams a NativeChatSession for a pane: windowed
* `readSession` + live `subscribe` tail, merged with live hook turn-state.
@@ -156,6 +168,9 @@ export function useNativeChatLiveSession(
let cancelled = false
// Set by the first authoritative frame so the readSession seed below can't clobber a live snapshot.
let frameArrived = false
// Set once the host reports no transcript on disk yet, which makes a notFound
// read known-good news rather than a failure worth surfacing.
let transcriptPending = false
const retryTimer = createNativeChatReadRetryTimer()
const retryStartedAt = Date.now()
// Re-bound as a const: TS drops the `!sessionId` narrowing inside the hoisted nested function.
@@ -171,7 +186,7 @@ export function useNativeChatLiveSession(
// Independent initial seed in case subscribe never delivers a snapshot; applied only until an authoritative frame lands so a live snapshot wins.
function loadSession(attempt: number): void {
if (!latestEnabled.current || frameArrived) {
if (!latestEnabled.current || frameArrived || transcriptPending) {
return
}
void transport
@@ -181,10 +196,16 @@ export function useNativeChatLiveSession(
return
}
if (result && 'error' in result) {
// A not-yet-flushed transcript: stay in 'loading' and retry with backoff instead of a permanent error (#8401).
if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) {
retryTimer.schedule(attempt, () => loadSession(attempt + 1))
return
if (result.notFound) {
// The live stream owns recovery once it confirms the missing file;
// an older host gets the bounded seed retry as its fallback.
if (transcriptPending) {
return
}
if (Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) {
retryTimer.schedule(attempt, () => loadSession(attempt + 1))
return
}
}
setRead({ phase: 'error', error: result.error })
return
@@ -226,6 +247,17 @@ export function useNativeChatLiveSession(
setRead({ phase: 'error', error: frame.error })
return
}
if (frame.type === 'snapshot' && frame.pending === true) {
// No transcript exists yet (an agent that hasn't flushed, or was never
// prompted). Move off 'loading' so the view stops spinning, but keep
// an in-flight seed and appended tail eligible — this is not a read.
transcriptPending = true
// The live resolve-poll stream now owns the eventual initial drain;
// stop duplicating its filesystem probes forever from the renderer.
retryTimer.cancel()
setRead({ phase: 'awaiting' })
return
}
frameArrived = true
transcriptLifecycleControl.replace(frame.lifecycle)
replaceList(appendMergerRef.current, frame.messages)
@@ -45,7 +45,8 @@ export function createWebNativeChatApi(): NativeChatApi {
sessionId: args.sessionId,
subscriptionId: args.subscriptionId,
transcriptPath: args.transcriptPath,
limit: args.limit
limit: args.limit,
capabilities: { transcriptPending: 1 }
},
{
onResponse: (response) => {
@@ -70,8 +71,11 @@ export function createWebNativeChatApi(): NativeChatApi {
hasMore?: boolean
error?: string
lifecycle?: unknown
pending?: boolean
}
const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle)
// No transcript behind this window yet — forwarded so the view can stop spinning, but it is not the settled initial read.
const pending = result?.pending === true
if (
(result?.type === 'appended' ||
result?.type === 'snapshot' ||
@@ -79,13 +83,16 @@ export function createWebNativeChatApi(): NativeChatApi {
Array.isArray(result.messages)
) {
if (!receivedInitial) {
receivedInitial = true
if (!pending) {
receivedInitial = true
}
onFrame({
type: 'snapshot',
messages: result.messages,
hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300),
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
...(lifecycle ? { lifecycle } : {}),
...(pending ? { pending: true } : {})
})
} else if (result.type === 'snapshot') {
onFrame({
@@ -93,7 +100,8 @@ export function createWebNativeChatApi(): NativeChatApi {
messages: result.messages,
hasMore: result.hasMore ?? false,
...(result.error ? { error: result.error } : {}),
...(lifecycle ? { lifecycle } : {})
...(lifecycle ? { lifecycle } : {}),
...(pending ? { pending: true } : {})
})
} else {
onFrame(
@@ -86,6 +86,64 @@ describe('web native chat preload API', () => {
}
])
})
it('forwards the pending flag and still treats the real snapshot as the initial frame', async () => {
const message = {
id: 'a-1',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: 'flushed' }],
timestamp: 7,
source: 'transcript' as const
}
let deliver: (result: unknown) => void = () => {}
let subscribeParams: unknown
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
subscribe(
_method: string,
params: unknown,
callbacks: { onResponse: (response: RuntimeRpcResponse<unknown>) => void }
): Promise<{ unsubscribe: () => void }> {
subscribeParams = params
deliver = (result) =>
callbacks.onResponse({
id: 'stream-1',
ok: true,
result,
_meta: { runtimeId: 'runtime-1' }
})
return Promise.resolve({ unsubscribe: vi.fn() })
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
const frames: unknown[] = []
globals.window.api.nativeChat.subscribe(
{ subscriptionId: 'sub-1', agent: 'claude', sessionId: 'session-1', limit: 1 },
(frame) => frames.push(frame)
)
await Promise.resolve()
expect(subscribeParams).toMatchObject({ capabilities: { transcriptPending: 1 } })
// The host's unflushed-transcript frame, then the flush that follows it.
deliver({ type: 'snapshot', messages: [], hasMore: false, pending: true })
deliver({ type: 'snapshot', messages: [message] })
// Dropping `pending` would settle an empty read as the session's history.
// hasMore proves the pending frame did not consume the initial slot — only
// the initial branch infers a filled window from the limit.
expect(frames).toEqual([
{ type: 'snapshot', messages: [], hasMore: false, pending: true },
{ type: 'snapshot', messages: [message], hasMore: true }
])
})
})
describe('web MiniMax preload API', () => {