Skip to content

Commit 465433e

Browse files
fix(mobile): dragging a microphone or environment route now saves its new place
Dropping a row in Settings > Microphone or an environment's route list snapped it back and never saved the order. Cause: #21124 moved the drag handle's translation from a ref into a local variable inside the gesture builder. The worklets Babel plugin turns the gesture callbacks into worklets whose factory copies captured values when the gesture is built, so onFinalize always read the initial 0 and every drop moved the row zero steps. Fix: read the translation from the final pan event. The microphone list also drops its Edit/Done mode: devices are forgotten by swiping left (ThreadSwipeable, the thread list's swipe action) or through a Forget accessibility action on the row. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent d1c4b52 commit 465433e

5 files changed

Lines changed: 132 additions & 67 deletions

File tree

‎apps/mobile/src/features/settings/SettingsMicrophoneRouteScreen.tsx‎

Lines changed: 47 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import { Button } from "../../components/ui/Button";
21
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
32
import { useAtomSet, useAtomValue } from "@effect/atom-react";
43
import { AsyncResult } from "effect/reactivity";
54
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from "expo-audio";
65
import { useState } from "react";
7-
import { Linking, Pressable, View } from "react-native";
6+
import { Linking, useWindowDimensions, View } from "react-native";
87
import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
98
import { useSafeAreaInsets } from "react-native-safe-area-context";
109

11-
import { type AppSymbolName, SymbolView } from "../../components/ui/AppSymbol";
10+
import { type AppSymbolName } from "../../components/ui/AppSymbol";
1211
import { AppText as Text } from "../../components/ui/AppText";
1312
import { cn } from "../../lib/cn";
1413
import { makePermissionStatusAtom } from "../../lib/permissionStatusAtom";
@@ -21,12 +20,11 @@ import {
2120
} from "../../lib/microphonePriority";
2221
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
2322
import { ListRowLayout } from "../../components/ui/ListRowLayout";
23+
import { ThreadSwipeable } from "../home/thread-swipe-actions";
2424
import { SettingsActionRow } from "./components/SettingsActionRow";
2525
import { SettingsDragHandle } from "./components/SettingsDragHandle";
2626
import { SettingsSection } from "./components/SettingsSection";
2727

28-
const REMOVE_SIZE = 20;
29-
3028
type MicrophonePermission = "checking" | "granted" | "ask" | "denied";
3129

3230
function toMicrophonePermission(response: {
@@ -98,9 +96,7 @@ export function SettingsMicrophoneRouteScreen() {
9896
const entries = microphoneOrder(
9997
AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.microphones : undefined,
10098
);
101-
const hasDevices = entries.some(isMicrophoneDevice);
10299
const { permission, request: requestPermission } = useMicrophonePermission();
103-
const [editing, setEditing] = useState(false);
104100
const [drag, setDrag] = useState<{ readonly key: string; readonly translation: number } | null>(
105101
null,
106102
);
@@ -166,18 +162,7 @@ export function SettingsMicrophoneRouteScreen() {
166162
</Text>
167163
</>
168164
) : null}
169-
<SettingsSection
170-
title="Preferred order"
171-
trailing={
172-
hasDevices ? (
173-
<Button
174-
label={editing ? "Done" : "Edit"}
175-
tone="text"
176-
onPress={() => setEditing((value) => !value)}
177-
/>
178-
) : undefined
179-
}
180-
>
165+
<SettingsSection title="Preferred order">
181166
{entries.map((entry, index) => {
182167
const key = microphoneEntryKey(entry);
183168
const shift =
@@ -195,7 +180,6 @@ export function SettingsMicrophoneRouteScreen() {
195180
position={index + 1}
196181
count={entries.length}
197182
reorderable={ready}
198-
editing={editing && hasDevices}
199183
offset={index === dragFrom ? (drag?.translation ?? 0) : shift}
200184
lifted={index === dragFrom}
201185
onHeight={setRowHeight}
@@ -215,7 +199,7 @@ export function SettingsMicrophoneRouteScreen() {
215199
<Text className="px-2 text-sm text-foreground-muted">
216200
Voice input records from the first connected microphone in this list. Wired and Bluetooth
217201
devices are added by name after you use voice input with them connected; until then, the
218-
Other row ranks them.
202+
Other row ranks them. Swipe a device left to forget it.
219203
</Text>
220204
</ScrollView>
221205
</View>
@@ -227,7 +211,6 @@ function MicrophoneRow(props: {
227211
readonly position: number;
228212
readonly count: number;
229213
readonly reorderable: boolean;
230-
readonly editing: boolean;
231214
readonly offset: number;
232215
readonly lifted: boolean;
233216
readonly onHeight: (height: number) => void;
@@ -237,9 +220,10 @@ function MicrophoneRow(props: {
237220
readonly onStep: (direction: "up" | "down") => void;
238221
readonly onForget: (() => void) | undefined;
239222
}) {
240-
const { lifted, offset, entry } = props;
223+
const { lifted, offset, entry, onForget } = props;
241224
const kind = KINDS[entry.kind];
242225
const title = isMicrophoneDevice(entry) ? entry.name : kind.row;
226+
const { width: windowWidth } = useWindowDimensions();
243227
const style = useAnimatedStyle(() => ({
244228
transform: [
245229
{
@@ -250,39 +234,16 @@ function MicrophoneRow(props: {
250234
],
251235
zIndex: lifted ? 1 : 0,
252236
}));
253-
return (
254-
<Reanimated.View
255-
style={style}
256-
onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
257-
className={cn(
258-
"flex-row items-center",
259-
props.position > 1 && !lifted && "border-t border-border-subtle",
260-
lifted && "bg-grouped-card shadow-md",
261-
)}
262-
>
263-
{props.editing && props.onForget ? (
264-
<Pressable
265-
accessibilityRole="button"
266-
accessibilityLabel={`Forget ${title}`}
267-
hitSlop={8}
268-
onPress={props.onForget}
269-
className="pl-4 active:opacity-70"
270-
>
271-
<SymbolView
272-
name="xmark.circle.fill"
273-
size={REMOVE_SIZE}
274-
tintColorClassName="accent-danger-foreground"
275-
type="monochrome"
276-
/>
277-
</Pressable>
278-
) : props.editing ? (
279-
<View className="pl-4">
280-
<View style={{ width: REMOVE_SIZE }} />
281-
</View>
282-
) : null}
237+
const row = (
238+
// Opaque so the swipe actions stay hidden until the row slides over them.
239+
<View className="flex-row items-center bg-grouped-card">
283240
<View
284241
accessible
285242
accessibilityLabel={`${title}, ${kind.label}, ${props.position} of ${props.count}`}
243+
accessibilityActions={onForget ? [{ name: "forget", label: "Forget" }] : undefined}
244+
onAccessibilityAction={({ nativeEvent }) => {
245+
if (nativeEvent.actionName === "forget") onForget?.();
246+
}}
286247
className="min-w-0 flex-1"
287248
>
288249
<ListRowLayout
@@ -302,6 +263,39 @@ function MicrophoneRow(props: {
302263
onStep={props.onStep}
303264
/>
304265
) : null}
266+
</View>
267+
);
268+
return (
269+
<Reanimated.View
270+
style={style}
271+
onLayout={(event) => props.onHeight(event.nativeEvent.layout.height)}
272+
className={cn(
273+
props.position > 1 && !lifted && "border-t border-border-subtle",
274+
lifted && "bg-grouped-card shadow-md",
275+
)}
276+
>
277+
{onForget ? (
278+
<ThreadSwipeable
279+
backgroundColor="transparent"
280+
fullSwipeAction="primary"
281+
fullSwipeWidth={windowWidth - 40}
282+
onDelete={onForget}
283+
primaryAction={{
284+
accessibilityLabel: `Forget ${title}`,
285+
destructive: true,
286+
icon: "trash",
287+
label: "Forget",
288+
onPress: onForget,
289+
}}
290+
secondaryAction={null}
291+
threadKey={`microphone:${microphoneEntryKey(entry)}`}
292+
threadTitle={title}
293+
>
294+
{() => row}
295+
</ThreadSwipeable>
296+
) : (
297+
row
298+
)}
305299
</Reanimated.View>
306300
);
307301
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { ReactNode } from "react";
2+
import { act } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { describe, expect, it, vi } from "vite-plus/test";
5+
6+
type PanEvent = { readonly translationY: number };
7+
type PanHandlers = {
8+
onStart?: () => void;
9+
onUpdate?: (event: PanEvent) => void;
10+
onFinalize?: (event: PanEvent, success: boolean) => void;
11+
};
12+
13+
const pans = vi.hoisted(() => [] as PanHandlers[]);
14+
15+
vi.mock("react-native", () => ({
16+
View: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
17+
}));
18+
vi.mock("react-native-gesture-handler", () => ({
19+
GestureDetector: ({ children }: { children?: ReactNode }) => children,
20+
Gesture: {
21+
Pan: () => {
22+
const handlers: PanHandlers = {};
23+
pans.push(handlers);
24+
const pan = {
25+
minDistance: () => pan,
26+
shouldCancelWhenOutside: () => pan,
27+
runOnJS: () => pan,
28+
onStart: (callback: PanHandlers["onStart"]) => ((handlers.onStart = callback), pan),
29+
onUpdate: (callback: PanHandlers["onUpdate"]) => ((handlers.onUpdate = callback), pan),
30+
onFinalize: (callback: PanHandlers["onFinalize"]) => (
31+
(handlers.onFinalize = callback),
32+
pan
33+
),
34+
};
35+
return pan;
36+
},
37+
},
38+
}));
39+
vi.mock("../../../components/ui/AppSymbol", () => ({ SymbolView: () => null }));
40+
41+
import { SettingsDragHandle } from "./SettingsDragHandle";
42+
43+
describe("SettingsDragHandle", () => {
44+
// The worklets Babel plugin copies values a gesture callback closes over when the gesture is
45+
// built, so a drop must not read a translation tracked in a local variable.
46+
it("reports the released translation when the pan ends", () => {
47+
pans.length = 0;
48+
const onEnd = vi.fn();
49+
const root = createRoot(document.createElement("div"));
50+
act(() =>
51+
root.render(
52+
<SettingsDragHandle
53+
title="Built-in microphone"
54+
canMoveUp
55+
canMoveDown
56+
onStart={() => {}}
57+
onMove={() => {}}
58+
onEnd={onEnd}
59+
onStep={() => {}}
60+
/>,
61+
),
62+
);
63+
const pan = pans.at(-1)!;
64+
65+
act(() => {
66+
pan.onStart?.();
67+
pan.onUpdate?.({ translationY: 64 });
68+
pan.onUpdate?.({ translationY: 130 });
69+
pan.onFinalize?.({ translationY: 130 }, true);
70+
});
71+
72+
expect(onEnd).toHaveBeenCalledWith(130, false);
73+
act(() => root.unmount());
74+
});
75+
});

‎apps/mobile/src/features/settings/components/SettingsDragHandle.tsx‎

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,17 @@ export function SettingsDragHandle(props: {
1818
const onEnd = useEffectEvent((translation: number, cancelled: boolean) =>
1919
props.onEnd(translation, cancelled),
2020
);
21-
const [gesture] = useState(() => {
22-
let translation = 0;
23-
return Gesture.Pan()
21+
// The worklets plugin copies the values these callbacks close over when the gesture is built,
22+
// so the drop reads its translation from the final event rather than a tracked variable.
23+
const [gesture] = useState(() =>
24+
Gesture.Pan()
2425
.minDistance(0)
2526
.shouldCancelWhenOutside(false)
2627
.runOnJS(true)
27-
.onStart(() => {
28-
translation = 0;
29-
onStart();
30-
})
31-
.onUpdate((event) => {
32-
translation = event.translationY;
33-
onMove(event.translationY);
34-
})
35-
.onFinalize((_, success) => onEnd(translation, !success));
36-
});
28+
.onStart(() => onStart())
29+
.onUpdate((event) => onMove(event.translationY))
30+
.onFinalize((event, success) => onEnd(event.translationY, !success)),
31+
);
3732
return (
3833
<GestureDetector gesture={gesture}>
3934
<View

‎apps/mobile/vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const reactTests = [
1717
"src/features/pull-requests/guide/useReviewGuide.test.tsx",
1818
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
1919
"src/features/settings/components/BranchNamingSettings.test.tsx",
20+
"src/features/settings/components/SettingsDragHandle.test.tsx",
2021
"src/features/threads/ThreadMarkdownImage.test.tsx",
2122
"src/features/threads/UsageLimitRecoveryCard.test.tsx",
2223
"src/features/threads/NewTaskDraftRouteScreen.test.tsx",

‎docs/user/composer.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ The list mixes kinds (built-in, other wired, other Bluetooth, CarPlay) with wire
190190
Bluetooth devices you have dictated with, listed by name. Drag to rank them together:
191191
for example, put **Other Bluetooth** above your AirPods so a new headset is preferred
192192
while the AirPods microphone stays low. By default, CarPlay comes after the built-in
193-
microphone.
193+
microphone. Swipe a device left to forget it.
194194

195195
Transcription runs on your device. T3 Code deletes the temporary audio after
196196
transcription or cancellation; only the message text is sent when you submit.

0 commit comments

Comments
 (0)