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 index 1ccaefc42a5..4ae0ce9fe78 100644 --- 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 @@ -9,6 +9,7 @@ export type NativeChatPtySendQueueHandle = { cancel: () => void settleAfterMs: number + settled: Promise bodyStarted: () => boolean finished: () => boolean } @@ -97,6 +98,16 @@ export function enqueueNativeChatPtySend( const timers: ReturnType[] = [] let release: (() => void) | null = null + const finishEntry = (): void => { + if (finished) { + return + } + finished = true + const resolve = release + release = null + resolve?.() + } + const delay = (ms: number, fn: () => void): void => { const timer = setTimeout(() => { if (!cancelled) { @@ -106,19 +117,17 @@ export function enqueueNativeChatPtySend( timers.push(timer) } - const markFinished = (): void => { - finished = true - } - const markSubmitted = (): void => { submitted = true + finishEntry() } const execute = (): Promise => new Promise((resolve) => { release = resolve if (cancelled) { - markFinished() + release = null + finished = true resolve() return } @@ -126,17 +135,7 @@ export function enqueueNativeChatPtySend( 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 = @@ -148,7 +147,7 @@ export function enqueueNativeChatPtySend( const settleQueueEntry = (): void => { state.depth = Math.max(0, state.depth - 1) - markFinished() + finished = true 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. @@ -157,7 +156,8 @@ export function enqueueNativeChatPtySend( } } - state.tail = runPromise.then(settleQueueEntry, settleQueueEntry) + const settled = runPromise.then(settleQueueEntry, settleQueueEntry) + state.tail = settled const handle: NativeChatPtySendQueueHandle = { cancel: () => { @@ -169,20 +169,19 @@ export function enqueueNativeChatPtySend( 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 + finishEntry() dropHandle() if (shouldClear) { options?.onCancelUnsubmitted?.() } }, settleAfterMs, + settled, bodyStarted: () => bodyStarted, finished: () => finished } diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts index 35837d3e029..32f4947c3d6 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send-launch-draft.test.ts @@ -69,6 +69,37 @@ describe('sendNativeChatMessage with a parked multi-line draft', () => { expect(writes()).toEqual([clearInput, buildNativeChatPasteBytes('edited'), NATIVE_CHAT_SUBMIT]) }) + it('preserves the body-to-Enter gap when the renderer stalls past both nominal deadlines', async () => { + vi.useRealTimers() + const writeTimes = new Map() + sendRuntimePtyInput.mockImplementation((_settings, _pty, bytes: string) => { + writeTimes.set(bytes, performance.now()) + return true + }) + sendNativeChatMessage(SETTINGS, PTY, 'edited', { + clearInput: buildAgentTuiClearInputForText(DRAFT), + confirmCleared: () => true + }) + sendNativeChatMessage(SETTINGS, PTY, 'queued') + + const blockedUntil = + performance.now() + NATIVE_CHAT_CLEAR_CONFIRM_MS + NATIVE_CHAT_SUBMIT_DELAY_MS + 50 + while (performance.now() < blockedUntil) { + // Simulate a renderer long task delaying both nominal deadlines. + } + + await vi.waitFor(() => expect(writeTimes.has(NATIVE_CHAT_SUBMIT)).toBe(true), { + timeout: NATIVE_CHAT_SUBMIT_DELAY_MS + 1_000 + }) + await vi.waitFor(() => expect(writeTimes.has(buildNativeChatPasteBytes('queued'))).toBe(true)) + expect( + writeTimes.get(NATIVE_CHAT_SUBMIT)! - writeTimes.get(buildNativeChatPasteBytes('edited'))! + ).toBeGreaterThanOrEqual(NATIVE_CHAT_SUBMIT_DELAY_MS - 20) + expect(writes().indexOf(NATIVE_CHAT_SUBMIT)).toBeLessThan( + writes().indexOf(buildNativeChatPasteBytes('queued')) + ) + }) + it('widens to a maximal burst when the draft is still observed on the line', () => { const clearInput = buildAgentTuiClearInputForText(DRAFT) sendNativeChatMessage(SETTINGS, PTY, 'edited', { 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 e688c225458..93a63765a46 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 @@ -142,6 +142,23 @@ describe('sendNativeChatMessage', () => { expect(sendRuntimePtyInput).toHaveBeenCalledTimes(6) }) + it('does not let a canceled queued send stall the sends behind it', async () => { + sendNativeChatMessage(SETTINGS, PTY, 'first') + const canceled = sendNativeChatMessage(SETTINGS, PTY, 'canceled') + sendNativeChatMessage(SETTINGS, PTY, 'third') + canceled.cancel() + + await vi.advanceTimersByTimeAsync(NATIVE_CHAT_SUBMIT_DELAY_MS) + + expectWriteOrder(sendRuntimePtyInput.mock.calls, [ + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('first'), + NATIVE_CHAT_SUBMIT, + NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, + buildNativeChatPasteBytes('third') + ]) + }) + it('does not serialize sends across different PTYs', () => { sendNativeChatMessage(SETTINGS, 'pty-a', 'one') sendNativeChatMessage(SETTINGS, 'pty-b', 'two') 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 3f2544c7323..a2974474b2b 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 @@ -63,6 +63,8 @@ export type NativeChatSendHandle = { cancel: () => void /** Time after which every scheduled write has fired and the handle can drop. */ settleAfterMs: number + /** Actual completion, which can outlive the nominal schedule if the renderer stalls. */ + settled?: Promise } type RuntimeSettings = ReturnType @@ -97,7 +99,13 @@ function clearThenWrite( return } delay(NATIVE_CHAT_CLEAR_CONFIRM_MS, () => { - if (!confirmCleared()) { + let cleared = false + try { + cleared = confirmCleared() + } catch { + // An unreadable terminal is unconfirmed; the maximal clear remains safe. + } + if (!cleared) { sendRuntimePtyInput(settings, ptyId, AGENT_TUI_CLEAR_INPUT_MAX) } writeBody() @@ -130,15 +138,17 @@ export function sendNativeChatMessage( if (isCancelled()) { return } - delay(NATIVE_CHAT_SUBMIT_DELAY_MS + clearConfirmDurationMs(options), () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) clearThenWrite(settings, ptyId, options, delay, () => { if (isCancelled()) { return } sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) + // Schedule from the actual body write: an overdue clear-confirm callback + // must not collapse the required body-to-Enter gap after a renderer stall. + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }) }, { @@ -225,16 +235,6 @@ export function sendNativeChatMessageWithImageAttachments( if (isCancelled()) { return } - const clearConfirmMs = clearConfirmDurationMs(options) - if (trimmedText.length > 0) { - delay(clearConfirmMs + NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS, () => { - sendRuntimePtyInput(settings, ptyId, buildNativeChatPasteBytes(text)) - }) - } - delay(durationMs, () => { - sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) - markSubmitted() - }) clearThenWrite(settings, ptyId, options, delay, () => { if (isCancelled()) { return @@ -242,6 +242,20 @@ export function sendNativeChatMessageWithImageAttachments( 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_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) + }) + return + } + delay(NATIVE_CHAT_SUBMIT_DELAY_MS, () => { + sendRuntimePtyInput(settings, ptyId, NATIVE_CHAT_SUBMIT) + markSubmitted() + }) }) }, { diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx index 63525c1e3e7..a9bb8c3be33 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.test.tsx @@ -66,4 +66,30 @@ describe('useNativeChatSendLifecycle', () => { expect(settled.cancel).not.toHaveBeenCalled() expect(onPendingSendCanceled).not.toHaveBeenCalled() }) + + it('keeps a renderer-stalled send cancelable past its nominal schedule', async () => { + vi.useFakeTimers() + let resolveSettled!: () => void + const stalled = { + ...handle(640), + settled: new Promise((resolve) => { + resolveSettled = resolve + }) + } + const onPendingSendCanceled = vi.fn() + const { result, rerender } = renderHook( + ({ targetPtyId }) => useNativeChatSendLifecycle('tab-1', targetPtyId, onPendingSendCanceled), + { initialProps: { targetPtyId: 'pty-1' as string | null } } + ) + + act(() => result.current.trackPendingSend(stalled, 'pending-1')) + act(() => vi.advanceTimersByTime(stalled.settleAfterMs + 1_000)) + rerender({ targetPtyId: 'pty-2' }) + + expect(stalled.cancel).toHaveBeenCalledOnce() + expect(onPendingSendCanceled).toHaveBeenCalledWith('pending-1') + await act(async () => { + resolveSettled() + }) + }) }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts index 41e46148bfe..c325cb2200e 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-send-lifecycle.ts @@ -14,13 +14,15 @@ export function useNativeChatSendLifecycle( const pendingSendHandlesRef = useRef( new Map< NativeChatSendHandle, - { cleanupTimer: ReturnType; pendingId?: string } + { cleanupTimer: ReturnType | null; pendingId?: string } >() ) const cancelPendingSends = useCallback(() => { for (const [handle, entry] of pendingSendHandlesRef.current) { const { cleanupTimer, pendingId } = entry - clearTimeout(cleanupTimer) + if (cleanupTimer !== null) { + clearTimeout(cleanupTimer) + } handle.cancel() if (pendingId) { onPendingSendCanceled?.(pendingId) @@ -29,13 +31,22 @@ export function useNativeChatSendLifecycle( pendingSendHandlesRef.current.clear() }, [onPendingSendCanceled]) const trackPendingSend = useCallback((handle: NativeChatSendHandle, pendingId?: string) => { - const cleanupTimer = setTimeout(() => { + const entry = { + cleanupTimer: null as ReturnType | null, + ...(pendingId ? { pendingId } : {}) + } + pendingSendHandlesRef.current.set(handle, entry) + if (handle.settled) { + void handle.settled.then(() => { + if (pendingSendHandlesRef.current.get(handle) === entry) { + pendingSendHandlesRef.current.delete(handle) + } + }) + return + } + entry.cleanupTimer = setTimeout(() => { pendingSendHandlesRef.current.delete(handle) }, handle.settleAfterMs) - pendingSendHandlesRef.current.set(handle, { - cleanupTimer, - ...(pendingId ? { pendingId } : {}) - }) }, []) // Why: delayed Enter/image writes belong to the exact PTY target. A pane