Found during the adversarial review of #241. Pre-existing on pylon, not introduced by that branch — the gates are byte-identical before and after.
What happens
In apps/mobile/src/features/threads/ThreadComposer.tsx, the collapsed Stop
control renders under {!isExpanded && !voiceInput.isBusy} and the toolbar Stop
renders under {isToolbarVisible}, where
isToolbarVisible = isExpanded || isVoiceInputPresented.
Those are meant to be mutually exclusive, and normally are. But in the dictation
error phase isVoiceInputPresented is true — the status label is the error
message — while voiceInput.isBusy is false, because the error phase is not a
busy phase. Both conditions then pass at once.
Repro
- Collapsed composer, agent running (so
showStopAction is true).
- Start dictation and drive it to an error — the phase that shows a status
label and keeps Send available.
- Two Stop buttons render simultaneously.
Why it matters
Cosmetic rather than functional — both buttons call props.onStopThread, so
either one works. But it is a duplicated control in a state the user is already
recovering from, and VoiceOver announces the same action twice.
Suggested direction
The collapsed gate wants "not showing the toolbar" rather than "not busy".
Deriving it from isToolbarVisible instead of voiceInput.isBusy would make the
two mutually exclusive by construction rather than by coincidence, and would
close the gap without a second predicate to keep in sync.
Found during the adversarial review of #241. Pre-existing on
pylon, not introduced by that branch — the gates are byte-identical before and after.What happens
In
apps/mobile/src/features/threads/ThreadComposer.tsx, the collapsed Stopcontrol renders under
{!isExpanded && !voiceInput.isBusy}and the toolbar Stoprenders under
{isToolbarVisible}, whereisToolbarVisible = isExpanded || isVoiceInputPresented.Those are meant to be mutually exclusive, and normally are. But in the dictation
error phase
isVoiceInputPresentedis true — the status label is the errormessage — while
voiceInput.isBusyis false, because the error phase is not abusy phase. Both conditions then pass at once.
Repro
showStopActionis true).label and keeps Send available.
Why it matters
Cosmetic rather than functional — both buttons call
props.onStopThread, soeither one works. But it is a duplicated control in a state the user is already
recovering from, and VoiceOver announces the same action twice.
Suggested direction
The collapsed gate wants "not showing the toolbar" rather than "not busy".
Deriving it from
isToolbarVisibleinstead ofvoiceInput.isBusywould make thetwo mutually exclusive by construction rather than by coincidence, and would
close the gap without a second predicate to keep in sync.