mirror of
https://github.com/stablyai/orca.git
synced 2026-09-24 08:02:33 +00:00
refactor(mobile): drop the dictation finish id, which ordered nothing
`finishingIdRef` tracked the dictation a stop was finishing, and every state it could name was already named: `cancel`, a disable, an unmount and a newer start each bump the generation or clear the active id, so the finish guard answered the same either way. Its one distinguishing arm released pending audio bytes for a dictation whose budget `closeDictationAudio` had just reset, and could subtract those bytes from a newer dictation's reserve. `acceptingChunksRef` stays: it is what stops a late microphone event being sent after the capture handed over its tail and before the finish goes out. `pendingChunksRef` stays: `stop` awaits it so the finish cannot overtake the last chunk send. The finish guard is pinned by a case that cancels while the finish is in flight; neutered, it reds. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -48,18 +48,14 @@ export function isCurrentMobileDictationStart(
|
||||
return currentGeneration === generation && enabled && activeId === dictationId
|
||||
}
|
||||
|
||||
/** A finish is still this dictation's while nothing has superseded it: `cancel`, a disable, an
|
||||
* unmount and a newer start each bump the generation or clear the active id, and most do both. */
|
||||
export function isCurrentMobileDictationFinish(
|
||||
currentGeneration: number,
|
||||
generation: number,
|
||||
enabled: boolean,
|
||||
activeId: string | null,
|
||||
finishingId: string | null,
|
||||
dictationId: string
|
||||
): boolean {
|
||||
return (
|
||||
currentGeneration === generation &&
|
||||
enabled &&
|
||||
activeId === dictationId &&
|
||||
finishingId === dictationId
|
||||
)
|
||||
return currentGeneration === generation && enabled && activeId === dictationId
|
||||
}
|
||||
|
||||
@@ -104,14 +104,26 @@ function audioOf(request: SentRequest): unknown {
|
||||
: null
|
||||
}
|
||||
|
||||
/** Answers everything the hook has sent except one method, so a case can hold that reply open. */
|
||||
function settleExcept(rpc: FakeRpcClient, sent: SentRequest[], method: string): void {
|
||||
for (const request of rpc.requests.splice(0)) {
|
||||
sent.push(request)
|
||||
if (request.method === method) {
|
||||
rpc.requests.push(request)
|
||||
continue
|
||||
}
|
||||
request.resolve({ id: 'desktop', ok: true, result: {} })
|
||||
}
|
||||
}
|
||||
|
||||
const held: { dictation: UseMobileDictationResult | null } = { dictation: null }
|
||||
|
||||
function mount(client: FakeRpcClient): void {
|
||||
function mount(client: FakeRpcClient, onTranscript: (text: string) => void = () => {}): void {
|
||||
function Probe(): null {
|
||||
held.dictation = useMobileDictation({
|
||||
client,
|
||||
enabled: true,
|
||||
onTranscript: () => {},
|
||||
onTranscript,
|
||||
onError: () => {}
|
||||
})
|
||||
return null
|
||||
@@ -191,3 +203,42 @@ describe('the audio a capture hands over as it ends', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('a finish whose dictation stopped being the current one', () => {
|
||||
it('delivers no transcript when a cancel lands while the finish is in flight', async () => {
|
||||
// What `finishingIdRef` was thought to guard, pinned against the two things that actually do:
|
||||
// `cancel` bumps the generation and clears the active id, and the finish is read against both
|
||||
// before its text reaches the composer. A transcript that arrived here would be typed into a
|
||||
// field the user has already dismissed the microphone from.
|
||||
const rpc = createFakeRpcClient()
|
||||
const sent: SentRequest[] = []
|
||||
const transcripts: string[] = []
|
||||
mount(rpc, (text) => transcripts.push(text))
|
||||
await act(async () => {
|
||||
const started = dictation().start()
|
||||
await pump(rpc, sent)
|
||||
await started
|
||||
})
|
||||
let stopped: Promise<void> = Promise.resolve()
|
||||
await act(async () => {
|
||||
stopped = dictation().stop()
|
||||
// Everything but the finish, which stays in flight while the user cancels.
|
||||
for (let round = 0; round < 4; round += 1) {
|
||||
settleExcept(rpc, sent, 'speech.dictation.finish')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
})
|
||||
expect(sent.map((request) => request.method)).toContain('speech.dictation.finish')
|
||||
await act(async () => {
|
||||
// Started rather than awaited: the cancel's own request has to be answered by the pump below
|
||||
// before it settles, and awaiting it first would deadlock the case rather than the product.
|
||||
const cancelled = dictation().cancel()
|
||||
await pump(rpc, sent)
|
||||
await cancelled
|
||||
await stopped
|
||||
})
|
||||
expect(transcripts).toEqual([])
|
||||
expect(dictation().status).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,7 +46,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget())
|
||||
const acceptingChunksRef = useRef(false)
|
||||
const generationRef = useRef(0)
|
||||
const finishingIdRef = useRef<string | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Native audio events can arrive before passive Effects flush, but refs
|
||||
@@ -103,8 +102,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
const audioChunkQueue = {
|
||||
pendingChunks: pendingChunksRef.current,
|
||||
pendingAudioBudget: pendingAudioBudgetRef.current,
|
||||
shouldReleaseBudget: (id: string) =>
|
||||
activeIdRef.current === id || finishingIdRef.current === id,
|
||||
shouldReleaseBudget: (id: string) => activeIdRef.current === id,
|
||||
failActiveDictation
|
||||
}
|
||||
const sub = capture.onChunk((chunk) => {
|
||||
@@ -206,7 +204,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
|
||||
const generation = generationRef.current + 1
|
||||
generationRef.current = generation
|
||||
finishingIdRef.current = dictationId
|
||||
setStatus('processing')
|
||||
try {
|
||||
// Inside the try so a throwing native shutdown still runs the finally
|
||||
@@ -225,7 +222,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
@@ -244,7 +240,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
@@ -253,7 +248,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
const transcript = rpcPayloadMember(finished, 'text')
|
||||
const text = typeof transcript === 'string' ? transcript.trim() : ''
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
setStatus('idle')
|
||||
@@ -268,9 +262,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
// Hold the wake tag through chunk drain and the finish RPC: a screen
|
||||
// lock mid-processing suspends the app and loses the transcript.
|
||||
void keepAwakeOwner.release(dictationId).catch(() => undefined)
|
||||
if (finishingIdRef.current === dictationId) {
|
||||
finishingIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [capture, failActiveDictation, keepAwakeOwner])
|
||||
|
||||
@@ -279,7 +270,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
const dictationId = activeIdRef.current
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
closeDictationAudio(dictationId)
|
||||
if (client && dictationId) {
|
||||
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
|
||||
@@ -308,7 +298,6 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
const dictationId = activeIdRef.current
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
closeDictationAudio(dictationId)
|
||||
capture.release()
|
||||
if (clientRef.current && dictationId) {
|
||||
|
||||
Reference in New Issue
Block a user