fix(native-chat): close picker reveal lifecycle races

This commit is contained in:
Brennan Benson
2026-08-27 06:39:04 -07:00
parent d866a785a0
commit 6244468b7b
4 changed files with 60 additions and 5 deletions
@@ -64,11 +64,23 @@ export function cancelNativeChatPtySends(ptyId: string): void {
/** Wait until every chat sequence on this PTY has finished or been cancelled. */
export async function waitForNativeChatPtyIdle(ptyId: string): Promise<void> {
const state = ptyQueues.get(ptyId)
if (!state) {
while (true) {
const state = ptyQueues.get(ptyId)
if (!state) {
return
}
const tail = state.tail
await tail
const current = ptyQueues.get(ptyId)
if (!current) {
return
}
// A send may append while the captured tail drains; include its replacement.
if (current !== state || current.tail !== tail) {
continue
}
return
}
await state.tail
}
/**
@@ -39,4 +39,25 @@ describe('waitForNativeChatSendQueueIdle', () => {
await vi.advanceTimersByTimeAsync(1)
expect(resolved).toBe(true)
})
it('waits for sends appended while an earlier successor is draining', async () => {
const revealingSend = enqueueTimedSend(10)
enqueueTimedSend(20)
const queueIdle = waitForNativeChatSendQueueIdle(PTY_ID, revealingSend.settled)
let resolved = false
void queueIdle?.then(() => {
resolved = true
})
await vi.advanceTimersByTimeAsync(15)
enqueueTimedSend(30)
await vi.advanceTimersByTimeAsync(15)
expect(resolved).toBe(false)
await vi.advanceTimersByTimeAsync(29)
expect(resolved).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(resolved).toBe(true)
})
})
@@ -84,6 +84,21 @@ describe('useNativeChatSlashCommandDispatched', () => {
expect(onSwitchToTerminal).not.toHaveBeenCalled()
})
it('does not reveal after the originating chat view unmounts', async () => {
let settle = (): void => {}
const settled = new Promise<void>((resolve) => {
settle = resolve
})
const hook = render('claude')
act(() => hook.result.current('/resume', settled))
hook.unmount()
settle()
await settled
expect(onSwitchToTerminal).not.toHaveBeenCalled()
})
it('records the marker immediately, not on settle', async () => {
// The `Ran /x` line is local feedback for a command with no transcript turn;
// holding it until the writes finish would lag the user's own action.
@@ -1,4 +1,4 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { AgentType } from '../../../../shared/agent-status-types'
import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
import { nativeChatSlashCommandOpensAgentPicker } from '../../../../shared/native-chat-slash-commands'
@@ -35,6 +35,13 @@ export function useNativeChatSlashCommandDispatched(args: {
onSwitchToTerminal?: (agent: AgentType) => void
}): (command: string, settled?: Promise<void>, cancelled?: () => boolean) => void {
const { agent, commandMarkerScope, setCommandMarkers, onSwitchToTerminal } = args
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
return useCallback(
(command: string, settled?: Promise<void>, cancelled?: () => boolean) => {
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command))
@@ -42,7 +49,7 @@ export function useNativeChatSlashCommandDispatched(args: {
return
}
const reveal = (): void => {
if (cancelled?.()) {
if (!mountedRef.current || cancelled?.()) {
return
}
onSwitchToTerminal?.(agent)