Skip to content

Commit b21cb8f

Browse files
committed
fix(mobile): repair dictation placement defects found in review
Adversarial review of the hand-placed dictation UI found three ways the composer strands the user. All three are in code written by hand rather than ported, and none was caught by typecheck, lint, or 1042 tests. The editor was never frozen. NewTaskDraftScreen passes readOnly={voiceInput.freezesEditor}; the thread composer did not, so the keyboard stayed live during recording. One keystroke makes resolveTranscriptCommit see a changed draft and discard the entire transcript as stale - up to five minutes of speech, silently. It also made every native read-only guard this branch adds dead code on this surface. Send was gated on isVoiceInputPresented rather than voicePresentation.showsSend. Those look equivalent but diverge in exactly one phase: error shows a status label AND keeps send. Any dictation failure - denied permission, no speech detected, the stale-draft error above - left the composer with no send control until the user found the dismiss button. Worse on entry, since dispose() no-ops in the error phase, so a stale error survives navigating away and back. Stop sat inside ComposerToolbarScroller, which is the else branch of the dictation ternary, so an agent was unstoppable for the whole recording and transcription window. Moved to the always-rendered right cluster. Also: guard both submission entry points on blocksSubmission, since canSend is derived above voiceInput and cannot include it; move the 4px spacer outside ComposerDictationToolbar's fixed 44px box, where it was overflowing and clipping the collapsed dictation strip; restore pointerEvents="none" on the glass layer with a comment matching the new sibling structure; and rebrand two T3 Code strings in docs. The showsSend divergence now has a regression test. It is the one defect here that is a pure predicate rather than JSX placement, and it is the one most likely to be reintroduced.
1 parent 7202195 commit b21cb8f

5 files changed

Lines changed: 73 additions & 25 deletions

File tree

