Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 116 additions & 19 deletions apps/mobile/src/features/threads/ThreadQueueControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,16 +26,19 @@ 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;
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));
Expand All @@ -58,8 +62,15 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
const busyRef = useRef(false);
const [draggedRunId, setDraggedRunId] = useState<RunId | null>(null);
const [previewBeforeRunId, setPreviewBeforeRunId] = useState<RunId | null | undefined>();
const [dragRows, setDragRows] = useState<ReadonlyArray<QueueRowLayout> | null>(null);
const rowLayouts = useRef(new Map<RunId, { y: number; height: number }>());
const drag = useRef<{ runId: RunId; order: string } | null>(null);
const drag = useRef<{
runId: RunId;
order: string;
beforeRunId: RunId | null | undefined;
rows: ReadonlyArray<QueueRowLayout>;
} | null>(null);
const [translation] = useState(() => new Animated.Value(0));
const queuedRuns = workflow?.queuedRuns ?? [];
const order = queuedRuns.map(({ run }) => run.id).join(",");
Expand All @@ -68,6 +79,8 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
if (drag.current && drag.current.order !== order) {
drag.current = null;
setDraggedRunId(null);
setPreviewBeforeRunId(undefined);
setDragRows(null);
translation.setValue(0);
}
}, [order, translation]);
Expand Down Expand Up @@ -145,6 +158,18 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
};

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 = (
<ScrollView
className="flex-1"
Expand All @@ -161,6 +186,14 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
</Text>
) : 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,
Expand All @@ -173,9 +206,11 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
const title =
controls.displayText || (attachments.length > 0 ? "Attachments" : "Queued message");
return (
<View
<QueueShiftedRow
key={run.id}
style={{ zIndex: draggedRunId === run.id ? 1 : 0 }}
offset={offset}
dragging={draggedRunId !== null}
lifted={draggedRunId === run.id}
onLayout={({ nativeEvent }) => rowLayouts.current.set(run.id, nativeEvent.layout)}
>
<Animated.View
Expand All @@ -196,28 +231,66 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
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}
Expand Down Expand Up @@ -306,7 +379,7 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
</ControlPillMenu>
</QueueRowSwipeable>
</Animated.View>
</View>
</QueueShiftedRow>
);
})}
</ScrollView>
Expand Down Expand Up @@ -356,6 +429,30 @@ export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
);
}

function QueueShiftedRow(props: {
readonly offset: number;
readonly dragging: boolean;
readonly lifted: boolean;
readonly onLayout: React.ComponentProps<typeof View>["onLayout"];
readonly children: React.ReactNode;
}) {
const { dragging, offset } = props;
const style = useAnimatedStyle(() => ({
transform: [
{
translateY: dragging
? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
: offset,
},
],
}));
return (
<Reanimated.View onLayout={props.onLayout} style={[style, { zIndex: props.lifted ? 1 : 0 }]}>
{props.children}
</Reanimated.View>
);
}

/** Swipe left to remove, the one destructive action that needs no menu. */
function QueueRowSwipeable(props: {
readonly enabled: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/threadQueueControlPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Loading