1import { type StaticScreenProps, useNavigation } from "@react-navigation/native";
2import { useAtomValue } from "@effect/atom-react";
3import type { ChatAttachment, EnvironmentId, RunId, ThreadId } from "@t3tools/contracts";
4import { Image } from "expo-image";
5import * as Haptics from "expo-haptics";
6import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
7import { Animated, Platform, Pressable, ScrollView, View } from "react-native";
8import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
9import ReanimatedSwipeable, {
10 type SwipeableMethods,
11} from "react-native-gesture-handler/ReanimatedSwipeable";
12import { Screen, ScreenStack, ScreenStackHeaderConfig } from "react-native-screens";
13import { useSafeAreaInsets } from "react-native-safe-area-context";
14import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
15
16import { MaterialButton } from "../../components/MaterialButton";
17import { AndroidSheetHeader } from "../../components/AndroidScreenHeader";
18import { AppText as Text } from "../../components/AppText";
19import { SymbolView } from "../../components/AppSymbol";
20import { ControlPillMenu } from "../../components/ControlPill";
21import { MaterialScreenContent } from "../../components/MaterialScreenContent";
22import { scopedThreadKey } from "../../lib/scopedEntities";
23import { useUniwindTheme } from "../../lib/useUniwindTheme";
24import { NativeStackScreenOptions, nativeHeaderScrollEdgeEffects } from "../../native/StackHeader";
25import { useAssetUrl } from "../../state/assets";
26import { assetImageSource } from "../../lib/assetImageSource";
27import { beginQueuedRunEdit, useQueuedRunEdit } from "../../state/queued-run-edit";
28import { environmentThreadDetails, threadEnvironment } from "../../state/threads";
29import { useAtomCommand } from "../../state/use-atom-command";
30import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
31import {
32 REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL,
33 buildCancelQueuedRunCommand,
34 resolveQueueDragBeforeRunId,
35 resolveQueueDropBeforeRunId,
36 resolveThreadQueueRowControls,
37} from "./threadQueueControlPresentation";
38import { threadDragGapOffset } from "./threadDragGap";
39
40const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version);
41const REMOVE_ACTION_WIDTH = 76;
42const THUMBNAIL_LIMIT = 3;
43
44type QueueTarget = { readonly environmentId: EnvironmentId; readonly threadId: ThreadId };
45type QueueAction = "steer" | "edit" | "up" | "down" | "remove";
46type QueueRowLayout = { readonly id: RunId; readonly y?: number; readonly height?: number };
47
48export function useThreadQueueWorkflow(target: QueueTarget) {
49 return useAtomValue(environmentThreadDetails.queueWorkflowAtom(target));
50}
51
52export function useThreadQueuedCount(target: QueueTarget) {
53 return useAtomValue(environmentThreadDetails.queuedCountAtom(target));
54}
55
56export function ThreadQueueSheet({ route }: StaticScreenProps<QueueTarget>) {
57 const { layout } = useAdaptiveWorkspaceLayout();
58 const target = route.params;
59 const navigation = useNavigation();
60 const insets = useSafeAreaInsets();
61 const theme = useUniwindTheme();
62 const workflow = useThreadQueueWorkflow(target);
63 const threadKey = scopedThreadKey(target.environmentId, target.threadId);
64 const editing = useQueuedRunEdit(threadKey);
65 const reorder = useAtomCommand(threadEnvironment.reorderQueuedRun, "reorder queued message");
66 const promote = useAtomCommand(threadEnvironment.promoteQueuedRun, "promote queued message");
67 const cancel = useAtomCommand(threadEnvironment.cancelQueuedRun, "remove queued message");
68 const resume = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue");
69 const [resuming, setResuming] = useState(false);
70 const [busyRunId, setBusyRunId] = useState<RunId | null>(null);
71 const busyRef = useRef(false);
72 const [draggedRunId, setDraggedRunId] = useState<RunId | null>(null);
73 const [previewBeforeRunId, setPreviewBeforeRunId] = useState<RunId | null | undefined>();
74 const [dragRows, setDragRows] = useState<ReadonlyArray<QueueRowLayout> | null>(null);
75 const rowLayouts = useRef(new Map<RunId, { y: number; height: number }>());
76 const drag = useRef<{
77 runId: RunId;
78 order: string;
79 beforeRunId: RunId | null | undefined;
80 rows: ReadonlyArray<QueueRowLayout>;
81 } | null>(null);
82 const [translation] = useState(() => new Animated.Value(0));
83 const queuedRuns = workflow?.queuedRuns ?? [];
84 const order = queuedRuns.map(({ run }) => run.id).join(",");
85
86 useEffect(() => {
87 if (drag.current && drag.current.order !== order) {
88 drag.current = null;
89 setDraggedRunId(null);
90 setPreviewBeforeRunId(undefined);
91 setDragRows(null);
92 translation.setValue(0);
93 }
94 }, [order, translation]);
95
96 // Nothing left to manage: the sheet closes rather than sitting on an empty
97 // list the user has to dismiss themselves.
98 const hadQueuedRuns = useRef(queuedRuns.length > 0);
99 useEffect(() => {
100 if (queuedRuns.length > 0) {
101 hadQueuedRuns.current = true;
102 return;
103 }
104 if (hadQueuedRuns.current) navigation.goBack();
105 }, [navigation, queuedRuns.length]);
106
107 const move = async (runId: RunId, beforeRunId: RunId | null) => {
108 if (busyRef.current || !workflow?.canReorder) return;
109 busyRef.current = true;
110 setBusyRunId(runId);
111 void Haptics.selectionAsync();
112 try {
113 await reorder({ ...target, input: { threadId: target.threadId, runId, beforeRunId } });
114 } finally {
115 busyRef.current = false;
116 setBusyRunId(null);
117 }
118 };
119
120 const act = async (runId: RunId, action: QueueAction) => {
121 if (busyRef.current) return;
122 const index = queuedRuns.findIndex(({ run }) => run.id === runId);
123 if (index < 0) return;
124 if (action === "up" && index > 0) {
125 await move(runId, queuedRuns[index - 1]!.run.id);
126 return;
127 }
128 if (action === "down" && index < queuedRuns.length - 1) {
129 await move(runId, queuedRuns[index + 2]?.run.id ?? null);
130 return;
131 }
132 if (action === "edit") {
133 const entry = queuedRuns[index]!;
134 void Haptics.selectionAsync();
135 beginQueuedRunEdit(threadKey, {
136 runId,
137 messageId: entry.messageId,
138 originalText: entry.text,
139 existingAttachments: entry.attachments,
140 ...(entry.context ? { context: entry.context } : {}),
141 });
142 navigation.goBack();
143 return;
144 }
145 if (action !== "steer" && action !== "remove") return;
146 busyRef.current = true;
147 setBusyRunId(runId);
148 void Haptics.selectionAsync();
149 try {
150 if (action === "remove") {
151 await cancel(buildCancelQueuedRunCommand({ ...target, runId }));
152 } else if (workflow?.activeRun && workflow.canPromoteToSteer) {
153 await promote({
154 ...target,
155 input: {
156 threadId: target.threadId,
157 queuedRunId: runId,
158 targetRunId: workflow.activeRun.id,
159 },
160 });
161 }
162 } finally {
163 busyRef.current = false;
164 setBusyRunId(null);
165 }
166 };
167
168 const canReorder = workflow?.canReorder === true && queuedRuns.length > 1;
169 const sourceLayout = dragRows?.find((row) => row.id === draggedRunId);
170 const lastLayout = dragRows?.at(-1);
171 const insertionOffset =
172 previewBeforeRunId === undefined
173 ? undefined
174 : previewBeforeRunId === null
175 ? lastLayout?.y === undefined || lastLayout.height === undefined
176 ? undefined
177 : lastLayout.y + lastLayout.height
178 : dragRows?.find((row) => row.id === previewBeforeRunId)?.y;
179 const queueRows = () =>
180 queuedRuns.map(({ run }) => ({ id: run.id, ...rowLayouts.current.get(run.id) }));
181 const content = (
182 <ScrollView
183 nestedScrollEnabled
184 className="flex-1"
185 scrollEnabled={draggedRunId === null}
186 // The iOS header is translucent and floats over this view; UIKit has to
187 // inset the content or the first row sits underneath the title.
188 contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "automatic" : "never"}
189 contentContainerClassName="px-5 pb-6"
190 contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) + 8 }}
191 >
192 {workflow?.isHeld && queuedRuns.length > 0 ? (
193 <View className="gap-2 py-3">
194 <Text className="text-sm text-foreground-muted">Queue held after restart</Text>
195 <MaterialButton
196 label="Resume queue"
197 disabled={resuming || busyRunId !== null}
198 onPress={async () => {
199 if (busyRef.current) return;
200 busyRef.current = true;
201 setResuming(true);
202 try {
203 await resume({ ...target, input: { threadId: target.threadId } });
204 } finally {
205 busyRef.current = false;
206 setResuming(false);
207 }
208 }}
209 />
210 </View>
211 ) : null}
212 {queuedRuns.length === 0 ? (
213 <Text className="pt-6 text-center text-sm text-foreground-muted">
214 No messages waiting in this queue.
215 </Text>
216 ) : null}
217 {queuedRuns.map(({ run, text, attachments }, index) => {
218 const layout = dragRows?.find((row) => row.id === run.id);
219 const offset =
220 sourceLayout?.y !== undefined &&
221 sourceLayout.height !== undefined &&
222 layout?.y !== undefined &&
223 insertionOffset !== undefined
224 ? threadDragGapOffset(layout.y, sourceLayout.y, sourceLayout.height, insertionOffset)
225 : 0;
226 const controls = resolveThreadQueueRowControls({
227 busy: busyRunId !== null || draggedRunId !== null,
228 canPromoteToSteer: workflow?.canPromoteToSteer ?? false,
229 canReorder: workflow?.canReorder ?? false,
230 index,
231 isEditing: editing?.runId === run.id,
232 queuedCount: queuedRuns.length,
233 text,
234 });
235 const title =
236 controls.displayText || (attachments.length > 0 ? "Attachments" : "Queued message");
237 return (
238 <QueueShiftedRow
239 key={run.id}
240 offset={offset}
241 dragging={draggedRunId !== null}
242 lifted={draggedRunId === run.id}
243 onLayout={({ nativeEvent }) => rowLayouts.current.set(run.id, nativeEvent.layout)}
244 >
245 <Animated.View
246 className="flex-row items-center border-b border-border bg-sheet"
247 style={
248 draggedRunId === run.id
249 ? { transform: [{ translateY: translation }], zIndex: 1, opacity: 0.85 }
250 : undefined
251 }
252 >
253 {canReorder ? (
254 // Outside the swipeable: two pans on one row would race, and
255 // the handle owns vertical movement while the row owns sideways.
256 <QueueDragHandle
257 disabled={busyRunId !== null}
258 title={title}
259 canMoveUp={controls.canMoveUp}
260 canMoveDown={controls.canMoveDown}
261 onStep={(action) => void act(run.id, action)}
262 onStart={() => {
263 const rows = queueRows();
264 const beforeRunId = resolveQueueDragBeforeRunId(rows, run.id, 0);
265 drag.current = { runId: run.id, order, beforeRunId, rows };
266 translation.setValue(0);
267 setDragRows(rows);
268 setPreviewBeforeRunId(beforeRunId);
269 setDraggedRunId(run.id);
270 void Haptics.selectionAsync();
271 }}
272 onMove={(y) => {
273 const current = drag.current;
274 if (current?.runId !== run.id || current.order !== order) return;
275 translation.setValue(y);
276 const before = resolveQueueDragBeforeRunId(current.rows, run.id, y);
277 if (current.beforeRunId !== before) {
278 current.beforeRunId = before;
279 setPreviewBeforeRunId(before);
280 }
281 }}
282 onEnd={(y, success) => {
283 const started = drag.current;
284 const stop = () => {
285 if (drag.current !== started) return;
286 drag.current = null;
287 setDraggedRunId(null);
288 setPreviewBeforeRunId(undefined);
289 setDragRows(null);
290 translation.setValue(0);
291 };
292 // A remote reorder or a newly started run invalidates this drag.
293 if (!success || started?.order !== order || started.runId !== run.id) {
294 stop();
295 return;
296 }
297 const before = resolveQueueDropBeforeRunId(started.rows, run.id, y);
298 if (before === undefined) {
299 stop();
300 return;
301 }
302 const source = started.rows.find((row) => row.id === run.id);
303 const tail = started.rows.at(-1);
304 const insertion =
305 before === null
306 ? tail?.y !== undefined && tail.height !== undefined
307 ? tail.y + tail.height
308 : undefined
309 : started.rows.find((row) => row.id === before)?.y;
310 if (
311 source?.y !== undefined &&
312 source.height !== undefined &&
313 insertion !== undefined
314 ) {
315 Animated.timing(translation, {
316 toValue: insertion - source.y - (insertion > source.y ? source.height : 0),
317 duration: 160,
318 useNativeDriver: true,
319 }).start();
320 }
321 setPreviewBeforeRunId(before);
322 void move(run.id, before).finally(stop);
323 }}
324 />
325 ) : null}
326 <QueueRowSwipeable
327 enabled={draggedRunId === null && busyRunId === null && controls.canDismiss}
328 background={theme["--color-sheet"]}
329 onRemove={() => void act(run.id, "remove")}
330 >
331 <ControlPillMenu
332 accessibilityLabel={`Actions for queued message ${index + 1}`}
333 shouldOpenOnLongPress
334 actions={[
335 ...(workflow?.canPromoteToSteer
336 ? [
337 {
338 id: "steer",
339 title: "Steer now",
340 attributes: { disabled: !controls.canSteer },
341 image: Platform.OS === "ios" ? "arrow.turn.left.up" : "arrow_upward",
342 },
343 ]
344 : []),
345 {
346 id: "edit",
347 title: "Edit",
348 attributes: { disabled: !controls.canEdit },
349 image: Platform.OS === "ios" ? "pencil" : "edit",
350 },
351 { id: "up", title: "Move up", attributes: { disabled: !controls.canMoveUp } },
352 {
353 id: "down",
354 title: "Move down",
355 attributes: { disabled: !controls.canMoveDown },
356 },
357 {
358 id: "remove",
359 title: "Remove",
360 attributes: { disabled: !controls.canDismiss, destructive: true },
361 },
362 ]}
363 onPressAction={({ nativeEvent }) =>
364 void act(run.id, nativeEvent.event as QueueAction)
365 }
366 >
367 <Pressable
368 accessibilityRole="button"
369 accessibilityLabel={title}
370 accessibilityHint="Opens this message in the composer for editing"
371 disabled={!controls.canEdit}
372 onPress={() => void act(run.id, "edit")}
373 className="min-h-14 flex-row items-center gap-2.5 py-2.5 active:opacity-70"
374 >
375 <QueueAttachmentThumbnails
376 environmentId={target.environmentId}
377 attachments={attachments}
378 />
379 <Text
380 className={
381 controls.isEditing
382 ? "min-w-0 flex-1 text-sm text-foreground-muted"
383 : "min-w-0 flex-1 text-sm text-foreground"
384 }
385 numberOfLines={1}
386 >
387 {title}
388 </Text>
389 {controls.isEditing ? (
390 <Text className="shrink-0 text-2xs uppercase tracking-wide text-primary">
391 Editing
392 </Text>
393 ) : null}
394 {workflow?.canPromoteToSteer ? (
395 <Pressable
396 accessibilityRole="button"
397 accessibilityLabel={`Steer with message ${index + 1} now`}
398 disabled={!controls.canSteer}
399 onPress={() => void act(run.id, "steer")}
400 className="h-8 shrink-0 justify-center rounded-full bg-primary px-3 active:opacity-70 disabled:opacity-40"
401 >
402 <Text className="font-t3-medium text-xs text-primary-foreground">
403 Steer
404 </Text>
405 </Pressable>
406 ) : null}
407 </Pressable>
408 </ControlPillMenu>
409 </QueueRowSwipeable>
410 </Animated.View>
411 </QueueShiftedRow>
412 );
413 })}
414 </ScrollView>
415 );
416
417 if (Platform.OS === "ios") {
418 // A plain formSheet screen never renders a stack header, so it comes from
419 // a nested native stack inside the sheet (same shape as the git sheet).
420 return (
421 <GestureHandlerRootView style={{ flex: 1 }}>
422 <View collapsable={false} className="flex-1 bg-sheet">
423 <ScreenStack style={{ flex: 1 }}>
424 <Screen
425 activityState={2}
426 enabled
427 isNativeStack
428 screenId="thread-queue-sheet-native"
429 scrollEdgeEffects={HEADER_SCROLL_EDGE_EFFECTS}
430 style={{ backgroundColor: theme["--color-sheet"], flex: 1 }}
431 >
432 {content}
433 <ScreenStackHeaderConfig
434 backgroundColor="rgba(0,0,0,0)"
435 color={theme["--color-foreground"]}
436 hideBackButton
437 hideShadow={false}
438 title="Queued"
439 titleColor={theme["--color-foreground"]}
440 titleFontSize={18}
441 titleFontWeight="800"
442 translucent
443 />
444 </Screen>
445 </ScreenStack>
446 </View>
447 </GestureHandlerRootView>
448 );
449 }
450
451 return (
452 <GestureHandlerRootView style={{ flex: 1 }}>
453 <View collapsable={false} className="flex-1 bg-sheet">
454 <NativeStackScreenOptions options={{ sheetCornerRadius: 28 }} />
455 <AndroidSheetHeader title="Queued" onBack={() => navigation.goBack()} />
456 <MaterialScreenContent insetHorizontal={layout.usesSplitView}>
457 {content}
458 </MaterialScreenContent>
459 </View>
460 </GestureHandlerRootView>
461 );
462}
463
464function QueueShiftedRow(props: {
465 readonly offset: number;
466 readonly dragging: boolean;
467 readonly lifted: boolean;
468 readonly onLayout: React.ComponentProps<typeof View>["onLayout"];
469 readonly children: React.ReactNode;
470}) {
471 const { dragging, offset } = props;
472 const style = useAnimatedStyle(() => ({
473 transform: [
474 {
475 translateY: dragging
476 ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
477 : offset,
478 },
479 ],
480 }));
481 return (
482 <Reanimated.View onLayout={props.onLayout} style={[style, { zIndex: props.lifted ? 1 : 0 }]}>
483 {props.children}
484 </Reanimated.View>
485 );
486}
487
488/** Swipe left to remove, the one destructive action that needs no menu. */
489function QueueRowSwipeable(props: {
490 readonly enabled: boolean;
491 readonly background: string;
492 readonly onRemove: () => void;
493 readonly children: React.ReactNode;
494}) {
495 const swipeableRef = useRef<SwipeableMethods | null>(null);
496 return (
497 <ReanimatedSwipeable
498 ref={swipeableRef}
499 enabled={props.enabled}
500 friction={2}
501 rightThreshold={40}
502 overshootRight={false}
503 // The sheet's own vertical scroll and the drag handle both move up and
504 // down; a swipe has to be clearly sideways before it takes the gesture.
505 failOffsetY={[-12, 12]}
506 containerStyle={{ backgroundColor: props.background, flex: 1 }}
507 childrenContainerStyle={{ backgroundColor: props.background }}
508 onSwipeableOpen={(direction) => {
509 if (direction !== "right") return;
510 swipeableRef.current?.close();
511 props.onRemove();
512 }}
513 renderRightActions={() => (
514 <Pressable
515 accessibilityRole="button"
516 accessibilityLabel={REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL}
517 disabled={!props.enabled}
518 onPress={() => {
519 swipeableRef.current?.close();
520 props.onRemove();
521 }}
522 className="items-center justify-center bg-danger active:opacity-70 disabled:opacity-40"
523 style={{ width: REMOVE_ACTION_WIDTH }}
524 >
525 <SymbolView name="trash" size={16} tintColorClassName="accent-danger-foreground" />
526 <Text className="pt-1 text-2xs font-t3-medium text-danger-foreground">Remove</Text>
527 </Pressable>
528 )}
529 >
530 {props.children}
531 </ReanimatedSwipeable>
532 );
533}
534
535function QueueAttachmentThumbnails(props: {
536 readonly environmentId: EnvironmentId;
537 readonly attachments: ReadonlyArray<ChatAttachment>;
538}) {
539 const images = props.attachments.filter((attachment) => attachment.mimeType.startsWith("image/"));
540 const shown = images.slice(0, THUMBNAIL_LIMIT);
541 const overflow = props.attachments.length - shown.length;
542 if (props.attachments.length === 0) return null;
543 return (
544 <View className="shrink-0 flex-row items-center gap-1">
545 {shown.map((attachment) => (
546 <QueueAttachmentThumbnail
547 key={attachment.id}
548 environmentId={props.environmentId}
549 attachment={attachment}
550 />
551 ))}
552 {overflow > 0 ? (
553 <View className="h-6 min-w-6 items-center justify-center rounded bg-subtle px-1">
554 <Text className="text-2xs tabular-nums text-foreground-muted">
555 {shown.length === 0 ? `${overflow}` : `+${overflow}`}
556 </Text>
557 </View>
558 ) : null}
559 </View>
560 );
561}
562
563function QueueAttachmentThumbnail(props: {
564 readonly environmentId: EnvironmentId;
565 readonly attachment: ChatAttachment;
566}) {
567 const resource = {
568 _tag: "attachment",
569 attachmentId: props.attachment.id,
570 fileName: props.attachment.name,
571 mimeType: props.attachment.mimeType,
572 disposition: "inline",
573 } as const;
574 const url = useAssetUrl(props.environmentId, resource);
575 if (url === null) {
576 return <View className="h-6 w-6 rounded bg-subtle" />;
577 }
578 return (
579 <Image
580 source={assetImageSource(props.environmentId, resource, url)}
581 cachePolicy="memory-disk"
582 contentFit="cover"
583 style={{ width: 24, height: 24, borderRadius: 4 }}
584 accessibilityIgnoresInvertColors
585 />
586 );
587}
588
589function QueueDragHandle(props: {
590 disabled: boolean;
591 title: string;
592 canMoveUp: boolean;
593 canMoveDown: boolean;
594 onStep: (action: "up" | "down") => void;
595 onStart: () => void;
596 onMove: (y: number) => void;
597 onEnd: (y: number, success: boolean) => void;
598}) {
599 const latest = useRef(props);
600 useLayoutEffect(() => {
601 latest.current = props;
602 });
603 const gesture = useMemo(
604 () =>
605 Gesture.Pan()
606 .enabled(!props.disabled)
607 .minDistance(0)
608 .shouldCancelWhenOutside(false)
609 .runOnJS(true)
610 .onStart(() => latest.current.onStart())
611 .onUpdate((event) => latest.current.onMove(event.translationY))
612 .onFinalize((event, success) => latest.current.onEnd(event.translationY, success)),
613 [props.disabled],
614 );
615 return (
616 <GestureDetector gesture={gesture}>
617 <View
618 collapsable={false}
619 accessible
620 accessibilityRole="adjustable"
621 accessibilityLabel={`Reorder ${props.title}`}
622 accessibilityState={{ disabled: props.disabled }}
623 accessibilityActions={[
624 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
625 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
626 ]}
627 onAccessibilityAction={({ nativeEvent }) => {
628 if (props.disabled) return;
629 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
630 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
631 }}
632 className="h-12 w-8 items-center justify-center"
633 >
634 <SymbolView
635 name="line.3.horizontal"
636 size={16}
637 tintColorClassName="accent-foreground-muted"
638 />
639 </View>
640 </GestureDetector>
641 );
642}