apps/mobile/src/features/threads/ThreadComposer.tsx

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -323,8 +323,9 @@ export function ComposerSurface(props: {
323323
chrome="none"
324324
fallbackClassName="border border-border bg-card-translucent"
325325
glassEffectStyle="regular"
326-
// The composer is a passive material containing interactive controls.
327-
// Expo GlassView defaults to non-interactive and both layouts share it.
326+
// Keep native glass out of the interactive content's layout path: the
327+
// content is now a sibling of this layer, not a child of it.
328+
pointerEvents="none"
328329
tintColor="transparent"
329330
layout={layoutTransition}
330331
style={[{ position: "absolute", inset: 0 }, animatedShapeStyle]}
@@ -1261,6 +1262,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
12611262
const { onSendMessage } = props;
12621263

12631264
const handleSend = useCallback(async () => {
1265+
// canSend is derived above voiceInput, so the block lives here.
1266+
// Reachable via a hardware-keyboard Return while recording.
1267+
if (voiceInput.blocksSubmission) return;
12641268
if (!canSend) return;
12651269
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
12661270
if (inFlightThreadIdsRef.current.has(threadKey)) return;
@@ -1292,6 +1296,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
12921296
voiceInput.blocksSubmission,
12931297
]);
12941298
const handleQueueFollowUp = useCallback(async () => {
1299+
// canSend is derived above voiceInput, so the block lives here.
1300+
// Reachable via a hardware-keyboard Return while recording.
1301+
if (voiceInput.blocksSubmission) return;
12951302
if (!canSend) return;
12961303
const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id);
12971304
if (inFlightThreadIdsRef.current.has(threadKey) || isMutatingSessionInputQueue) return;
@@ -1612,6 +1619,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
16121619
ref={inputRef}
16131620
multiline
16141621
value={props.draftMessage}
1622+
// Without this the keyboard stays live during dictation, and any
1623+
// keystroke makes resolveTranscriptCommit see a changed draft and
1624+
// discard the whole transcript as stale.
1625+
readOnly={voiceInput.freezesEditor}
16151626
skills={selectedProviderStatus?.skills ?? []}
16161627
selection={composerMenu.selection}
16171628
onChangeText={props.onChangeDraftMessage}
@@ -1729,13 +1740,14 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
17291740
)}
17301741
</Animated.View>
17311742
) : null}
1743+
{isExpanded ? <View className="h-1" /> : null}
17321744
</ComposerDictationDraftContent>
17331745
{isToolbarVisible ? (
17341746
<ComposerDictationToolbar
17351747
showsDictation={isVoiceInputPresented}
17361748
visible={isToolbarVisible}
17371749
>
1738-
<ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={4}>
1750+
<ComposerToolbarRow paddingBottom={0} paddingHorizontal={0} paddingTop={0}>
17391751
<ComposerDictationCancelAction
17401752
presentation={voicePresentation}
17411753
onCancel={voiceInput.cancel}
@@ -1919,29 +1931,32 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
19191931
showChevron={false}
19201932
/>
19211933
) : null}
1922-
{showStopAction ? (
1923-
<ComposerToolbarButton
1924-
accessibilityLabel="Stop"
1925-
icon="stop.fill"
1926-
variant="danger"
1927-
onPress={props.onStopThread}
1928-
showChevron={false}
1929-
/>
1930-
) : null}
19311934
</ComposerToolbarScroller>
19321935
)}
19331936
<View className="shrink-0 flex-row items-center gap-2">
1934-
{voiceInput.isAvailable ? (
1935-
<ComposerDictationPrimaryAction
1936-
state={voiceInput.state}
1937-
presentation={voicePresentation}
1938-
isAvailable={voiceInput.isAvailable}
1939-
onStart={voiceInput.start}
1940-
onConfirm={voiceInput.stop}
1941-
onCancel={voiceInput.cancel}
1937+
{/* Stop lives outside the dictation ternary: an agent must stay
1938+
stoppable for the whole recording and transcription window. */}
1939+
{showStopAction ? (
1940+
<ComposerToolbarButton
1941+
accessibilityLabel="Stop"
1942+
icon="stop.fill"
1943+
variant="danger"
1944+
onPress={props.onStopThread}
1945+
showChevron={false}
19421946
/>
19431947
) : null}
1944-
{isVoiceInputPresented ? null : (
1948+
<ComposerDictationPrimaryAction
1949+
state={voiceInput.state}
1950+
presentation={voicePresentation}
1951+
isAvailable={voiceInput.isAvailable}
1952+
onStart={voiceInput.start}
1953+
onConfirm={voiceInput.stop}
1954+
onCancel={voiceInput.cancel}
1955+
/>
1956+
{/* showsSend, not isVoiceInputPresented: the error phase shows a
1957+
status label AND keeps send, so gating on the label strands
1958+
the user with no way to send until they dismiss the error. */}
1959+
{voicePresentation.showsSend ? (
19451960
<ComposerToolbarButton
19461961
accessibilityLabel={sendLabel}
19471962
icon="arrow.up"
@@ -1950,7 +1965,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
19501965
onPress={canQueueFollowUp ? handleQueueFollowUp : handleSend}
19511966
showChevron={false}
19521967
/>
1953-
)}
1968+
) : null}
19541969
</View>
19551970
</ComposerToolbarRow>
19561971
</ComposerDictationToolbar>

apps/mobile/src/features/voice-input/voiceInputPresentation.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,36 @@ describe("resolveVoiceComposerPresentation", () => {
6767
expect(voiceInputFreezesEditor({ phase: "idle", error: null, errorAction: null })).toBe(false);
6868
});
6969
});
70+
71+
describe("showsSend is not the same predicate as having a status label", () => {
72+
// The composer must gate its send control on showsSend. Gating on the
73+
// presence of a status label instead looks equivalent — both are non-null
74+
// through recording and transcribing — but diverges exactly in the error
75+
// phase, which shows a label AND keeps send. Getting this wrong strands the
76+
// user with no send control until they find the dismiss affordance.
77+
it("keeps send available in the error phase, which also shows a status label", () => {
78+
const presentation = resolveVoiceComposerPresentation(
79+
{ phase: "error", error: "No speech was detected.", errorAction: null },
80+
0,
81+
);
82+
expect(presentation.showsSend).toBe(true);
83+
expect(presentation.statusLabel).not.toBeNull();
84+
});
85+
86+
it("hides send only while dictation actually owns the row", () => {
87+
for (const phase of ["preparing", "recording", "transcribing"] as const) {
88+
const presentation = resolveVoiceComposerPresentation(
89+
{ phase, error: null, errorAction: null },
90+
0,
91+
);
92+
expect(presentation.showsSend).toBe(false);
93+
expect(presentation.statusLabel).not.toBeNull();
94+
}
95+
const idle = resolveVoiceComposerPresentation(
96+
{ phase: "idle", error: null, errorAction: null },
97+
0,
98+
);
99+
expect(idle.showsSend).toBe(true);
100+
expect(idle.statusLabel).toBeNull();
101+
});
102+
});

docs/internals/voice-input.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Voice input
22

3-
> For maintainers. Using T3 Code? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone).
3+
> For maintainers. Using Pylon? See [voice input on iPhone](../user/composer.md#voice-input-on-iphone).
44
55
Voice input produces editable composer text. The current implementation records on the client and
66
transcribes locally with Apple's `SpeechAnalyzer` and `SpeechTranscriber` on supported iOS 26+

docs/user/composer.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ you send it.
6262
The first use can download Apple's speech model and needs a network connection. Later transcription
6363
works offline for that language. A recording can be up to five minutes long. Canceling voice input,
6464
leaving the screen, or an audio interruption discards the new recording and keeps the existing draft
65-
and attachments. T3 Code deletes the local audio file after transcription or cancellation. It sends
65+
and attachments. Pylon deletes the local audio file after transcription or cancellation. It sends
6666
only the normal message text when you submit the draft.
6767

6868
## Commands and skills

pnpm-workspace.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ patchedDependencies:
157157
"@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch
158158
"@react-navigation/native-stack@7.17.6": patches/@react-navigation%2Fnative-stack@7.17.6.patch
159159
effect@4.0.0-beta.103: patches/effect@4.0.0-beta.103.patch
160-
expo-modules-jsi@57.0.6: patches/expo-modules-jsi@57.0.6.patch
161160
expo-audio@57.0.4: patches/expo-audio@57.0.4.patch
161+
expo-modules-jsi@57.0.6: patches/expo-modules-jsi@57.0.6.patch
162162
expo-sharing@57.0.16: patches/expo-sharing@57.0.16.patch
163163
react-native-gesture-handler@2.32.0: patches/react-native-gesture-handler@2.32.0.patch
164164
react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch

0 commit comments

Comments
 (0)