From 98bdd653abcb12e2b8abba57fa0f2aa9ed636110 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:06:47 -0700 Subject: [PATCH] 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 --- .../mobile-native-chat-render-data.test.ts | 7 + .../session/mobile-native-chat-render-data.ts | 7 +- .../mobile-native-chat-stream-frame.test.ts | 30 +++ .../mobile-native-chat-stream-frame.ts | 10 + .../use-mobile-native-chat-session.test.ts | 77 ++++++ .../session/use-mobile-native-chat-session.ts | 28 +- src/main/ipc/native-chat.test.ts | 48 ++++ src/main/ipc/native-chat.ts | 15 ++ .../native-chat/transcript-watch-contract.ts | 5 + .../transcript-watch-unflushed-settle.test.ts | 119 +++++++++ src/main/native-chat/transcript-watch.ts | 40 +++ .../runtime/rpc/methods/native-chat.test.ts | 39 +++ src/main/runtime/rpc/methods/native-chat.ts | 13 + src/preload/api/native-chat-api.ts | 3 + .../components/native-chat/NativeChatView.tsx | 5 +- .../native-chat-session-transport.test.ts | 40 +++ .../native-chat-session-transport.ts | 23 +- ...e-native-chat-live-session-pending.test.ts | 244 ++++++++++++++++++ .../use-native-chat-live-session.ts | 44 +++- .../web/preload-api/web-native-chat-api.ts | 16 +- .../web-preload-api-agent-providers.test.ts | 58 +++++ 21 files changed, 845 insertions(+), 26 deletions(-) create mode 100644 src/main/native-chat/transcript-watch-unflushed-settle.test.ts create mode 100644 src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 1a1811f808d..13fbddf8e69 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -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' diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 73a45db6962..ce24a1fa507 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -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': { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index 1f00bc5ec15..e7733dac1ce 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -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')]) diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index a3cef3d5464..8984a04a0e3 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -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 } : {}), diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 2ff97a3370c..ed2f66c42bf 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -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 subscription’s 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'] }) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index a79b5648dd7..e509b202c2a 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -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, diff --git a/src/main/ipc/native-chat.test.ts b/src/main/ipc/native-chat.test.ts index 4d0e1765a44..680dd315339 100644 --- a/src/main/ipc/native-chat.test.ts +++ b/src/main/ipc/native-chat.test.ts @@ -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) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 4ef0f48b872..f94424b67f6 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -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 diff --git a/src/main/native-chat/transcript-watch-contract.ts b/src/main/native-chat/transcript-watch-contract.ts index f2e46f3581b..13463e524ef 100644 --- a/src/main/native-chat/transcript-watch-contract.ts +++ b/src/main/native-chat/transcript-watch-contract.ts @@ -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, diff --git a/src/main/native-chat/transcript-watch-unflushed-settle.test.ts b/src/main/native-chat/transcript-watch-unflushed-settle.test.ts new file mode 100644 index 00000000000..5eb0becff2c --- /dev/null +++ b/src/main/native-chat/transcript-watch-unflushed-settle.test.ts @@ -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() + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index b2ef5b05ec3..a104bd35d3b 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -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 | 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 diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index af885e35de3..65bb525e798 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 3b8d42287de..8fc86bf695a 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -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 diff --git a/src/preload/api/native-chat-api.ts b/src/preload/api/native-chat-api.ts index 01eac44bde6..90cb5b0cfba 100644 --- a/src/preload/api/native-chat-api.ts +++ b/src/preload/api/native-chat-api.ts @@ -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' diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 4fc6df0f13b..ed798188fcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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. diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 47832b10897..1627f5e3e35 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -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() diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 0fe22211db0..112e7cdc3e0 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -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( diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts new file mode 100644 index 00000000000..c8ef643b1fa --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session-pending.test.ts @@ -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 { + await act(async () => { + root.render(createElement(Probe, props)) + await Promise.resolve() + await Promise.resolve() + }) + } + + async function emit(frame: unknown): Promise { + 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 { + 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) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 6044da30673..d56fb1cbe50 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -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) diff --git a/src/renderer/src/web/preload-api/web-native-chat-api.ts b/src/renderer/src/web/preload-api/web-native-chat-api.ts index 31eec6d92fa..746236330f9 100644 --- a/src/renderer/src/web/preload-api/web-native-chat-api.ts +++ b/src/renderer/src/web/preload-api/web-native-chat-api.ts @@ -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( diff --git a/src/renderer/src/web/web-preload-api-agent-providers.test.ts b/src/renderer/src/web/web-preload-api-agent-providers.test.ts index 1dc55f984ff..35324baaa13 100644 --- a/src/renderer/src/web/web-preload-api-agent-providers.test.ts +++ b/src/renderer/src/web/web-preload-api-agent-providers.test.ts @@ -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) => 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', () => {