From 2df2e195a5d5cdb67643bcfc976f5205b0ff1148 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:06:35 -0700 Subject: [PATCH] fix(native-chat): settle loading spinner on explicit turn boundaries Explicit transcript turn-lifecycle markers now fully replace the prose-fallback settlement path. Remove the now-unused `turnLifecycleCapable` flag and wire lifecycle to suppress spinner even when hook status lingers. Refine Claude lifecycle detection to distinguish terminal stops from mid-turn tool_use rows, exclude harness noise from new-generation detection, and apply clock-skew slack over SSH/relay. Serialize PTY sends per line to prevent rapid prompts from gluing before Enter, clearing unsubmitted input on cancel. Update working suppression to detect epoch rollovers so interrupt+next-turn without a ready gap resets the spinner correctly. --- .../native-chat-subscribe-lifecycle.test.ts | 5 +- src/main/ipc/native-chat.ts | 17 +- .../transcript-line-decoders-claude.ts | 3 +- .../transcript-line-decoders-codex.ts | 3 +- src/main/native-chat/transcript-reader.ts | 1 - .../transcript-turn-lifecycle.test.ts | 119 +++++++- .../native-chat/transcript-turn-lifecycle.ts | 96 +++++-- .../native-chat/transcript-turn-markers.ts | 26 ++ src/main/native-chat/transcript-watch.test.ts | 33 +++ .../runtime/rpc/methods/native-chat.test.ts | 9 +- src/main/runtime/rpc/methods/native-chat.ts | 8 - src/preload/api-types.ts | 5 - .../components/native-chat/NativeChatView.tsx | 29 +- .../native-chat/native-chat-live-status.ts | 41 ++- .../native-chat-pending-occurrence.ts | 119 ++++++++ .../native-chat/native-chat-pending.test.ts | 27 ++ .../native-chat/native-chat-pending.ts | 62 +++-- .../native-chat/native-chat-pty-send-queue.ts | 191 +++++++++++++ .../native-chat-runtime-contract.ts | 1 - .../native-chat-runtime-send.test.ts | 258 ++++++++++++------ .../native-chat/native-chat-runtime-send.ts | 163 +++++++---- .../native-chat-session-transport.test.ts | 59 ++-- .../native-chat-session-transport.ts | 6 - .../native-chat-working-suppression.test.ts | 19 ++ .../native-chat-working-suppression.ts | 28 +- .../use-native-chat-hook-status.ts | 17 +- .../use-native-chat-live-session.test.ts | 46 +++- .../use-native-chat-live-session.ts | 14 +- .../use-native-chat-session-option-command.ts | 49 ++-- .../use-native-chat-transcript-lifecycle.ts | 44 ++- src/renderer/src/web/web-preload-api.test.ts | 5 +- src/renderer/src/web/web-preload-api.ts | 6 - 32 files changed, 1145 insertions(+), 364 deletions(-) create mode 100644 src/main/native-chat/transcript-turn-markers.ts create mode 100644 src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts diff --git a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts index 4db836abaf2..683e0271086 100644 --- a/src/main/ipc/native-chat-subscribe-lifecycle.test.ts +++ b/src/main/ipc/native-chat-subscribe-lifecycle.test.ts @@ -262,7 +262,6 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable: true, error: 'Transcript unavailable' } }) @@ -281,8 +280,7 @@ describe('nativeChat subscribe lifecycle', () => { frame: { type: 'snapshot', messages: [], - hasMore: false, - turnLifecycleCapable: true + hasMore: false } }) }) @@ -302,7 +300,6 @@ describe('nativeChat subscribe lifecycle', () => { type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable: true, lifecycle } }) diff --git a/src/main/ipc/native-chat.ts b/src/main/ipc/native-chat.ts index 656b4252e10..6521bef3964 100644 --- a/src/main/ipc/native-chat.ts +++ b/src/main/ipc/native-chat.ts @@ -11,7 +11,6 @@ import { readNativeChatTranscriptTail, type NativeChatTranscriptSubscription } from '../native-chat/transcript-watch' -import { supportsNativeChatTranscriptTurnLifecycle } from '../native-chat/transcript-turn-lifecycle' // Re-export so existing test imports of `clearNativeChatTranscriptCache` from // this module keep working after the cache moved to transcript-read-cache.ts. @@ -36,18 +35,12 @@ async function readSession(args: NativeChatReadSessionArgs): Promise 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW - const result = await readNativeChatTranscriptTail({ + return readNativeChatTranscriptTail({ agent, sessionId, transcriptPath: args.transcriptPath, limit }) - return 'messages' in result - ? { - ...result, - turnLifecycleCapable: supportsNativeChatTranscriptTurnLifecycle(agent) - } - : result } export type NativeChatSubscribeArgs = { @@ -70,20 +63,17 @@ export type NativeChatAppendedPayload = { hasMore: boolean error?: string lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable: boolean } | { type: 'replacement' messages: NativeChatMessage[] hasMore: boolean lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable: boolean } | { type: 'appended' messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable: boolean } } @@ -168,7 +158,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg return } const { subscriptionId, agent, sessionId, transcriptPath } = args - const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(agent) const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DESKTOP_READ_WINDOW // Replace any prior subscription under the same id (session change/resubscribe). const pendingToken = beginPendingSubscription(sender.id, subscriptionId) @@ -193,7 +182,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'snapshot', messages, hasMore, - turnLifecycleCapable, ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) } @@ -210,7 +198,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'replacement', messages, hasMore, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } } satisfies NativeChatAppendedPayload) @@ -224,7 +211,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg frame: { type: 'appended', messages, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } } @@ -258,7 +244,6 @@ async function handleSubscribe(event: IpcMainEvent, args: NativeChatSubscribeArg type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable, error: 'Transcript unavailable' } } diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts index ef792611f35..2e936874454 100644 --- a/src/main/native-chat/transcript-line-decoders-claude.ts +++ b/src/main/native-chat/transcript-line-decoders-claude.ts @@ -12,6 +12,7 @@ import { timestampMs } from '../ai-vault/session-scanner-values' import { claudeContentBlocks } from './transcript-record-blocks' +import { claudeInterruptedMessageId } from './transcript-turn-markers' export function decodeClaudeTranscriptLine( line: string, @@ -27,7 +28,7 @@ export function decodeClaudeTranscriptLine( } const timestamp = parseTimestamp(record.timestamp) const recordMessageId = extractString(record.uuid) ?? fallbackId - if (role === 'user' && extractString(record.interruptedMessageId)) { + if (claudeInterruptedMessageId(record)) { // Why: keep Claude's injected boilerplate out of the user-bubble path while // preserving the interruption as a quiet, replayable conversation status. return { diff --git a/src/main/native-chat/transcript-line-decoders-codex.ts b/src/main/native-chat/transcript-line-decoders-codex.ts index 66a135dec58..98eeb1cc054 100644 --- a/src/main/native-chat/transcript-line-decoders-codex.ts +++ b/src/main/native-chat/transcript-line-decoders-codex.ts @@ -12,6 +12,7 @@ import { timestampMs } from '../ai-vault/session-scanner-values' import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks' +import { CODEX_EVENT_TURN_ABORTED } from './transcript-turn-markers' export function decodeCodexTranscriptLine( line: string, @@ -91,7 +92,7 @@ function codexEventMessage( id: string, timestamp: number | null ): NativeChatMessage | null { - if (payload.type === 'turn_aborted') { + if (payload.type === CODEX_EVENT_TURN_ABORTED) { return { id, role: 'system', diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 1e93d61b014..85cd7357287 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -18,7 +18,6 @@ export type ReadTranscriptResult = | { messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable?: boolean } // notFound marks a retry-worthy miss (transcript not flushed to disk yet, // #8401) as opposed to a real parse/IO error callers surface immediately. diff --git a/src/main/native-chat/transcript-turn-lifecycle.test.ts b/src/main/native-chat/transcript-turn-lifecycle.test.ts index 0748cec285c..e46fcde0020 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.test.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.test.ts @@ -2,15 +2,15 @@ import { describe, expect, it } from 'vitest' import { decodeClaudeTurnLifecycle, decodeCodexTurnLifecycle, - supportsNativeChatTranscriptTurnLifecycle + nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' describe('native chat transcript turn lifecycle', () => { - it('reports which transcript formats have explicit boundaries', () => { - expect(supportsNativeChatTranscriptTurnLifecycle('claude')).toBe(true) - expect(supportsNativeChatTranscriptTurnLifecycle('openclaude')).toBe(true) - expect(supportsNativeChatTranscriptTurnLifecycle('codex')).toBe(true) - expect(supportsNativeChatTranscriptTurnLifecycle('grok')).toBe(false) + it('exposes a lifecycle decoder only for transcript formats with explicit boundaries', () => { + expect(nativeChatTurnLifecycleDecoderForAgent('claude')).not.toBeNull() + expect(nativeChatTurnLifecycleDecoderForAgent('openclaude')).not.toBeNull() + expect(nativeChatTurnLifecycleDecoderForAgent('codex')).not.toBeNull() + expect(nativeChatTurnLifecycleDecoderForAgent('grok')).toBeNull() }) it('decodes Codex task boundaries with the provider turn id', () => { @@ -108,30 +108,44 @@ describe('native chat transcript turn lifecycle', () => { ).toBeNull() }) - it('does not treat Claude mid-turn assistant rows as completed', () => { + it('does not treat Claude mid-turn tool_use assistant rows as completed', () => { expect( decodeClaudeTurnLifecycle( JSON.stringify({ type: 'assistant', uuid: 'assistant-tool', timestamp: '2026-07-16T23:45:37.608Z', - message: { role: 'assistant', stop_reason: 'tool_use', content: [] } + message: { + role: 'assistant', + stop_reason: 'tool_use', + content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: {} }] + } }), 'fallback' ) ).toBeNull() + }) + it('treats Claude assistant rows with omitted stop_reason and content as completed', () => { expect( decodeClaudeTurnLifecycle( JSON.stringify({ type: 'assistant', uuid: 'assistant-null-stop', timestamp: '2026-07-16T23:45:37.608Z', - message: { role: 'assistant', stop_reason: null, content: [] } + message: { + role: 'assistant', + stop_reason: null, + content: [{ type: 'text', text: 'final answer' }] + } }), 'fallback' ) - ).toBeNull() + ).toEqual({ + state: 'completed', + turnId: 'assistant-null-stop', + timestamp: Date.parse('2026-07-16T23:45:37.608Z') + }) expect( decodeClaudeTurnLifecycle( @@ -139,7 +153,90 @@ describe('native chat transcript turn lifecycle', () => { type: 'assistant', uuid: 'assistant-missing-stop', timestamp: '2026-07-16T23:45:37.608Z', - message: { role: 'assistant', content: [] } + message: { + role: 'assistant', + content: [{ type: 'text', text: 'final answer' }] + } + }), + 'fallback' + ) + ).toEqual({ + state: 'completed', + turnId: 'assistant-missing-stop', + timestamp: Date.parse('2026-07-16T23:45:37.608Z') + }) + }) + + it('does not complete a Claude assistant row that omits stop_reason but carries a tool_use block', () => { + // A pre-tool row can hold prose AND a tool_use block; without stop_reason the + // turn is still mid-flight, so it must not settle the spinner before the tool. + for (const stopReason of [null, undefined] as const) { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'assistant', + uuid: `assistant-pretool-${stopReason}`, + timestamp: '2026-07-16T23:45:37.608Z', + message: { + role: 'assistant', + ...(stopReason === null ? { stop_reason: null } : {}), + content: [ + { type: 'text', text: 'let me check' }, + { type: 'tool_use', id: 't1', name: 'Bash', input: {} } + ] + } + }), + 'fallback' + ) + ).toBeNull() + } + }) + + it('does not complete Claude assistant rows with omitted stop_reason and no content', () => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'assistant', + uuid: 'assistant-empty', + timestamp: '2026-07-16T23:45:37.608Z', + message: { role: 'assistant', stop_reason: null, content: [] } + }), + 'fallback' + ) + ).toBeNull() + }) + + it('does not treat harness noise user rows as a new working generation', () => { + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'user', + uuid: 'task-note-1', + timestamp: '2026-07-16T23:46:10.000Z', + message: { + role: 'user', + content: [ + { + type: 'text', + text: 'background task finished' + } + ] + } + }), + 'fallback' + ) + ).toBeNull() + + expect( + decodeClaudeTurnLifecycle( + JSON.stringify({ + type: 'user', + uuid: 'reminder-1', + timestamp: '2026-07-16T23:46:11.000Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'continue' }] + } }), 'fallback' ) diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts index 609e018820b..b91e4fa7bee 100644 --- a/src/main/native-chat/transcript-turn-lifecycle.ts +++ b/src/main/native-chat/transcript-turn-lifecycle.ts @@ -1,5 +1,6 @@ import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types' import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' +import { isNoiseMessage } from '../../shared/native-chat-noise' import { asRecord, extractString, @@ -7,6 +8,12 @@ import { timestampMs } from '../ai-vault/session-scanner-values' import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' +import { + claudeInterruptedMessageId, + CODEX_EVENT_TURN_ABORTED, + CODEX_EVENT_TURN_COMPLETE, + CODEX_EVENT_TURN_STARTED +} from './transcript-turn-markers' export type NativeChatTurnLifecycleDecoder = ( line: string, @@ -26,10 +33,6 @@ export function nativeChatTurnLifecycleDecoderForAgent( return null } -export function supportsNativeChatTranscriptTurnLifecycle(agent: AgentType): boolean { - return nativeChatTurnLifecycleDecoderForAgent(agent) !== null -} - export function decodeCodexTurnLifecycle( line: string, fallbackId: string @@ -40,16 +43,16 @@ export function decodeCodexTurnLifecycle( return null } if ( - payload.type !== 'task_started' && - payload.type !== 'task_complete' && - payload.type !== 'turn_aborted' + payload.type !== CODEX_EVENT_TURN_STARTED && + payload.type !== CODEX_EVENT_TURN_COMPLETE && + payload.type !== CODEX_EVENT_TURN_ABORTED ) { return null } const state = - payload.type === 'task_started' + payload.type === CODEX_EVENT_TURN_STARTED ? 'working' - : payload.type === 'turn_aborted' + : payload.type === CODEX_EVENT_TURN_ABORTED ? 'interrupted' : 'completed' return { @@ -76,20 +79,34 @@ export function decodeClaudeTurnLifecycle( } const message = asRecord(record.message) const timestamp = lifecycleTimestamp(record.timestamp) - const interruptedMessageId = extractString(record.interruptedMessageId) - if (record.type === 'user' && interruptedMessageId) { + const interruptedMessageId = claudeInterruptedMessageId(record) + if (interruptedMessageId) { // Why: Claude stores its interrupt notice as an injected user row; it ends // the active generation and must not be mistaken for the next user prompt. return { state: 'interrupted', turnId: interruptedMessageId, timestamp } } - // Why: capable hosts disable the prose-fallback settlement path, so every - // real terminal stop_reason must emit completed — not only end_turn. - if (record.type === 'assistant' && isClaudeTerminalStopReason(message?.stop_reason)) { - return { - state: 'completed', - turnId: extractString(record.uuid) ?? extractString(message?.id) ?? fallbackId, - timestamp + if (record.type === 'assistant') { + const stopReason = message?.stop_reason + // Why: capable hosts rely on explicit terminals (prose is only a backup when + // the latest lifecycle is not mid-generation). Emit completed for every real + // end marker — including historical/OpenClaude rows that omit stop_reason — + // while tool_use stays non-terminal so mid-turn tool loops keep working. The + // no-stop_reason backup also excludes rows carrying a tool_use block: a + // pre-tool assistant row that omits stop_reason is mid-turn, not done, so it + // must not settle the spinner before the tool runs. + const isTerminal = + isClaudeTerminalStopReason(stopReason) || + (stopReason == null && + assistantHasRenderableContent(message) && + !assistantHasToolUse(message)) + if (isTerminal) { + return { + state: 'completed', + turnId: extractString(record.uuid) ?? extractString(message?.id) ?? fallbackId, + timestamp + } } + return null } if (record.type !== 'user') { return null @@ -100,6 +117,12 @@ export function decodeClaudeTurnLifecycle( // continuations of the active turn, not a new user-authored generation. return null } + // Why: harness noise (task-notification, system-reminder, …) is user-role in + // the JSONL but not a new generation. Treating it as working would overwrite + // a real terminal marker and re-stick the chat spinner after done/interrupt. + if (isNoiseMessage(decoded)) { + return null + } return { state: 'working', turnId: decoded.id, timestamp } } @@ -107,3 +130,40 @@ function lifecycleTimestamp(value: unknown): number | null { const parsed = timestampMs(value) return Number.isFinite(parsed) ? parsed : null } + +function assistantHasRenderableContent(message: Record | null): boolean { + const content = message?.content + if (typeof content === 'string') { + return content.trim().length > 0 + } + if (!Array.isArray(content)) { + return false + } + return content.some((block) => { + const record = asRecord(block) + if (!record) { + return false + } + if ( + record.type === 'text' && + typeof record.text === 'string' && + record.text.trim().length > 0 + ) { + return true + } + if (record.type === 'thinking' || record.type === 'redacted_thinking') { + return true + } + return false + }) +} + +/** True when an assistant row contains a tool_use block — the turn continues to + * a tool call, so a missing stop_reason must not be read as completion. */ +function assistantHasToolUse(message: Record | null): boolean { + const content = message?.content + if (!Array.isArray(content)) { + return false + } + return content.some((block) => asRecord(block)?.type === 'tool_use') +} diff --git a/src/main/native-chat/transcript-turn-markers.ts b/src/main/native-chat/transcript-turn-markers.ts new file mode 100644 index 00000000000..727bef5d5aa --- /dev/null +++ b/src/main/native-chat/transcript-turn-markers.ts @@ -0,0 +1,26 @@ +// Shared detection of provider-authored turn boundaries. The message decoders +// render a visible status row from these lines; the lifecycle decoders settle +// the chat spinner from the same lines. Keeping the predicates here means the +// two consumers can never disagree about which JSONL line is an interrupt/abort +// or a turn boundary — updating a provider's format touches one place, so a +// rename can't leave a visible "interrupted" row that never settles (or vice +// versa). + +import { extractString } from '../ai-vault/session-scanner-values' + +/** + * The `interruptedMessageId` on a Claude user row when that row is Claude's + * injected interrupt notice rather than a real user prompt. Returns undefined + * for genuine user turns. + */ +export function claudeInterruptedMessageId(record: Record): string | undefined { + if (record.type !== 'user') { + return undefined + } + return extractString(record.interruptedMessageId) ?? undefined +} + +/** Codex `event_msg` payload types that bound a turn's lifecycle. */ +export const CODEX_EVENT_TURN_STARTED = 'task_started' +export const CODEX_EVENT_TURN_COMPLETE = 'task_complete' +export const CODEX_EVENT_TURN_ABORTED = 'turn_aborted' diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 209e9259ad4..1aeced2c3ff 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -228,6 +228,39 @@ describe('subscribeNativeChatTranscript', () => { expect(result).toMatchObject({ lifecycle: { state: 'working', turnId: 'turn-2' } }) }) + it('recovers a completion marker even when trailing non-boundary rows follow it', async () => { + // The lifecycle scan walks newest-first; rows that decode to no boundary + // (tool-results, harness noise) must not hide an earlier real completion + // within the window, or a reconnect snapshot would fail to settle. + const toolResult = `${JSON.stringify({ + type: 'user', + uuid: 'tool-result-1', + timestamp: '2026-06-01T10:00:02.000Z', + message: { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' }] + } + })}\n` + const noise = `${JSON.stringify({ + type: 'user', + uuid: 'note-1', + timestamp: '2026-06-01T10:00:03.000Z', + message: { + role: 'user', + content: [{ type: 'text', text: 'continue' }] + } + })}\n` + const filePath = await tempFile(claudeEndTurnLine('a-1', 'done') + toolResult + noise) + const result = await readNativeChatTranscriptTail({ + agent: 'claude', + sessionId: 'ignored', + filePath, + limit: 40 + }) + + expect(result).toMatchObject({ lifecycle: { state: 'completed', turnId: 'a-1' } }) + }) + it('emits a bulk append in bounded ordered batches', async () => { const filePath = await tempFile('') const batches: NativeChatMessage[][] = [] diff --git a/src/main/runtime/rpc/methods/native-chat.test.ts b/src/main/runtime/rpc/methods/native-chat.test.ts index aa9662668e3..2e0358e338d 100644 --- a/src/main/runtime/rpc/methods/native-chat.test.ts +++ b/src/main/runtime/rpc/methods/native-chat.test.ts @@ -373,7 +373,6 @@ describe('nativeChat.subscribe initial snapshot', () => { type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable: true, error: 'Transcript unavailable' } ]) @@ -397,7 +396,6 @@ describe('nativeChat.subscribe initial snapshot', () => { messages: [], hasMore: false, beforeOffset: 0, - turnLifecycleCapable: true, error: 'Transcript unavailable' } ]) @@ -420,8 +418,7 @@ describe('nativeChat.subscribe initial snapshot', () => { type: 'snapshot', messages: [expect.objectContaining({ id: 'a-1' })], hasMore: false, - beforeOffset: 7, - turnLifecycleCapable: true + beforeOffset: 7 } ]) }) @@ -452,13 +449,11 @@ describe('nativeChat.subscribe initial snapshot', () => { messages: [expect.objectContaining({ id: 'a-1' })], hasMore: false, beforeOffset: 3, - turnLifecycleCapable: true, lifecycle: completed }, { type: 'appended', messages: [], - turnLifecycleCapable: true, lifecycle: completed }, { @@ -466,7 +461,6 @@ describe('nativeChat.subscribe initial snapshot', () => { messages: [expect.objectContaining({ id: 'a-1' })], hasMore: false, beforeOffset: 9, - turnLifecycleCapable: true, lifecycle: completed } ]) @@ -488,7 +482,6 @@ describe('nativeChat.readSession lifecycle payload', () => { expect(result).toMatchObject({ hasMore: false, beforeOffset: 123, - turnLifecycleCapable: true, lifecycle }) expect((result as { messages: NativeChatMessage[] }).messages).toHaveLength(1) diff --git a/src/main/runtime/rpc/methods/native-chat.ts b/src/main/runtime/rpc/methods/native-chat.ts index 64460a1cced..51758dbc803 100644 --- a/src/main/runtime/rpc/methods/native-chat.ts +++ b/src/main/runtime/rpc/methods/native-chat.ts @@ -5,7 +5,6 @@ import { readNativeChatTranscriptTail, subscribeNativeChatTranscript } from '../../../native-chat/transcript-watch' -import { supportsNativeChatTranscriptTurnLifecycle } from '../../../native-chat/transcript-turn-lifecycle' import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core' // Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The @@ -184,7 +183,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ params: NativeChatSession, handler: async (params, { clientKind }) => { const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW - const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(params.agent) const result = await readNativeChatTranscriptTail({ agent: params.agent, sessionId: params.sessionId, @@ -197,7 +195,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ messages: windowForClient(result.messages, clientKind, limit), hasMore: result.hasMore, beforeOffset: result.beforeOffset, - turnLifecycleCapable, ...(result.lifecycle ? { lifecycle: result.lifecycle } : {}) } : result @@ -219,7 +216,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ const cleanupToken = params.subscriptionId ?? `${params.agent}:${params.sessionId}` const subscriptionId = `nativeChat:${connectionId ?? 'local'}:${cleanupToken}` const limit = params.limit ?? MOBILE_NATIVE_CHAT_DEFAULT_WINDOW - const turnLifecycleCapable = supportsNativeChatTranscriptTurnLifecycle(params.agent) runtime.registerSubscriptionCleanup( subscriptionId, () => { @@ -248,7 +244,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ messages: windowForClient(messages, clientKind, limit), hasMore, beforeOffset, - turnLifecycleCapable, ...(error ? { error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -262,7 +257,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ messages: windowForClient(messages, clientKind, limit), hasMore, beforeOffset, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) }) }, @@ -273,7 +267,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ emit({ type: 'appended', messages: sanitizeAppendForClient(messages, clientKind), - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) }) } @@ -288,7 +281,6 @@ export const NATIVE_CHAT_METHODS: readonly RpcAnyMethod[] = [ type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable, error: 'Transcript unavailable' }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 26286fafd5f..ae21f80ef76 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -843,8 +843,6 @@ export type NativeChatReadSessionResult = | { messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle - /** True when this host can decode authoritative transcript turn boundaries. */ - turnLifecycleCapable?: boolean } | { error: string; notFound?: true } @@ -858,20 +856,17 @@ export type NativeChatSubscriptionFrame = hasMore: boolean error?: string lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable?: boolean } | { type: 'replacement' messages: NativeChatMessage[] hasMore: boolean lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable?: boolean } | { type: 'appended' messages: NativeChatMessage[] lifecycle?: NativeChatTurnLifecycle - turnLifecycleCapable?: boolean } /** Wire payload for the `nativeChat:appended` push channel. */ diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index f017783aa37..798712e9ca2 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -162,11 +162,17 @@ function NativeChatResolvedView({ // The agent's in-progress reply preview (hook), shown as a live streaming // bubble while it works — before the completed turn flushes to the transcript. const hookPreview = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.lastAssistantMessage) + // Why: Stop suppression must clear on a newer working epoch even when status + // never leaves 'working' (interrupt + immediate next turn coalesced). + const hookWorkingEpoch = useAppStore( + (s) => s.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null + ) const canSend = useNativeChatCanSend(targetPtyId) // Reuse the verified composer send path for interactive cards and composer // stop (Stop sends ESC, the agent-TUI interrupt key). const interactiveSend = useNativeChatInteractiveSend(terminalTabId, targetPtyId, agent) const [workingInterrupted, setWorkingInterrupted] = useState(false) + const previousWorkingEpochRef = useRef(null) // True while a question card owns the input region, so the composer is hidden. const [questionActive, setQuestionActive] = useState(false) const rootRef = useRef(null) @@ -331,10 +337,23 @@ function NativeChatResolvedView({ const isConversation = viewState.kind === 'ready' useEffect(() => { - if (shouldClearNativeChatWorkingSuppression({ working: liveWorking })) { + if ( + shouldClearNativeChatWorkingSuppression({ + working: liveWorking, + interrupted: workingInterrupted, + workingEpoch: hookWorkingEpoch, + previousWorkingEpoch: previousWorkingEpochRef.current + }) + ) { setWorkingInterrupted(false) } - }, [liveWorking]) + if (liveWorking && hookWorkingEpoch != null) { + previousWorkingEpochRef.current = hookWorkingEpoch + } + if (!liveWorking) { + previousWorkingEpochRef.current = null + } + }, [liveWorking, workingInterrupted, hookWorkingEpoch]) const isWorking = shouldShowNativeChatWorking({ isConversation, working: liveWorking, @@ -343,8 +362,12 @@ function NativeChatResolvedView({ const stopAgent = useCallback(() => { setWorkingInterrupted(true) + // Why: Stop after a submitted turn drops the delayed-write handle once it + // settles, so cancelPendingSends no longer sees the optimistic id. Clear + // the echo cache here so a cancelled prompt cannot stick as a ghost bubble. + setPending(writePendingSendCache(pendingScope, [])) interactiveSend.cancel() - }, [interactiveSend]) + }, [interactiveSend, pendingScope]) const nativeChatFileLinkClick = useNativeChatFileLinkClick(fileLinkContext) // Chat-only font zoom via Cmd/Ctrl +/-/0, gated to the live conversation so diff --git a/src/renderer/src/components/native-chat/native-chat-live-status.ts b/src/renderer/src/components/native-chat/native-chat-live-status.ts index 6d5e6feb453..06efe472dc4 100644 --- a/src/renderer/src/components/native-chat/native-chat-live-status.ts +++ b/src/renderer/src/components/native-chat/native-chat-live-status.ts @@ -22,8 +22,6 @@ export type NativeChatLiveMergeInput = { stateStartedAt?: number | null /** Latest provider-authored turn boundary recovered from the transcript. */ transcriptLifecycle?: NativeChatTurnLifecycle - /** Whether the serving host can decode explicit transcript turn boundaries. */ - turnLifecycleCapable?: boolean /** Claude can finish its lead turn while background children remain active. */ hookHasWorkingSubagents?: boolean /** True before the initial snapshot resolves; forces 'loading'. */ @@ -41,6 +39,8 @@ export type NativeChatLiveMergeInput = { * - errors win outright; live work wins over transcript loading. * - hook 'working' stays authoritative until the hook exits that state OR an * explicit terminal marker for this turn lands. + * - design is hook-first: lifecycle is a terminal suppressor for dropped + * Stop hooks, not a full authority for active-turn reconstruction. */ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): NativeChatSession { const { @@ -50,7 +50,6 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat hookState, stateStartedAt, transcriptLifecycle, - turnLifecycleCapable, hookHasWorkingSubagents, loading, error @@ -64,7 +63,6 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat sources, stateStartedAt, transcriptLifecycle, - turnLifecycleCapable ?? false, hookHasWorkingSubagents ?? false ) if (loading && status !== 'working') { @@ -78,12 +76,14 @@ export function mergeNativeChatLiveSession(input: NativeChatLiveMergeInput): Nat }) } +/** Slack for comparing transcript timestamps to hook receipt times across hosts. */ +const LIFECYCLE_CLOCK_SKEW_SLACK_MS = 2_000 + function liveStatusOverride( hookState: AgentStatusState | null, sources: NativeChatSources, stateStartedAt: number | null | undefined, transcriptLifecycle: NativeChatTurnLifecycle | undefined, - turnLifecycleCapable: boolean, hookHasWorkingSubagents: boolean ): NativeChatSessionStatus | undefined { // Only 'working' drives a live override; blocked/waiting/done leave the @@ -98,18 +98,21 @@ function liveStatusOverride( return undefined } // Why: a lead completion does not end Claude's aggregate turn while a - // background child still runs; the hook roster owns that extra lifetime. + // background child still runs; callers must already scope the roster to the + // current working epoch so prior-turn children cannot veto forever. if (hookHasWorkingSubagents) { return 'working' } if (terminatesCurrentTurn) { return undefined } - // Why: Grok and older remote hosts have no explicit boundary decoder. Keep - // their degraded recovery without letting prose override capable providers. + // Why: prose recovery stays available whenever the latest lifecycle is not an + // explicit in-progress generation. That covers incapable hosts and capable + // hosts whose transcript never emitted a terminal marker for this window. + // Mid-turn (lifecycle === working) keeps prose off so partial assistant rows + // do not settle early on capable providers. if ( - !turnLifecycleCapable && - transcriptLifecycle === undefined && + transcriptLifecycle?.state !== 'working' && trailingAssistantPostDates(sources, stateStartedAt) ) { return undefined @@ -124,12 +127,22 @@ function lifecycleTerminatesCurrentTurn( if (lifecycle?.state !== 'completed' && lifecycle?.state !== 'interrupted') { return false } + // Why: omit/null timestamps are valid on the wire. Prefer the terminal marker + // over a stuck spinner — the latest lifecycle is already last-wins from the + // watcher, so a newer user generation would have replaced it with working. if (stateStartedAt == null || lifecycle.timestamp == null) { - // Why: without comparable timing, a replayed terminal marker could belong to - // the prior turn and must not hide a newer live working signal. - return false + return true } - return lifecycle.timestamp >= stateStartedAt + if (lifecycle.timestamp >= stateStartedAt) { + return true + } + // Why: transcript clocks and hook receipt times can skew over SSH/runtime. + // Only apply slack to real epoch timestamps so small logical clocks used in + // tests (and any non-wall-clock ids) keep strict ordering. + if (lifecycle.timestamp > 1e11 && stateStartedAt > 1e11) { + return lifecycle.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= stateStartedAt + } + return false } function trailingAssistantPostDates( diff --git a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts index 3c94e5bfadd..d552747efb0 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending-occurrence.ts @@ -80,6 +80,125 @@ export function advancedNativeChatUserContentCounts( return advanced } +function nativeChatUserMessageNormalizedText(message: NativeChatMessage): string | null { + if (message.role !== 'user') { + return null + } + const text = normalizeNativeChatPendingText( + message.blocks + .filter(isTextBlock) + .map((block) => block.text) + .join(' ') + ) + return text.length > 0 ? text : null +} + +/** User texts that already have a later non-user turn (ready to prune echoes). */ +export function advancedNativeChatUserTexts( + messages: readonly NativeChatMessage[] +): readonly string[] { + const advanced: string[] = [] + const waiting: string[] = [] + for (const message of messages) { + if (message.role === 'user') { + const text = nativeChatUserMessageNormalizedText(message) + if (text) { + waiting.push(text) + } + continue + } + advanced.push(...waiting) + waiting.length = 0 + } + return advanced +} + +/** All user texts (for hiding optimistic echoes once the turn exists). */ +export function matchingNativeChatUserTexts( + messages: readonly NativeChatMessage[] +): readonly string[] { + const texts: string[] = [] + for (const message of messages) { + const text = nativeChatUserMessageNormalizedText(message) + if (text) { + texts.push(text) + } + } + return texts +} + +/** + * How many leading pending texts concatenate exactly to `userText`. + * Covers rapid-send glue ("joke"+"continue" → "jokecontinue") without matching + * unrelated prefixes ("hi" ↛ "history"). + */ +export function countLeadingPendingTextsGluedToUserText( + pendingTexts: readonly string[], + userText: string +): number { + if (pendingTexts.length === 0 || userText.length === 0) { + return 0 + } + let combined = '' + for (let index = 0; index < pendingTexts.length; index += 1) { + const piece = pendingTexts[index] + if (!piece) { + return 0 + } + combined += piece + if (combined === userText) { + return index + 1 + } + if (!userText.startsWith(combined)) { + return 0 + } + } + return 0 +} + +/** + * Mark pending entries represented only by multi-send glue (2+ consecutive + * optimistic texts concatenated into one transcript user row). Exact single + * matches stay in the content-key/occurrence path so repeated prompts and + * send boundaries keep their existing semantics. + */ +export function selectPendingIndicesRepresentedByUserTexts( + pending: readonly NativeChatPendingOccurrence[], + userTexts: readonly string[] +): Set { + const represented = new Set() + if (pending.length < 2 || userTexts.length === 0) { + return represented + } + const remaining = pending.map((entry, index) => ({ + index, + text: normalizeNativeChatPendingText(entry.text) + })) + for (const userText of userTexts) { + const open = remaining.filter((entry) => !represented.has(entry.index) && entry.text.length > 0) + const gluedCount = countLeadingPendingTextsGluedToUserText( + open.map((entry) => entry.text), + userText + ) + // Why: gluedCount === 1 is an exact match — leave it to occurrence counting. + if (gluedCount < 2) { + continue + } + for (let i = 0; i < gluedCount; i += 1) { + const entry = open[i] + if (!entry) { + continue + } + represented.add(entry.index) + const at = remaining.findIndex((candidate) => candidate.index === entry.index) + if (at >= 0) { + remaining.splice(at, 1) + } + } + } + return represented +} + export function nativeChatPendingMatchKey(pending: NativeChatPendingOccurrence): string { return `${String(pending.afterMessageId)}\0${nativeChatPendingContentKey(pending)}` } diff --git a/src/renderer/src/components/native-chat/native-chat-pending.test.ts b/src/renderer/src/components/native-chat/native-chat-pending.test.ts index b63dba307b2..78ffa01b78f 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.test.ts @@ -140,6 +140,26 @@ describe('prunePendingSends', () => { prunePendingSends(pending, [userMessage('u1', 'repeat'), assistantMessage('a1', 'done')]) ).toEqual([pendingOf('p2', 'repeat')]) }) + + it('prunes consecutive optimistic sends that were glued into one transcript user turn', () => { + const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'tell me a jokecontinue'), + assistantMessage('a1', 'a joke') + ]) + ).toEqual([]) + }) + + it('does not treat an unrelated longer user turn as a glued match', () => { + const pending = [pendingOf('p1', 'hi')] + expect( + prunePendingSends(pending, [ + userMessage('u1', 'history of the project'), + assistantMessage('a1', 'ok') + ]) + ).toEqual(pending) + }) }) describe('pendingSendsAsMessages', () => { @@ -179,6 +199,13 @@ describe('pendingSendsAsMessages', () => { expect(pendingSendsAsMessages(pending, [])).toHaveLength(1) }) + it('hides consecutive optimistic sends once a glued transcript user turn lands', () => { + const pending = [pendingOf('p1', 'tell me a joke'), pendingOf('p2', 'continue')] + expect(pendingSendsAsMessages(pending, [userMessage('u1', 'tell me a jokecontinue')])).toEqual( + [] + ) + }) + it('keeps a repeated prompt visible when its only match predates the send boundary', () => { const history = [userMessage('old-user', 'run tests'), assistantMessage('old-answer', 'passed')] const pending = [{ ...pendingOf('new-send', 'run tests'), afterMessageId: 'old-answer' }] diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 92dbaf17ef7..2fb916c0f73 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -8,12 +8,15 @@ import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache' import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt' import { advancedNativeChatUserContentCounts, + advancedNativeChatUserTexts, assignNativeChatPendingOccurrence, matchingNativeChatUserContentCounts, + matchingNativeChatUserTexts, nativeChatPendingContentKey, nativeChatPendingMatchKey, nativeChatPendingMatchingAfter, - nativeChatPendingOccurrence + nativeChatPendingOccurrence, + selectPendingIndicesRepresentedByUserTexts } from './native-chat-pending-occurrence' /** An optimistic, not-yet-confirmed composer send. */ @@ -133,7 +136,7 @@ export function prunePendingSends( return pending } const consumed = new Map() - const next = pending.filter((entry) => { + const exactKeep = pending.map((entry) => { const contentKey = nativeChatPendingContentKey(entry) const key = nativeChatPendingMatchKey(entry) const available = @@ -143,10 +146,22 @@ export function prunePendingSends( const used = consumed.get(key) ?? 0 const occurrence = nativeChatPendingOccurrence(entry, used) consumed.set(key, Math.max(used, occurrence)) - if (occurrence > available) { - return true + return occurrence > available + }) + // Why: when rapid body writes glued two optimistic sends into one transcript + // user row ("joke"+"continue"→"jokecontinue"), exact keys never match. Drop + // those echoes once an assistant turn advances past the glued user text. + const stillOpen = pending.filter((_, index) => exactKeep[index]) + const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( + stillOpen, + advancedNativeChatUserTexts(messages) + ) + const next = pending.filter((entry, index) => { + if (!exactKeep[index]) { + return false } - return false + const openIndex = stillOpen.indexOf(entry) + return openIndex < 0 || !gluedRepresented.has(openIndex) }) return next.length === pending.length ? pending : next } @@ -162,21 +177,32 @@ export function pendingSendsAsMessages( existingMessages: NativeChatMessage[] = [] ): NativeChatMessage[] { const consumed = new Map() + const exactVisible = pending.map((entry) => { + const contentKey = nativeChatPendingContentKey(entry) + const key = nativeChatPendingMatchKey(entry) + const represented = + matchingNativeChatUserContentCounts( + messagesAfterPendingBoundary(existingMessages, entry) + ).get(contentKey) ?? 0 + const used = consumed.get(key) ?? 0 + const occurrence = nativeChatPendingOccurrence(entry, used) + consumed.set(key, Math.max(used, occurrence)) + return occurrence > represented + }) + // Hide optimistic echoes that were glued into a single transcript user row + // even before the assistant reply lands (matching, not advanced). + const stillVisible = pending.filter((_, index) => exactVisible[index]) + const gluedRepresented = selectPendingIndicesRepresentedByUserTexts( + stillVisible, + matchingNativeChatUserTexts(existingMessages) + ) return pending - .filter((entry) => { - const contentKey = nativeChatPendingContentKey(entry) - const key = nativeChatPendingMatchKey(entry) - const represented = - matchingNativeChatUserContentCounts( - messagesAfterPendingBoundary(existingMessages, entry) - ).get(contentKey) ?? 0 - const used = consumed.get(key) ?? 0 - const occurrence = nativeChatPendingOccurrence(entry, used) - consumed.set(key, Math.max(used, occurrence)) - if (occurrence > represented) { - return true + .filter((entry, index) => { + if (!exactVisible[index]) { + return false } - return false + const openIndex = stillVisible.indexOf(entry) + return openIndex < 0 || !gluedRepresented.has(openIndex) }) .map((entry) => ({ id: `pending:${entry.id}`, diff --git a/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts new file mode 100644 index 00000000000..1ccaefc42a5 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-pty-send-queue.ts @@ -0,0 +1,191 @@ +// Per-PTY serialization for native-chat clear+body+Enter sequences. +// Why: Enter is delayed (busy-agent safety). Without a queue, a second send's +// clear/body can interleave with the first Enter window and still glue or race +// the agent composer. Each sequence owns the line until its Enter fires. +// +// Option commands (model switch) cancel/await this queue first so a delayed chat +// Enter cannot land on Claude's model confirmation dialog. + +export type NativeChatPtySendQueueHandle = { + cancel: () => void + settleAfterMs: number + bodyStarted: () => boolean + finished: () => boolean +} + +export type EnqueueNativeChatPtySendOptions = { + /** + * Called when cancel aborts after `start` began but before Enter was marked + * submitted. Used to clear leftover body text from the agent TUI. + */ + onCancelUnsubmitted?: () => void +} + +type PtyQueueState = { + tail: Promise + freeAt: number + depth: number + handles: Set +} + +const ptyQueues = new Map() + +function getOrCreateState(ptyId: string): PtyQueueState { + let state = ptyQueues.get(ptyId) + if (!state) { + state = { tail: Promise.resolve(), freeAt: Date.now(), depth: 0, handles: new Set() } + ptyQueues.set(ptyId, state) + } + return state +} + +export function resetNativeChatPtySendQueuesForTests(): void { + for (const state of ptyQueues.values()) { + for (const handle of state.handles) { + handle.cancel() + } + } + ptyQueues.clear() +} + +/** Abort every in-flight/queued chat send on this PTY (clears delayed Enter). */ +export function cancelNativeChatPtySends(ptyId: string): void { + const state = ptyQueues.get(ptyId) + if (!state) { + return + } + for (const handle of state.handles) { + handle.cancel() + } +} + +/** Wait until every chat sequence on this PTY has finished or been cancelled. */ +export async function waitForNativeChatPtyIdle(ptyId: string): Promise { + const state = ptyQueues.get(ptyId) + if (!state) { + return + } + await state.tail +} + +/** + * Run `start` only after prior sequences for `ptyId` finish. When the queue is + * idle, `start` runs synchronously so the body write is not deferred a tick. + */ +export function enqueueNativeChatPtySend( + ptyId: string, + durationMs: number, + start: (ctx: { + isCancelled: () => boolean + delay: (ms: number, fn: () => void) => void + /** Call when Enter (or the terminal write that completes the send) fires. */ + markSubmitted: () => void + }) => void, + options?: EnqueueNativeChatPtySendOptions +): NativeChatPtySendQueueHandle { + const now = Date.now() + const state = getOrCreateState(ptyId) + const waitMs = Math.max(0, state.freeAt - now) + const settleAfterMs = waitMs + Math.max(0, durationMs) + state.freeAt = Math.max(now, state.freeAt) + Math.max(0, durationMs) + state.depth += 1 + + let cancelled = false + let bodyStarted = false + let finished = false + let submitted = false + const timers: ReturnType[] = [] + let release: (() => void) | null = null + + const delay = (ms: number, fn: () => void): void => { + const timer = setTimeout(() => { + if (!cancelled) { + fn() + } + }, ms) + timers.push(timer) + } + + const markFinished = (): void => { + finished = true + } + + const markSubmitted = (): void => { + submitted = true + } + + const execute = (): Promise => + new Promise((resolve) => { + release = resolve + if (cancelled) { + markFinished() + resolve() + return + } + bodyStarted = true + start({ isCancelled: () => cancelled, delay, markSubmitted }) + if (durationMs <= 0) { + markSubmitted() + markFinished() + resolve() + return + } + // Why: always release after the declared duration so a cancel mid-flight + // cannot stall the per-pty queue forever. + const done = setTimeout(() => { + markFinished() + resolve() + }, durationMs) + timers.push(done) + }) + + const runPromise = + state.depth === 1 && waitMs === 0 ? execute() : state.tail.then(() => execute()) + + const dropHandle = (): void => { + state.handles.delete(handle) + } + + const settleQueueEntry = (): void => { + state.depth = Math.max(0, state.depth - 1) + markFinished() + dropHandle() + // Why: drop the per-pty record once nothing is in flight so the map does not + // accumulate one permanent entry per pty over a long, multi-pane session. + if (state.depth === 0 && state.handles.size === 0 && ptyQueues.get(ptyId) === state) { + ptyQueues.delete(ptyId) + } + } + + state.tail = runPromise.then(settleQueueEntry, settleQueueEntry) + + const handle: NativeChatPtySendQueueHandle = { + cancel: () => { + if (cancelled) { + return + } + cancelled = true + for (const timer of timers) { + clearTimeout(timer) + } + const shouldClear = bodyStarted && !submitted + markFinished() + // Why: refund only THIS sequence's charged window rather than collapsing + // freeAt to now — later queued sends still hold the line, so a blanket + // reset would understate the next enqueue's settle time and let a send + // card drop while a queued Enter is still pending. + state.freeAt = Math.max(Date.now(), state.freeAt - Math.max(0, durationMs)) + release?.() + release = null + dropHandle() + if (shouldClear) { + options?.onCancelUnsubmitted?.() + } + }, + settleAfterMs, + bodyStarted: () => bodyStarted, + finished: () => finished + } + state.handles.add(handle) + return handle +} diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index aaeca46fc5b..329c93f852f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -47,7 +47,6 @@ export function parseRuntimeNativeChatReadSessionResult( const lifecycle = parseRuntimeNativeChatTurnLifecycle(record.lifecycle) return { messages: record.messages as NativeChatAppendedMessages, - turnLifecycleCapable: record.turnLifecycleCapable === true, ...(lifecycle ? { lifecycle } : {}) } } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts index 730ad84f1a6..95f744baad8 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.test.ts @@ -15,10 +15,12 @@ import { sendNativeChatMessageWithImageAttachments, submitNativeChatPrompt, sendNativeChatAskAnswer, + resetNativeChatPtySendQueuesForTests, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, NATIVE_CHAT_SUBMIT_DELAY_MS, NATIVE_CHAT_QUESTION_STEP_MS, - NATIVE_CHAT_ADVANCE_BUFFER_MS + NATIVE_CHAT_ADVANCE_BUFFER_MS, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT } from './native-chat-runtime-send' import { buildNativeChatImagePasteBytes, @@ -29,24 +31,27 @@ import { const SETTINGS = {} as Parameters[0] const PTY = 'pty-1' +function expectWriteOrder(calls: unknown[][], expected: string[]): void { + expect(calls.map((call) => call[2])).toEqual(expected) +} + describe('sendNativeChatMessage', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() + resetNativeChatPtySendQueuesForTests() }) afterEach(() => { vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() }) - it('writes the framed body immediately, before the Enter', () => { + it('clears the TUI line, then writes the framed body, before the Enter', () => { const handle = sendNativeChatMessage(SETTINGS, PTY, 'hello world') - // Body lands synchronously; Enter is still pending on the timer. - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - expect(sendRuntimePtyInput).toHaveBeenCalledWith( - SETTINGS, - PTY, + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatPasteBytes('hello world') - ) + ]) expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) }) @@ -55,43 +60,119 @@ describe('sendNativeChatMessage', () => { // A short gap would fire Enter while a busy Codex has not yet landed the // paste, submitting an empty box — so nothing must happen before 500ms. vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) }) it('writes the bare carriage-return Enter as a separate delayed write', () => { sendNativeChatMessage(SETTINGS, PTY, 'hi') vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('hi'), + NATIVE_CHAT_SUBMIT + ]) }) - it('cancels the delayed Enter when its owning composer is detached', () => { + it('cancels the delayed Enter and re-clears an unsubmitted body', () => { const handle = sendNativeChatMessage(SETTINGS, PTY, 'hi') handle.cancel() vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + // Pre-send clear + body + cancel clear; Enter must not fire. + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('hi'), + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + ]) + }) + + it('clears leftover unsubmitted body on cancel so the next send cannot glue', async () => { + const handle = sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke') + handle.cancel() + + sendNativeChatMessage(SETTINGS, PTY, 'continue') + // Queue release after cancel is promise-chained; flush so the next body runs. + await Promise.resolve() + await Promise.resolve() + + expect(sendRuntimePtyInput.mock.calls.map((call) => call[2])).toEqual([ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('tell me a joke'), + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // cancel cleanup + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, // next send pre-clear + buildNativeChatPasteBytes('continue') + ]) + }) + + it('does not clear the TUI input when cancel runs after Enter already fired', () => { + const handle = sendNativeChatMessage(SETTINGS, PTY, 'already submitted') + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) + sendRuntimePtyInput.mockClear() + handle.cancel() + + expect(sendRuntimePtyInput).not.toHaveBeenCalled() }) it('matches orca-runtime writeTerminalAction Enter gap (500ms)', () => { expect(NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500) }) + + it('serializes rapid sends on the same PTY so bodies cannot glue before Enter', async () => { + sendNativeChatMessage(SETTINGS, PTY, 'tell me a joke') + sendNativeChatMessage(SETTINGS, PTY, 'continue') + + // First clear+body are immediate; second sequence waits for the first Enter. + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('tell me a joke') + ]) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('tell me a joke'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('continue') + ]) + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) + }) + + it('does not serialize sends across different PTYs', () => { + sendNativeChatMessage(SETTINGS, 'pty-a', 'one') + sendNativeChatMessage(SETTINGS, 'pty-b', 'two') + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('one'), + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('two') + ]) + expect(sendRuntimePtyInput.mock.calls[1]?.[1]).toBe('pty-a') + expect(sendRuntimePtyInput.mock.calls[3]?.[1]).toBe('pty-b') + }) }) describe('sendNativeChatMessageVerified', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInputVerified.mockReset().mockResolvedValue(true) + resetNativeChatPtySendQueuesForTests() }) afterEach(() => { vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() }) - it('awaits body acceptance before the delayed Enter write', async () => { + it('awaits body acceptance before the delayed Enter write (no pre-clear)', async () => { + // Why: model-switch confirmation watches the PTY while this send runs; + // verified option commands must not inject Ctrl+U noise. const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet') - await Promise.resolve() - - expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith( SETTINGS, PTY, @@ -102,6 +183,11 @@ describe('sendNativeChatMessageVerified', () => { expect(await result).toBe(true) expect(sendRuntimePtyInputVerified).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect( + sendRuntimePtyInputVerified.mock.calls.some( + (call) => call[2] === NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + ) + ).toBe(false) }) it('does not send Enter when the body is rejected', async () => { @@ -111,16 +197,40 @@ describe('sendNativeChatMessageVerified', () => { await vi.runAllTimersAsync() expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect( + sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT) + ).toBe(false) }) - it('cancels the pending Enter when its composer detaches', async () => { + it('cancels an in-flight chat Enter before delivering a verified option command', async () => { + sendNativeChatMessage(SETTINGS, PTY, 'hello') + expect(sendRuntimePtyInput).toHaveBeenCalled() + + const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model haiku') + // Chat cancel may Ctrl+U the unsubmitted body; Enter from chat must not fire. + await Promise.resolve() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expect(await result).toBe(true) + const submits = sendRuntimePtyInput.mock.calls.filter((call) => call[2] === NATIVE_CHAT_SUBMIT) + // Only the verified path's Enter — chat's delayed Enter was cancelled. + expect(submits).toHaveLength(0) + expect(sendRuntimePtyInputVerified).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + }) + + it('returns false when the delayed Enter wait is aborted', async () => { const controller = new AbortController() const result = sendNativeChatMessageVerified(SETTINGS, PTY, '/model sonnet', controller.signal) - await Promise.resolve() + await vi.waitFor(() => { + expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + }) controller.abort() expect(await result).toBe(false) - expect(sendRuntimePtyInputVerified).toHaveBeenCalledTimes(1) + expect( + sendRuntimePtyInputVerified.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT) + ).toBe(false) }) }) @@ -128,12 +238,14 @@ describe('sendNativeChatMessageWithImageAttachments', () => { beforeEach(() => { vi.useFakeTimers() sendRuntimePtyInput.mockClear() + resetNativeChatPtySendQueuesForTests() }) afterEach(() => { vi.useRealTimers() + resetNativeChatPtySendQueuesForTests() }) - it('bracket-pastes image paths before prompt text so the TUI creates image chips', () => { + it('clears the line, then bracket-pastes image paths before prompt text', () => { const handle = sendNativeChatMessageWithImageAttachments(SETTINGS, PTY, 'what do you see?', [ '/tmp/orca-paste-image.png' ]) @@ -142,15 +254,12 @@ describe('sendNativeChatMessageWithImageAttachments', () => { NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS ) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( - SETTINGS, - PTY, + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') - ) + ]) vi.advanceTimersByTime(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, @@ -158,8 +267,8 @@ describe('sendNativeChatMessageWithImageAttachments', () => { ) vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(4) }) it('waits the normal submit gap for an attachment-only send', () => { @@ -169,11 +278,16 @@ describe('sendNativeChatMessageWithImageAttachments', () => { expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png') + ]) + vi.advanceTimersByTime(NATIVE_CHAT_SUBMIT_DELAY_MS - 1) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(1) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(2) + expect(sendRuntimePtyInput).toHaveBeenCalledTimes(3) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) @@ -184,7 +298,15 @@ describe('sendNativeChatMessageWithImageAttachments', () => { handle.cancel() vi.runAllTimers() - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) + // Pre-clear + image body + cancel clear; no Enter. + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatImagePasteBytes('/tmp/orca-paste-image.png'), + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT + ]) + expect(sendRuntimePtyInput.mock.calls.some((call) => call[2] === NATIVE_CHAT_SUBMIT)).toBe( + false + ) }) }) @@ -195,9 +317,8 @@ describe('empty prompt submit', () => { it('submits an empty prompt with a bare Enter', () => { submitNativeChatPrompt(SETTINGS, PTY) - - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) + expect(sendRuntimePtyInput).toHaveBeenCalledOnce() + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) }) }) @@ -210,72 +331,49 @@ describe('sendNativeChatAskAnswer', () => { vi.useRealTimers() }) - it('paces each keystroke a full step apart (the proven submit gap + advance buffer)', () => { - expect(NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000) - expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500) - }) - - it('no writes and 0 settle for an empty keystroke list', () => { + it('returns a no-op handle for an empty key group list', () => { const handle = sendNativeChatAskAnswer(SETTINGS, PTY, []) expect(handle.settleAfterMs).toBe(0) - vi.runAllTimers() - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0) + handle.cancel() + expect(sendRuntimePtyInput).not.toHaveBeenCalled() }) - it('single option-number keystroke: fires at t=0, settles a submit gap later', () => { - // The STA-1860 fix: a lone single-select pick is delivered as the option - // NUMBER, with no trailing Enter (the number both selects and commits). - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '2' }]) - expect(handle.settleAfterMs).toBe(NATIVE_CHAT_SUBMIT_DELAY_MS) - - // Scheduled at t=0 (setTimeout 0), not written synchronously. - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(0) - vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '2') - vi.runAllTimers() - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - }) - - it('writes each group a step apart; text groups go through the paste framer', () => { - const groups = [{ raw: '3' }, { text: 'custom answer' }, { raw: '\r' }] - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, groups) + it('paces key groups so selector steps render before the next write', () => { + const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [ + { raw: '1' }, + { raw: '2' }, + { text: 'custom answer' } + ]) expect(handle.settleAfterMs).toBe( 2 * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS ) vi.advanceTimersByTime(0) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, '3') + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '1') - // The next group must wait a full step so the "Type something" row renders. - vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS - 1) - expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) - vi.advanceTimersByTime(1) + vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) + expect(sendRuntimePtyInput).toHaveBeenCalledWith(SETTINGS, PTY, '2') + + vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) expect(sendRuntimePtyInput).toHaveBeenLastCalledWith( SETTINGS, PTY, buildNativeChatPasteBytes('custom answer') ) - - vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS) - expect(sendRuntimePtyInput).toHaveBeenLastCalledWith(SETTINGS, PTY, NATIVE_CHAT_SUBMIT) - - vi.runAllTimers() - const calls = sendRuntimePtyInput.mock.calls.map((c) => c[2]) - expect(calls).toEqual(['3', buildNativeChatPasteBytes('custom answer'), NATIVE_CHAT_SUBMIT]) }) - it('cancel clears every pending keystroke', () => { - const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [ - { raw: '1' }, - { raw: '\x1b[C' }, - { raw: '\r' } - ]) + it('cancels remaining key group timers', () => { + const handle = sendNativeChatAskAnswer(SETTINGS, PTY, [{ raw: '1' }, { raw: '2' }]) vi.advanceTimersByTime(0) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) handle.cancel() - vi.runAllTimers() - // Only the first keystroke landed; the rest were cancelled. + vi.advanceTimersByTime(NATIVE_CHAT_QUESTION_STEP_MS * 2) expect(sendRuntimePtyInput).toHaveBeenCalledTimes(1) }) }) + +describe('constants', () => { + it('exports the ask-answer advance buffer used by interactive cards', () => { + expect(NATIVE_CHAT_ADVANCE_BUFFER_MS).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index a1dca86b65f..de0df04763d 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -1,6 +1,6 @@ -// Runtime send for native chat: writes the framed message body, then the Enter -// as a SEPARATE delayed pty write. Kept apart from the pure byte builders in -// native-chat-send.ts so those stay IO-free and unit-testable without aliases. +// Runtime send for native chat: clear any unsubmitted TUI line, write the framed +// body, then Enter as a SEPARATE delayed pty write. Kept apart from the pure +// byte builders in native-chat-send.ts so those stay IO-free and unit-testable. import { sendRuntimePtyInput, @@ -18,11 +18,24 @@ import { buildNativeChatPasteBytes, NATIVE_CHAT_SUBMIT } from './native-chat-send' +import { + cancelNativeChatPtySends, + enqueueNativeChatPtySend, + resetNativeChatPtySendQueuesForTests, + waitForNativeChatPtyIdle +} from './native-chat-pty-send-queue' export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHAT_SUBMIT_DELAY_MS } +export { resetNativeChatPtySendQueuesForTests } export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 +// Why: agent TUI composers treat Ctrl+U as kill-to-start-of-line. Chat sends +// start from an empty line so a prior cancelled paste cannot glue onto the next +// prompt. Not used on verified option commands — model-switch confirmation +// observes the PTY and Ctrl+U can miss confirmation markers. +export const NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15' + /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { @@ -31,22 +44,46 @@ export type NativeChatSendHandle = { settleAfterMs: number } +type RuntimeSettings = ReturnType + +function clearUnsubmittedAgentInput(settings: RuntimeSettings, ptyId: string): void { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT) +} + /** - * Send a native-chat message through the verified runtime pty path: framed body - * first, then a separate delayed Enter. `sendRuntimePtyInput` branches local - * pty:write vs remote runtime RPC, so this works for SSH panes too. Returns a - * cancel handle so callers can drop the still-pending Enter on unmount/stop. + * Chat message path: + * 1. clear any unsubmitted TUI line + * 2. write framed body + * 3. delayed Enter (separate write — same-write CR can be swallowed by paste) + * + * Serialized per PTY so rapid sends cannot glue before Enter. */ export function sendNativeChatMessage( - settings: ReturnType, + settings: RuntimeSettings, ptyId: string, text: string ): NativeChatSendHandle { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - const timer = setTimeout(() => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - }, NATIVE_CHAT_SUBMIT_DELAY_MS) - return { cancel: () => clearTimeout(timer), settleAfterMs: NATIVE_CHAT_SUBMIT_DELAY_MS } + return enqueueNativeChatPtySend( + ptyId, + NATIVE_CHAT_SUBMIT_DELAY_MS, + ({ isCancelled, delay, markSubmitted }) => { + if (isCancelled()) { + return + } + clearUnsubmittedAgentInput(settings, ptyId) + if (isCancelled()) { + return + } + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }, + { + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + } + ) } function waitForNativeChatSubmit(signal?: AbortSignal): Promise { @@ -70,12 +107,28 @@ function waitForNativeChatSubmit(signal?: AbortSignal): Promise { }) } +/** + * Session-option / slash command path (model switch, /effort, …). + * + * Does not pre-clear the line (model-switch confirmation watches the PTY). + * Cancels any in-flight chat clear/body/Enter on this PTY first so a delayed + * chat Enter cannot dismiss Claude's "Switch model?" dialog. + */ export async function sendNativeChatMessageVerified( - settings: ReturnType, + settings: RuntimeSettings, ptyId: string, text: string, signal?: AbortSignal ): Promise { + // Why: chat sends hold a delayed Enter for 500ms. Opening the model picker in + // that window used to let that Enter hit Claude's confirmation UI, so + // verification timed out with "Could not verify the model change". + cancelNativeChatPtySends(ptyId) + await waitForNativeChatPtyIdle(ptyId) + if (signal?.aborted) { + return false + } + // Why: option commands await remote/SSH acceptance so the Enter cannot race // ahead of the body while a model-change observer is already armed. const bodyAccepted = await sendRuntimePtyInputVerified( @@ -90,7 +143,7 @@ export async function sendNativeChatMessageVerified( } export function sendNativeChatMessageWithImageAttachments( - settings: ReturnType, + settings: RuntimeSettings, ptyId: string, text: string, imagePaths: readonly string[] @@ -98,62 +151,59 @@ export function sendNativeChatMessageWithImageAttachments( if (imagePaths.length === 0) { return sendNativeChatMessage(settings, ptyId, text) } - const timers: ReturnType[] = [] - for (const imagePath of imagePaths) { - sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) - } const trimmedText = text.trim() - if (trimmedText.length > 0) { - timers.push( - setTimeout(() => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - }, NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS) - ) - } - timers.push( - setTimeout( - () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - }, - trimmedText.length > 0 - ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS - ) - ) - return { - cancel: () => { - for (const timer of timers) { - clearTimeout(timer) + const durationMs = + trimmedText.length > 0 + ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + : NATIVE_CHAT_SUBMIT_DELAY_MS + return enqueueNativeChatPtySend( + ptyId, + durationMs, + ({ isCancelled, delay, markSubmitted }) => { + if (isCancelled()) { + return } + clearUnsubmittedAgentInput(settings, ptyId) + if (isCancelled()) { + return + } + for (const imagePath of imagePaths) { + sendRuntimePtyInput(settings, ptyId, buildNativeChatImagePasteBytes(imagePath)) + } + if (trimmedText.length > 0) { + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { + sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + }) + delay(NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }, - settleAfterMs: - trimmedText.length > 0 - ? NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS + NATIVE_CHAT_SUBMIT_DELAY_MS - : NATIVE_CHAT_SUBMIT_DELAY_MS - } + { + onCancelUnsubmitted: () => clearUnsubmittedAgentInput(settings, ptyId) + } + ) } /** Submit a TUI prompt with no body (Enter only) — e.g. a plain submit when the * composer is empty. */ -export function submitNativeChatPrompt( - settings: ReturnType, - ptyId: string -): void { +export function submitNativeChatPrompt(settings: RuntimeSettings, ptyId: string): void { sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) } /** * Answer Claude's AskUserQuestion by writing its keystroke groups (built by * `buildAskAnswerKeys`) to the PTY, one group per `NATIVE_CHAT_QUESTION_STEP_MS` - * step so the arrow-navigate selector applies each before the next — a - * navigation/number keystroke batched with the Enter that follows would commit - * the wrong (default) option. `raw` groups are written verbatim as keystrokes; - * `text` groups (a free-text answer) go through the composer's paste framing. - * Returns a cancel handle clearing every pending timer so a detached sequence - * can't keep writing PTY bytes after unmount/stop. + * step so the arrow-navigate selector applies each before the next. */ export function sendNativeChatAskAnswer( - settings: ReturnType, + settings: RuntimeSettings, ptyId: string, groups: AskAnswerKeyGroup[] ): NativeChatSendHandle { @@ -175,7 +225,6 @@ export function sendNativeChatAskAnswer( clearTimeout(timer) } }, - // Hold the card until the last keystroke has fired and its submit gap passed. settleAfterMs: (groups.length - 1) * NATIVE_CHAT_QUESTION_STEP_MS + NATIVE_CHAT_SUBMIT_DELAY_MS } } 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 18a0ca71767..47832b10897 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 @@ -68,9 +68,7 @@ describe('getNativeChatSessionTransport — selection', () => { it('returns the runtime adapter for an owner on the desktop client', async () => { markRuntimeEnvironmentCompatible(ENV) - runtimeEnvironmentsCall.mockResolvedValue( - okEnvelope({ messages: [], turnLifecycleCapable: true }) - ) + runtimeEnvironmentsCall.mockResolvedValue(okEnvelope({ messages: [] })) const transport = getNativeChatSessionTransport(ENV) await transport.readSession('claude', 'sess-1', 40, '/t/path') @@ -88,9 +86,7 @@ describe('getNativeChatSessionTransport — selection', () => { markRuntimeEnvironmentCompatible(ENV) const lifecycle = { state: 'completed', turnId: 'turn-1', timestamp: 42 } as const runtimeEnvironmentsCall - .mockResolvedValueOnce( - okEnvelope({ messages: [message('valid')], turnLifecycleCapable: true, lifecycle }) - ) + .mockResolvedValueOnce(okEnvelope({ messages: [message('valid')], lifecycle })) .mockResolvedValueOnce( okEnvelope({ messages: [message('invalid')], @@ -101,12 +97,11 @@ describe('getNativeChatSessionTransport — selection', () => { await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({ messages: [message('valid')], - turnLifecycleCapable: true, lifecycle }) + // An invalid lifecycle payload is dropped, leaving prose recovery to settle. await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({ - messages: [message('invalid')], - turnLifecycleCapable: false + messages: [message('invalid')] }) }) @@ -114,13 +109,12 @@ describe('getNativeChatSessionTransport — selection', () => { markRuntimeEnvironmentCompatible(ENV) const lifecycle = { state: 'interrupted', turnId: 'turn-2', timestamp: 43 } as const runtimeEnvironmentsCall.mockResolvedValueOnce( - okEnvelope({ messages: [message('interrupted')], turnLifecycleCapable: true, lifecycle }) + okEnvelope({ messages: [message('interrupted')], lifecycle }) ) const transport = getNativeChatSessionTransport(ENV) await expect(transport.readSession('codex', 'sess-1')).resolves.toEqual({ messages: [message('interrupted')], - turnLifecycleCapable: true, lifecycle }) }) @@ -130,7 +124,6 @@ describe('getNativeChatSessionTransport — selection', () => { runtimeEnvironmentsCall.mockResolvedValueOnce( okEnvelope({ messages: [message('no-ts')], - turnLifecycleCapable: true, lifecycle: { state: 'completed', turnId: 'turn-3' } }) ) @@ -138,7 +131,6 @@ describe('getNativeChatSessionTransport — selection', () => { await expect(transport.readSession('claude', 'sess-1')).resolves.toEqual({ messages: [message('no-ts')], - turnLifecycleCapable: true, lifecycle: { state: 'completed', turnId: 'turn-3', timestamp: null } }) }) @@ -200,20 +192,17 @@ describe('runtime subscribe', () => { expect(onFrame).toHaveBeenNthCalledWith(1, { type: 'snapshot', messages: [message('m-1')], - hasMore: false, - turnLifecycleCapable: false + hasMore: false }) expect(onFrame).toHaveBeenNthCalledWith(2, { type: 'snapshot', messages: [message('m-snapshot')], - hasMore: false, - turnLifecycleCapable: false + hasMore: false }) expect(onFrame).toHaveBeenNthCalledWith(3, { type: 'replacement', messages: [message('m-replacement')], - hasMore: true, - turnLifecycleCapable: false + hasMore: true }) }) @@ -229,7 +218,6 @@ describe('runtime subscribe', () => { deliver({ type: 'snapshot', messages: [message('valid')], - turnLifecycleCapable: true, lifecycle }) deliver({ @@ -242,13 +230,37 @@ describe('runtime subscribe', () => { type: 'snapshot', messages: [message('valid')], hasMore: false, - turnLifecycleCapable: true, lifecycle }) expect(onFrame).toHaveBeenNthCalledWith(2, { type: 'appended', - messages: [message('invalid')], - turnLifecycleCapable: false + messages: [message('invalid')] + }) + }) + + it('omits an invalid lifecycle payload from a stream 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' }, onFrame) + await Promise.resolve() + deliver({ + type: 'snapshot', + messages: [message('seed')], + hasMore: false, + lifecycle: { state: 'completed', turnId: 'turn-ok', timestamp: 1 } + }) + deliver({ + type: 'appended', + messages: [message('bad-lifecycle')], + lifecycle: { state: 'completed', turnId: '', timestamp: undefined } + }) + + expect(onFrame).toHaveBeenNthCalledWith(2, { + type: 'appended', + messages: [message('bad-lifecycle')] }) }) @@ -291,7 +303,6 @@ describe('runtime subscribe', () => { type: 'snapshot', messages: [], hasMore: false, - turnLifecycleCapable: false, error: 'runtime too old' }) }) 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 8428c937a92..0fe22211db0 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 @@ -134,10 +134,8 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess hasMore?: boolean error?: string lifecycle?: unknown - turnLifecycleCapable?: unknown } const lifecycle = parseRuntimeNativeChatTurnLifecycle(frame?.lifecycle) - const turnLifecycleCapable = frame?.turnLifecycleCapable === true if ( (frame?.type === 'appended' || frame?.type === 'snapshot' || @@ -150,7 +148,6 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? frame.messages.length >= (limit ?? 300), - turnLifecycleCapable, ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -159,7 +156,6 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'snapshot', messages: frame.messages, hasMore: frame.hasMore ?? false, - turnLifecycleCapable, ...(frame.error ? { error: frame.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -170,13 +166,11 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess type: 'replacement', messages: frame.messages, hasMore: frame.hasMore ?? false, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } : { type: 'appended', messages: frame.messages, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } ) diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts b/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts index 242f8da97a8..a603e31cdc9 100644 --- a/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-working-suppression.test.ts @@ -29,4 +29,23 @@ describe('native chat working suppression', () => { expect(shouldClearNativeChatWorkingSuppression({ working: true })).toBe(false) expect(shouldClearNativeChatWorkingSuppression({ working: false })).toBe(true) }) + + it('clears suppression when a newer working epoch starts while interrupted', () => { + expect( + shouldClearNativeChatWorkingSuppression({ + working: true, + interrupted: true, + workingEpoch: 20, + previousWorkingEpoch: 10 + }) + ).toBe(true) + expect( + shouldClearNativeChatWorkingSuppression({ + working: true, + interrupted: true, + workingEpoch: 10, + previousWorkingEpoch: 10 + }) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts b/src/renderer/src/components/native-chat/native-chat-working-suppression.ts index b09f5a13dc6..f179798373e 100644 --- a/src/renderer/src/components/native-chat/native-chat-working-suppression.ts +++ b/src/renderer/src/components/native-chat/native-chat-working-suppression.ts @@ -6,6 +6,30 @@ export function shouldShowNativeChatWorking(args: { return args.isConversation && args.working && !args.interrupted } -export function shouldClearNativeChatWorkingSuppression(args: { working: boolean }): boolean { - return !args.working +/** + * Clear local Stop suppression when live work ends, or when a newer working + * epoch starts while suppressed (Stop → immediate next turn without a ready gap). + */ +export function shouldClearNativeChatWorkingSuppression(args: { + working: boolean + interrupted?: boolean + /** Hook `stateStartedAt` for the current working epoch, when known. */ + workingEpoch?: number | null + /** Previous observed working epoch; used to detect a new generation. */ + previousWorkingEpoch?: number | null +}): boolean { + if (!args.working) { + return true + } + // Why: interrupt + next-turn can coalesce so `working` never goes false; a + // newer epoch means the user started another generation and must see it. + if ( + args.interrupted === true && + args.workingEpoch != null && + args.previousWorkingEpoch != null && + args.workingEpoch > args.previousWorkingEpoch + ) { + return true + } + return false } diff --git a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts index 2add8ddac67..0d462dd067d 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-hook-status.ts @@ -10,11 +10,18 @@ export function useNativeChatHookStatus( const stateStartedAt = useAppStore( (store) => store.agentStatusByPaneKey[paneKey]?.stateStartedAt ?? null ) - const hasWorkingSubagents = useAppStore( - (store) => - store.agentStatusByPaneKey[paneKey]?.subagents?.some( - (subagent) => subagent.state === 'working' + // Why: only children that started during the current parent working epoch can + // keep the session working after lead completion. Prior-turn roster leftovers + // (missed SubagentStop, pane reuse) must not veto settle forever. + const hasWorkingSubagents = useAppStore((store) => { + const entry = store.agentStatusByPaneKey[paneKey] + const epochStart = entry?.stateStartedAt + return ( + entry?.subagents?.some( + (subagent) => + subagent.state === 'working' && (epochStart == null || subagent.startedAt >= epochStart) ) ?? false - ) + ) + }) return [state, stateStartedAt, hasWorkingSubagents] } diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index f571f7c432c..821abbe8115 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -99,18 +99,29 @@ describe('mergeNativeChatLiveSession', () => { expect(session.status).toBe('working') }) - it('does not treat assistant prose as turn completion', () => { + it('does not treat assistant prose as turn completion while lifecycle is mid-generation', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, sessionId: 'sess', agent: 'claude', hookState: 'working', stateStartedAt: 1, - turnLifecycleCapable: true + transcriptLifecycle: { state: 'working', turnId: 'u-1', timestamp: 1 } }) expect(session.status).toBe('working') }) + it('recovers via assistant prose when capable host has no in-progress lifecycle', () => { + const session = mergeNativeChatLiveSession({ + sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: 1 + }) + expect(session.status).toBe('ready') + }) + it('settles a dropped working hook from an explicit completion marker', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [user('u-1', 'go'), assistant('a-1', 'done')] }, @@ -118,7 +129,6 @@ describe('mergeNativeChatLiveSession', () => { agent: 'claude', hookState: 'working', stateStartedAt: 1, - turnLifecycleCapable: true, transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } }) expect(session.status).toBe('ready') @@ -143,7 +153,6 @@ describe('mergeNativeChatLiveSession', () => { agent: 'claude', hookState: 'working', stateStartedAt: 5, - turnLifecycleCapable: true, transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 } }) expect(session.status).toBe('working') @@ -161,17 +170,33 @@ describe('mergeNativeChatLiveSession', () => { expect(session.status).toBe('working') }) - it('does not apply an unorderable completion marker to live work', () => { + it('settles an unorderable (null-timestamp) completion marker for live work', () => { const session = mergeNativeChatLiveSession({ sources: { transcript: [assistant('a-1', 'prior')] }, sessionId: 'sess', agent: 'claude', hookState: 'working', stateStartedAt: 5, - turnLifecycleCapable: true, transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: null } }) - expect(session.status).toBe('working') + expect(session.status).toBe('ready') + }) + + it('settles a completion slightly before hook receipt within clock-skew slack', () => { + const hookStartedAt = 1_700_000_000_000 + const session = mergeNativeChatLiveSession({ + sources: { transcript: [assistant('a-1', 'done')] }, + sessionId: 'sess', + agent: 'claude', + hookState: 'working', + stateStartedAt: hookStartedAt, + transcriptLifecycle: { + state: 'completed', + turnId: 'turn-1', + timestamp: hookStartedAt - 500 + } + }) + expect(session.status).toBe('ready') }) it('preserves the assistant fallback when the serving host lacks explicit boundaries', () => { @@ -180,8 +205,7 @@ describe('mergeNativeChatLiveSession', () => { sessionId: 'sess', agent: 'grok', hookState: 'working', - stateStartedAt: 1, - turnLifecycleCapable: false + stateStartedAt: 1 }) expect(session.status).toBe('ready') }) @@ -193,7 +217,6 @@ describe('mergeNativeChatLiveSession', () => { agent: 'claude', hookState: 'working', stateStartedAt: 1, - turnLifecycleCapable: true, transcriptLifecycle: { state: 'completed', turnId: 'turn-1', timestamp: 2 }, hookHasWorkingSubagents: true }) @@ -207,7 +230,6 @@ describe('mergeNativeChatLiveSession', () => { agent: 'claude', hookState: 'working', stateStartedAt: 1, - turnLifecycleCapable: true, transcriptLifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 2 }, hookHasWorkingSubagents: true }) @@ -721,7 +743,6 @@ describe('useNativeChatLiveSession — transport routing', () => { type: 'snapshot', messages: [user('u-1', 'go')], hasMore: false, - turnLifecycleCapable: true, lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 10 } }) ) @@ -732,7 +753,6 @@ describe('useNativeChatLiveSession — transport routing', () => { type: 'snapshot', messages: [user('u-1', 'go')], hasMore: false, - turnLifecycleCapable: true, lifecycle: { state: 'interrupted', turnId: 'turn-1', timestamp: 20 } }) ) 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 c242a3a4d7a..bcca032fac7 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 @@ -127,8 +127,7 @@ export function useNativeChatLiveSession( const [read, setRead] = useState({ phase: 'loading' }) const [hasMore, setHasMore] = useState(false) const [loadingEarlier, setLoadingEarlier] = useState(false) - const [transcriptLifecycle, turnLifecycleCapable, transcriptLifecycleControl] = - useNativeChatTranscriptLifecycle() + const [transcriptLifecycle, transcriptLifecycleControl] = useNativeChatTranscriptLifecycle() // The active read window; raised by loadEarlier to page in older history. const limitRef = useRef(NATIVE_CHAT_INITIAL_LIMIT) @@ -222,10 +221,7 @@ export function useNativeChatLiveSession( return } const messages = result?.messages ?? [] - transcriptLifecycleControl.replace( - result?.lifecycle, - result?.turnLifecycleCapable === true - ) + transcriptLifecycleControl.replace(result?.lifecycle) setRead({ phase: 'ready', messages }) setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) }) @@ -259,14 +255,14 @@ export function useNativeChatLiveSession( setRead({ phase: 'error', error: frame.error }) return } - transcriptLifecycleControl.replace(frame.lifecycle, frame.turnLifecycleCapable === true) + transcriptLifecycleControl.replace(frame.lifecycle) replaceList(appendMergerRef.current, frame.messages) setAppended([]) setRead({ phase: 'ready', messages: appendMergerRef.current.list }) setHasMore(frame.hasMore) return } - transcriptLifecycleControl.append(frame.lifecycle, frame.turnLifecycleCapable === true) + transcriptLifecycleControl.append(frame.lifecycle) // Merge by id (re-emits replace in place) then bound to the window so // the bucket can't grow without limit. The base read still holds older // turns, and the assembler re-dedups the concat, so trimming the recent @@ -398,7 +394,6 @@ export function useNativeChatLiveSession( hookState, stateStartedAt: hookStateStartedAt, transcriptLifecycle, - turnLifecycleCapable, hookHasWorkingSubagents, // Why: a watcher append (fix for #8401) can land content while the read is // still retrying ('loading') or after it settled into 'error' — in both @@ -416,7 +411,6 @@ export function useNativeChatLiveSession( hookState, hookStateStartedAt, transcriptLifecycle, - turnLifecycleCapable, hookHasWorkingSubagents, hasMore, loadingEarlier, diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts index 9720ab9f950..d57fa2110d9 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-option-command.ts @@ -7,6 +7,7 @@ import { } from './native-chat-composer-target' import { pushHistory, type HistoryState } from './native-chat-composer-state' import { sendNativeChatMessageVerified } from './native-chat-runtime-send' +import { cancelNativeChatPtySends, waitForNativeChatPtyIdle } from './native-chat-pty-send-queue' import { createClaudeModelSwitchConfirmationObserver, type ClaudeModelSwitchConfirmationObserver @@ -53,30 +54,38 @@ export function useNativeChatSessionOptionCommand(args: { if (!target || disabled) { throw new Error('No live terminal is available.') } - const detectClaudeConfirmation = - options?.detectAgentInteraction === 'claude-model-switch-confirmation' - let observer: ClaudeModelSwitchConfirmationObserver | null = null - if (detectClaudeConfirmation) { - observer = createClaudeModelSwitchConfirmationObserver({ - ptyId: target.ptyId, - settings: target.settings, - expectedModelLabel: options?.expectedChoiceLabel ?? null - }) - activeObserversRef.current.add(observer) - await observer.ready - if (!mountedRef.current) { - activeObserversRef.current.delete(observer) - observer.dispose() - throw new Error('Native chat command was canceled because the composer closed.') - } - // Why: arm only after the observer reaches the live PTY tail, then - // submit immediately so historical output cannot satisfy the match. - observer.arm() - } const sendController = new AbortController() activeSendsRef.current.add(sendController) + // Why: block composer chat sends for the whole drain+observe+verify window. setIsDispatching(true) + let observer: ClaudeModelSwitchConfirmationObserver | null = null try { + // Why: chat sends keep a delayed Enter for 500ms. Drain them *before* + // arming the model-switch observer so (a) that Enter cannot hit Claude's + // confirmation UI and (b) any Ctrl+U cleanup is outside the observation + // window (Ctrl+U mid-observe can miss "Set model to …" markers). + cancelNativeChatPtySends(target.ptyId) + await waitForNativeChatPtyIdle(target.ptyId) + if (!mountedRef.current || sendController.signal.aborted) { + throw new Error('Native chat command was canceled because the composer closed.') + } + const detectClaudeConfirmation = + options?.detectAgentInteraction === 'claude-model-switch-confirmation' + if (detectClaudeConfirmation) { + observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: target.ptyId, + settings: target.settings, + expectedModelLabel: options?.expectedChoiceLabel ?? null + }) + activeObserversRef.current.add(observer) + await observer.ready + if (!mountedRef.current || sendController.signal.aborted) { + throw new Error('Native chat command was canceled because the composer closed.') + } + // Why: arm only after the observer reaches the live PTY tail, then + // submit immediately so historical output cannot satisfy the match. + observer.arm() + } const accepted = await sendNativeChatMessageVerified( target.settings, target.ptyId, diff --git a/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts index d269c070737..92126528a9a 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-transcript-lifecycle.ts @@ -3,49 +3,37 @@ import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-typ type TranscriptLifecycleState = { lifecycle?: NativeChatTurnLifecycle - capable: boolean } type TranscriptLifecycleControl = { reset: () => void - replace: (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean) => void - append: (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean) => void + replace: (lifecycle: NativeChatTurnLifecycle | undefined) => void + append: (lifecycle: NativeChatTurnLifecycle | undefined) => void revision: () => number replaceFromPagination: (lifecycle: NativeChatTurnLifecycle | undefined, revision: number) => void } export function useNativeChatTranscriptLifecycle(): readonly [ NativeChatTurnLifecycle | undefined, - boolean, TranscriptLifecycleControl ] { - const [state, setState] = useState({ capable: false }) + const [state, setState] = useState({}) // Why: pagination may resolve after a live completion; its older boundary // can update history only when no live lifecycle write won the race. const revisionRef = useRef(0) - const replace = useCallback( - (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean): void => { - revisionRef.current += 1 - setState({ lifecycle, capable }) - }, - [] - ) - const reset = useCallback((): void => replace(undefined, false), [replace]) - const append = useCallback( - (lifecycle: NativeChatTurnLifecycle | undefined, capable: boolean): void => { - if (lifecycle) { - revisionRef.current += 1 - } - setState((current) => { - if (!lifecycle && current.capable === capable) { - return current - } - return { lifecycle: lifecycle ?? current.lifecycle, capable } - }) - }, - [] - ) + const replace = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => { + revisionRef.current += 1 + setState({ lifecycle }) + }, []) + const reset = useCallback((): void => replace(undefined), [replace]) + const append = useCallback((lifecycle: NativeChatTurnLifecycle | undefined): void => { + if (!lifecycle) { + return + } + revisionRef.current += 1 + setState({ lifecycle }) + }, []) const revision = useCallback((): number => revisionRef.current, []) const replaceFromPagination = useCallback( (lifecycle: NativeChatTurnLifecycle | undefined, expectedRevision: number): void => { @@ -62,5 +50,5 @@ export function useNativeChatTranscriptLifecycle(): readonly [ () => ({ reset, replace, append, revision, replaceFromPagination }), [append, replace, replaceFromPagination, reset, revision] ) - return [state.lifecycle, state.capable, control] + return [state.lifecycle, control] } diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index ded856e4f40..102b45c67f3 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -727,7 +727,7 @@ describe('web native chat preload API', () => { return Promise.resolve({ id: 'read-1', ok: true, - result: { messages: [message], turnLifecycleCapable: true, lifecycle }, + result: { messages: [message], lifecycle }, _meta: { runtimeId: 'runtime-1' } }) } @@ -744,7 +744,6 @@ describe('web native chat preload API', () => { type: 'snapshot', messages: [message], hasMore: false, - turnLifecycleCapable: true, lifecycle }, _meta: { runtimeId: 'runtime-1' } @@ -764,7 +763,6 @@ describe('web native chat preload API', () => { await expect(globals.window.api.nativeChat.readSession('claude', 'session-1')).resolves.toEqual( { messages: [message], - turnLifecycleCapable: true, lifecycle } ) @@ -780,7 +778,6 @@ describe('web native chat preload API', () => { type: 'snapshot', messages: [message], hasMore: false, - turnLifecycleCapable: true, lifecycle } ]) diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index bbf8eb3dfc0..3987fc71f3d 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1150,10 +1150,8 @@ function createNativeChatApi(): NativeChatApi { hasMore?: boolean error?: string lifecycle?: unknown - turnLifecycleCapable?: unknown } const lifecycle = parseRuntimeNativeChatTurnLifecycle(result?.lifecycle) - const turnLifecycleCapable = result?.turnLifecycleCapable === true if ( (result?.type === 'appended' || result?.type === 'snapshot' || @@ -1166,7 +1164,6 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? result.messages.length >= (args.limit ?? 300), - turnLifecycleCapable, ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1175,7 +1172,6 @@ function createNativeChatApi(): NativeChatApi { type: 'snapshot', messages: result.messages, hasMore: result.hasMore ?? false, - turnLifecycleCapable, ...(result.error ? { error: result.error } : {}), ...(lifecycle ? { lifecycle } : {}) }) @@ -1186,13 +1182,11 @@ function createNativeChatApi(): NativeChatApi { type: 'replacement', messages: result.messages, hasMore: result.hasMore ?? false, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } : { type: 'appended', messages: result.messages, - turnLifecycleCapable, ...(lifecycle ? { lifecycle } : {}) } )