From 89ce3fc5207878c297d30e7d8fbde9a38f897636 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 17 Sep 2026 02:41:09 -0700 Subject: [PATCH] fix(mobile): preview queued message order while dragging --- .../features/threads/ThreadQueueControl.tsx | 135 +++++++++++++++--- .../threadQueueControlPresentation.test.ts | 20 +++ .../threads/threadQueueControlPresentation.ts | 17 ++- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadQueueControl.tsx b/apps/mobile/src/features/threads/ThreadQueueControl.tsx index 92d2ef98e083..532d22b70e45 100644 --- a/apps/mobile/src/features/threads/ThreadQueueControl.tsx +++ b/apps/mobile/src/features/threads/ThreadQueueControl.tsx @@ -11,6 +11,7 @@ import ReanimatedSwipeable, { } from "react-native-gesture-handler/ReanimatedSwipeable"; import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated"; import { AndroidSheetHeader } from "../../components/AndroidScreenHeader"; import { AppText as Text } from "../../components/AppText"; @@ -25,9 +26,11 @@ import { environmentThreadDetails, threadEnvironment } from "../../state/threads import { useAtomCommand } from "../../state/use-atom-command"; import { buildCancelQueuedRunCommand, + resolveQueueDragBeforeRunId, resolveQueueDropBeforeRunId, resolveThreadQueueRowControls, } from "./threadQueueControlPresentation"; +import { threadDragGapOffset } from "./threadDragGap"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); const REMOVE_ACTION_WIDTH = 76; @@ -35,6 +38,7 @@ const THUMBNAIL_LIMIT = 3; type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId }; type QueueAction = "steer" | "edit" | "up" | "down" | "remove"; +type QueueRowLayout = { readonly id: RunId; readonly y?: number; readonly height?: number }; export function useThreadQueueWorkflow(target: QueueTarget) { return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target)); @@ -58,8 +62,15 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const [busyRunId, setBusyRunId] = useState(null); const busyRef = useRef(false); const [draggedRunId, setDraggedRunId] = useState(null); + const [previewBeforeRunId, setPreviewBeforeRunId] = useState(); + const [dragRows, setDragRows] = useState | null>(null); const rowLayouts = useRef(new Map()); - const drag = useRef<{ runId: RunId; order: string } | null>(null); + const drag = useRef<{ + runId: RunId; + order: string; + beforeRunId: RunId | null | undefined; + rows: ReadonlyArray; + } | null>(null); const [translation] = useState(() => new Animated.Value(0)); const queuedRuns = workflow?.queuedRuns ?? []; const order = queuedRuns.map(({ run }) => run.id).join(","); @@ -68,6 +79,8 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { if (drag.current && drag.current.order !== order) { drag.current = null; setDraggedRunId(null); + setPreviewBeforeRunId(undefined); + setDragRows(null); translation.setValue(0); } }, [order, translation]); @@ -145,6 +158,18 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { }; const canReorder = workflow?.canReorder === true && queuedRuns.length > 1; + const sourceLayout = dragRows?.find((row) => row.id === draggedRunId); + const lastLayout = dragRows?.at(-1); + const insertionOffset = + previewBeforeRunId === undefined + ? undefined + : previewBeforeRunId === null + ? lastLayout?.y === undefined || lastLayout.height === undefined + ? undefined + : lastLayout.y + lastLayout.height + : dragRows?.find((row) => row.id === previewBeforeRunId)?.y; + const queueRows = () => + queuedRuns.map(({ run }) => ({ id: run.id, ...rowLayouts.current.get(run.id) })); const content = ( ) { ) : null} {queuedRuns.map(({ run, text, attachments }, index) => { + const layout = dragRows?.find((row) => row.id === run.id); + const offset = + sourceLayout?.y !== undefined && + sourceLayout.height !== undefined && + layout?.y !== undefined && + insertionOffset !== undefined + ? threadDragGapOffset(layout.y, sourceLayout.y, sourceLayout.height, insertionOffset) + : 0; const controls = resolveThreadQueueRowControls({ busy: busyRunId !== null || draggedRunId !== null, canPromoteToSteer: workflow?.canPromoteToSteer ?? false, @@ -173,9 +206,11 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { const title = controls.displayText || (attachments.length > 0 ? "Attachments" : "Queued message"); return ( - rowLayouts.current.set(run.id, nativeEvent.layout)} > ) { canMoveDown={controls.canMoveDown} onStep={(action) => void act(run.id, action)} onStart={() => { - drag.current = { runId: run.id, order }; + const rows = queueRows(); + const beforeRunId = resolveQueueDragBeforeRunId(rows, run.id, 0); + drag.current = { runId: run.id, order, beforeRunId, rows }; translation.setValue(0); + setDragRows(rows); + setPreviewBeforeRunId(beforeRunId); setDraggedRunId(run.id); void Haptics.selectionAsync(); }} - onMove={(y) => translation.setValue(y)} + onMove={(y) => { + const current = drag.current; + if (current?.runId !== run.id || current.order !== order) return; + translation.setValue(y); + const before = resolveQueueDragBeforeRunId(current.rows, run.id, y); + if (current.beforeRunId !== before) { + current.beforeRunId = before; + setPreviewBeforeRunId(before); + } + }} onEnd={(y, success) => { const started = drag.current; - drag.current = null; - setDraggedRunId(null); - translation.setValue(0); + const stop = () => { + if (drag.current !== started) return; + drag.current = null; + setDraggedRunId(null); + setPreviewBeforeRunId(undefined); + setDragRows(null); + translation.setValue(0); + }; // A remote reorder or a newly started run invalidates this drag. - if (!success || started?.order !== order || started.runId !== run.id) return; - const before = resolveQueueDropBeforeRunId( - queuedRuns.map(({ run: item }) => ({ - id: item.id, - ...rowLayouts.current.get(item.id), - })), - run.id, - y, - ); - if (before !== undefined) void move(run.id, before); + if (!success || started?.order !== order || started.runId !== run.id) { + stop(); + return; + } + const before = resolveQueueDropBeforeRunId(started.rows, run.id, y); + if (before === undefined) { + stop(); + return; + } + const source = started.rows.find((row) => row.id === run.id); + const tail = started.rows.at(-1); + const insertion = + before === null + ? tail?.y !== undefined && tail.height !== undefined + ? tail.y + tail.height + : undefined + : started.rows.find((row) => row.id === before)?.y; + if ( + source?.y !== undefined && + source.height !== undefined && + insertion !== undefined + ) { + Animated.timing(translation, { + toValue: insertion - source.y - (insertion > source.y ? source.height : 0), + duration: 160, + useNativeDriver: true, + }).start(); + } + setPreviewBeforeRunId(before); + void move(run.id, before).finally(stop); }} /> ) : null} @@ -306,7 +379,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { - + ); })} @@ -356,6 +429,30 @@ export function ThreadQueueSheet({ route }: StaticScreenProps) { ); } +function QueueShiftedRow(props: { + readonly offset: number; + readonly dragging: boolean; + readonly lifted: boolean; + readonly onLayout: React.ComponentProps["onLayout"]; + readonly children: React.ReactNode; +}) { + const { dragging, offset } = props; + const style = useAnimatedStyle(() => ({ + transform: [ + { + translateY: dragging + ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System }) + : offset, + }, + ], + })); + return ( + + {props.children} + + ); +} + /** Swipe left to remove, the one destructive action that needs no menu. */ function QueueRowSwipeable(props: { readonly enabled: boolean; diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts index 3926f2847625..8a770537a9b0 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts @@ -3,9 +3,11 @@ import { describe, expect, it } from "vite-plus/test"; import { REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL, buildCancelQueuedRunCommand, + resolveQueueDragBeforeRunId, resolveThreadQueueRowControls, resolveQueueDropBeforeRunId, } from "./threadQueueControlPresentation"; +import { threadDragGapOffset } from "./threadDragGap"; describe("threadQueueControlPresentation", () => { it("preserves queue reorder and steer controls with removal", () => { @@ -101,6 +103,24 @@ describe("queue drag insertion", () => { expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, -300)).toBe("first"); }); + it("opens the destination gap while the dragged row crosses other rows", () => { + const offsets = (runId: (typeof rows)[number]["id"], translation: number) => { + const before = resolveQueueDragBeforeRunId(rows, runId, translation); + if (before === undefined) return; + const source = rows.find((row) => row.id === runId)!; + const last = rows.at(-1)!; + const insertion = + before === null ? last.y + last.height : rows.find((row) => row.id === before)!.y; + return rows.map((row) => threadDragGapOffset(row.y, source.y, source.height, insertion)); + }; + + expect(resolveQueueDragBeforeRunId(rows, rows[0]!.id, 0)).toBe(rows[1]!.id); + expect(offsets(rows[0]!.id, 140)).toEqual([0, -80, 0]); + expect(offsets(rows[0]!.id, 300)).toEqual([0, -80, -80]); + expect(offsets(rows[2]!.id, -300)).toEqual([80, 80, 0]); + expect(offsets(rows[1]!.id, 0)).toEqual([0, 0, 0]); + }); + it("does not send a reorder for an unchanged or unmeasured drop", () => { expect(resolveQueueDropBeforeRunId(rows, rows[1]!.id, 0)).toBeUndefined(); expect(resolveQueueDropBeforeRunId(rows, rows[2]!.id, 20)).toBeUndefined(); diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts index 107892e2869f..104eee38300d 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts @@ -60,8 +60,8 @@ export function buildCancelQueuedRunCommand(input: { }; } -/** Return the insertion anchor after a drag, or undefined when the order is unchanged. */ -export function resolveQueueDropBeforeRunId( +/** Find the insertion anchor using the rows' original, untransformed layout. */ +export function resolveQueueDragBeforeRunId( rows: ReadonlyArray<{ id: RunId; y?: number; height?: number }>, runId: RunId, translationY: number, @@ -71,6 +71,17 @@ export function resolveQueueDropBeforeRunId( if (!source || rows.some((row) => row.y === undefined || row.height === undefined)) return; const center = source.y! + source.height! / 2 + translationY; const remaining = rows.filter((row) => row.id !== runId); - const before = remaining.find((row) => center < row.y! + row.height! / 2)?.id ?? null; + return remaining.find((row) => center < row.y! + row.height! / 2)?.id ?? null; +} + +/** Return the insertion anchor after a drag, or undefined when the order is unchanged. */ +export function resolveQueueDropBeforeRunId( + rows: ReadonlyArray<{ id: RunId; y?: number; height?: number }>, + runId: RunId, + translationY: number, +): RunId | null | undefined { + const before = resolveQueueDragBeforeRunId(rows, runId, translationY); + if (before === undefined) return; + const sourceIndex = rows.findIndex((row) => row.id === runId); return before === (rows[sourceIndex + 1]?.id ?? null) ? undefined : before; }