authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-08 20:34:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 20:34:58-07:00
log2a303535305d7d224a750fdee78c66e2a52ab3ab
tree25ff5b7cee9e24cbb8c747eb2c500bad53bd2eb8
parent08463e2c401ce87858aaaebcb70ed86fb002fb5f
signature Signed by PGP key B5690EEEBB952194

feat(mobile): arrange threads with drag handles (#10496)


12 files changed, 1037 insertions(+), 38 deletions(-)

apps/mobile/src/App.tsx+2
......@@ -9,6 +9,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
99import { createStaticNavigation } from "@react-navigation/native";
1010
1111import { RegistryContext } from "@effect/atom-react";
12import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet";
1213import { ConfirmDialogHost } from "./components/ConfirmDialogHost";
1314import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider";
1415import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene";
......@@ -94,6 +95,7 @@ function AppContent() {
9495 <Navigation linking={appLinking} theme={navigationTheme} />
9596 </IncomingShareProvider>
9697 <ConfirmDialogHost />
98 <ThreadArrangementHost />
9799 </BlurTargetView>
98100 {/* Anchored-menu overlays render here — in-window, so the
99101 keyboard stays up while a dropdown is open. */}
apps/mobile/src/components/AppSymbol.tsx+2
......@@ -59,6 +59,7 @@ import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";
5959import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";
6060import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight";
6161import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing";
62import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
6263import IconLink from "@tabler/icons-react-native/IconLink";
6364import IconMessage from "@tabler/icons-react-native/IconMessage";
6465import IconMinus from "@tabler/icons-react-native/IconMinus";
......@@ -141,6 +142,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
141142 "info.circle": IconInfoCircle,
142143 laptopcomputer: IconDeviceLaptop,
143144 link: IconLink,
145 "line.3.horizontal": IconMenu2,
144146 "line.3.horizontal.decrease": IconFilter,
145147 "line.3.horizontal.decrease.circle": IconFilter,
146148 "line.3.horizontal.decrease.circle.fill": IconFilterFilled,
apps/mobile/src/features/home/HomeScreen.tsx+3-2
......@@ -1,3 +1,4 @@
1import type { ThreadMoveDestination } from "../threads/threadOrder";
12import { createThreadMovePlanner } from "../threads/threadOrder";
23import {
34 LegendList,
......@@ -122,7 +123,7 @@ interface HomeScreenProps {
122123 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
123124 readonly onMoveThread: (
124125 thread: EnvironmentThreadShell,
125 direction: "up" | "down",
126 direction: ThreadMoveDestination,
126127 ) => Promise<boolean>;
127128 readonly onRegenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
128129 readonly onSelectPendingTask: (pendingTask: PendingNewTask) => void;
......@@ -519,7 +520,7 @@ export function HomeScreen(props: HomeScreenProps) {
519520 [props.onPinThread],
520521 );
521522 const handleMoveThread = useCallback(
522 (thread: EnvironmentThreadShell, direction: "up" | "down") => {
523 (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
523524 void props.onMoveThread(thread, direction);
524525 },
525526 [props.onMoveThread],
apps/mobile/src/features/home/useThreadListActions.ts+102-22
......@@ -1,5 +1,6 @@
1import type { ThreadMoveDestination } from "../threads/threadOrder";
12import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { canSnooze } from "@t3tools/client-runtime/state/thread-settled";
3import { canSnooze, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
34import * as Cause from "effect/Cause";
45import * as Haptics from "expo-haptics";
56import { useCallback, useRef } from "react";
......@@ -15,8 +16,16 @@ import { environmentServerConfigsAtom } from "../../state/server";
1516import { environmentThreadShells, threadEnvironment } from "../../state/threads";
1617import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
1718import { useAtomCommand } from "../../state/use-atom-command";
18import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
19import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
19import {
20 beginPendingThreadOrder,
21 getPendingThreadOrder,
22 threadDropBusyAtom,
23} from "../../state/thread-order";
24import {
25 createPendingThreadOrder,
26 createThreadMovePlanner,
27 threadDropLifecycle,
28} from "../threads/threadOrder";
2029import { getThreadListV2OrderedSection } from "../threads/threadListV2";
2130
2231/** Version skew: never send settle/unsettle to a server that predates them
......@@ -229,7 +238,7 @@ export function useThreadListActions(): {
229238 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
230239 readonly moveThread: (
231240 thread: EnvironmentThreadShell,
232 direction: "up" | "down",
241 direction: ThreadMoveDestination,
233242 ) => Promise<boolean>;
234243 readonly regenerateThreadTitle: (thread: EnvironmentThreadShell) => Promise<boolean>;
235244} {
......@@ -474,9 +483,29 @@ export function useThreadListActions(): {
474483 reportFailure: false,
475484 });
476485 const moveThread = useCallback(
477 async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
478 if (getPendingThreadOrder() !== null) return false;
479 const section = thread.pinnedAt != null ? "pinned" : "active";
486 async (thread: EnvironmentThreadShell, direction: ThreadMoveDestination) => {
487 if (getPendingThreadOrder() !== null || appAtomRegistry.get(threadDropBusyAtom)) return false;
488 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
489 const current = shells.find(
490 (row) => row.id === thread.id && row.environmentId === thread.environmentId,
491 );
492 if (!current || current.archivedAt !== null) return false;
493 thread = current;
494 const section =
495 typeof direction === "object" && direction.section !== undefined
496 ? direction.section
497 : thread.pinnedAt != null
498 ? "pinned"
499 : "active";
500 if (section === "settled") {
501 if (!environmentSupportsSettlement(thread.environmentId)) return false;
502 appAtomRegistry.set(threadDropBusyAtom, true);
503 try {
504 return await settleThread(thread);
505 } finally {
506 appAtomRegistry.set(threadDropBusyAtom, false);
507 }
508 }
480509 const configs = appAtomRegistry.get(environmentServerConfigsAtom);
481510 const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
482511 const capabilities = configs.get(environmentId)?.environment.capabilities;
......@@ -491,7 +520,6 @@ export function useThreadListActions(): {
491520 );
492521 return false;
493522 }
494 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
495523 const ordered = getThreadListV2OrderedSection({
496524 threads: shells,
497525 section,
......@@ -515,24 +543,67 @@ export function useThreadListActions(): {
515543 reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),
516544 })(scopedThreadKey(thread.environmentId, thread.id), direction);
517545 if (assignments === null) return false;
546 const lifecycle = threadDropLifecycle(thread, section, new Date().toISOString());
547 const crossSection = !ordered.some(
548 (row) => row.id === thread.id && row.environmentId === thread.environmentId,
549 );
550 if (
551 crossSection &&
552 (((section === "pinned" || thread.pinnedAt != null) &&
553 !environmentSupportsPinning(thread.environmentId)) ||
554 (thread.settledOverride === "settled" &&
555 !environmentSupportsSettlement(thread.environmentId)) ||
556 (effectiveSnoozed(thread, { now: new Date().toISOString() }) &&
557 !environmentSupportsSnooze(thread.environmentId)))
558 )
559 return false;
518560 const shellByKey = new Map(
519 ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
561 shells.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
520562 );
521563 selectionHaptic();
522 const pending = beginPendingThreadOrder(
523 createPendingThreadOrder({
524 section,
525 ordered,
526 movedId: scopedThreadKey(thread.environmentId, thread.id),
527 direction,
528 assignments,
529 }),
530 );
564 appAtomRegistry.set(threadDropBusyAtom, true);
565 const pending = crossSection
566 ? null
567 : beginPendingThreadOrder(
568 createPendingThreadOrder({
569 section,
570 ordered,
571 movedId: scopedThreadKey(thread.environmentId, thread.id),
572 direction,
573 assignments,
574 }),
575 );
531576 let succeeded = false;
532577 const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation;
533578 try {
579 if (crossSection) {
580 if (section === "pinned") {
581 const orderKey = assignments.find(
582 ({ id }) => id === scopedThreadKey(thread.environmentId, thread.id),
583 )?.orderKey;
584 const result = await pinMutation({
585 environmentId: thread.environmentId,
586 input: { threadId: thread.id, ...(orderKey === undefined ? {} : { orderKey }) },
587 });
588 if (result._tag === "Failure") {
589 Alert.alert("Could not pin thread", String(Cause.squash(result.cause)));
590 return false;
591 }
592 } else {
593 if (lifecycle.unpin && !(await unpinThread(thread))) return false;
594 if (lifecycle.unsettle && !(await unsettleThread(thread))) return false;
595 if (lifecycle.unsnooze && !(await unsnoozeThread(thread))) return false;
596 }
597 }
534598 for (const assignment of assignments) {
535 if (!pending.isPending()) return false;
599 if (
600 crossSection &&
601 section === "pinned" &&
602 thread.pinnedAt == null &&
603 assignment.id === scopedThreadKey(thread.environmentId, thread.id)
604 )
605 continue;
606 if (pending !== null && !pending.isPending()) return false;
536607 const target = shellByKey.get(assignment.id);
537608 if (target === undefined) continue;
538609 const result = await reorder({
......@@ -552,13 +623,22 @@ export function useThreadListActions(): {
552623 }
553624 }
554625 succeeded = true;
555 pending.complete();
626 pending?.complete();
556627 return true;
557628 } finally {
558 if (!succeeded) pending.cancel();
629 if (!succeeded) pending?.cancel();
630 appAtomRegistry.set(threadDropBusyAtom, false);
559631 }
560632 },
561 [reorderActiveMutation, reorderPinnedMutation],
633 [
634 settleThread,
635 reorderActiveMutation,
636 reorderPinnedMutation,
637 pinMutation,
638 unpinThread,
639 unsettleThread,
640 unsnoozeThread,
641 ],
562642 );
563643
564644 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
apps/mobile/src/features/threads/ThreadArrangementSheet.tsx created+564
......@@ -0,0 +1,564 @@
1import { appAtomRegistry } from "../../state/atom-registry";
2import { useAtomValue } from "@effect/atom-react";
3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
4import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
5import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
6import { Animated, FlatList, Modal, Pressable, View } from "react-native";
7import { Gesture, GestureDetector, GestureHandlerRootView } from "react-native-gesture-handler";
8import Reanimated, { ReduceMotion, useAnimatedStyle, withTiming } from "react-native-reanimated";
9import { threadDragGapOffset } from "./threadDragGap";
10import { useSafeAreaInsets } from "react-native-safe-area-context";
11
12import { AppText as Text } from "../../components/AppText";
13import { SymbolView } from "../../components/AppSymbol";
14import { scopedThreadKey } from "../../lib/scopedEntities";
15import { environmentServerConfigsAtom } from "../../state/server";
16import { environmentThreadShells } from "../../state/threads";
17import {
18 pendingThreadOrderAtom,
19 threadDropBusyAtom,
20 threadArrangementOpenAtom,
21} from "../../state/thread-order";
22import { queuedThreadKeysAtom } from "../../state/use-thread-outbox";
23import { useThreadListActions } from "../home/useThreadListActions";
24import {
25 createThreadMovePlanner,
26 threadDragAction,
27 type ThreadMoveDestination,
28} from "./threadOrder";
29import { getThreadListV2OrderedSection } from "./threadListV2";
30
31const ROW_HEIGHT = 56;
32const HEADER_HEIGHT = 48;
33const keyOf = (thread: EnvironmentThreadShell) => scopedThreadKey(thread.environmentId, thread.id);
34type Section = "pinned" | "active" | "snoozed" | "settled";
35type Destination = Exclude<ThreadMoveDestination, string>;
36type Row = {
37 key: string;
38 section: Section;
39 thread?: EnvironmentThreadShell;
40 offset: number;
41 height: number;
42};
43type Drag = {
44 orderVersion: string;
45 sourceSection: Section;
46 thread: EnvironmentThreadShell;
47 startY: number;
48 translation: number;
49 destination: Destination | null;
50};
51
52function ArrangementRow(props: {
53 height: number;
54 offset: number;
55 lifted: boolean;
56 dragging: boolean;
57 children: ReactNode;
58}) {
59 const { dragging, offset, lifted } = props;
60 const style = useAnimatedStyle(() => ({
61 transform: [
62 {
63 translateY: dragging
64 ? withTiming(offset, { duration: 160, reduceMotion: ReduceMotion.System })
65 : offset,
66 },
67 ],
68 opacity: lifted ? 0 : 1,
69 }));
70 return (
71 <Reanimated.View
72 style={[{ height: props.height }, style]}
73 className="flex-row items-center border-b border-border-subtle px-5"
74 >
75 {props.children}
76 </Reanimated.View>
77 );
78}
79
80/** Native pan recognition wins over list scrolling only inside the handle. */
81function DragHandle(props: {
82 title: string;
83 disabled: boolean;
84 onStart: () => void;
85 onMove: (translation: number) => void;
86 onEnd: (cancelled: boolean) => void;
87 onStep: (direction: "up" | "down") => void;
88 sectionActions: readonly { name: "pinned" | "active" | "settled"; label: string }[];
89 onSectionMove: (section: "pinned" | "active" | "settled") => void;
90 canMoveUp: boolean;
91 canMoveDown: boolean;
92}) {
93 const latest = useRef(props);
94 latest.current = props;
95 const gesture = useMemo(
96 () =>
97 Gesture.Pan()
98 .enabled(!props.disabled)
99 .minDistance(0)
100 .shouldCancelWhenOutside(false)
101 .runOnJS(true)
102 .onStart(() => latest.current.onStart())
103 .onUpdate((event) => latest.current.onMove(event.translationY))
104 .onEnd((event) => latest.current.onMove(event.translationY))
105 .onFinalize((_, success) => latest.current.onEnd(!success)),
106 [props.disabled],
107 );
108 return (
109 <GestureDetector gesture={gesture}>
110 <View
111 collapsable={false}
112 accessible
113 accessibilityRole="adjustable"
114 accessibilityLabel={`Reorder ${props.title}`}
115 accessibilityHint="Move up and Move down reorder within this section. Other actions move between sections."
116 accessibilityState={{ disabled: props.disabled }}
117 accessibilityActions={[
118 ...props.sectionActions,
119 ...(props.canMoveUp ? [{ name: "decrement", label: "Move up" }] : []),
120 ...(props.canMoveDown ? [{ name: "increment", label: "Move down" }] : []),
121 ]}
122 onAccessibilityAction={({ nativeEvent }) => {
123 if (props.disabled) return;
124 const sectionAction = props.sectionActions.find(
125 (action) => action.name === nativeEvent.actionName,
126 );
127 if (sectionAction) props.onSectionMove(sectionAction.name);
128 if (nativeEvent.actionName === "decrement" && props.canMoveUp) props.onStep("up");
129 if (nativeEvent.actionName === "increment" && props.canMoveDown) props.onStep("down");
130 }}
131 style={{
132 width: 48,
133 height: 48,
134 alignItems: "center",
135 justifyContent: "center",
136 opacity: props.disabled ? 0.3 : 1,
137 }}
138 >
139 <SymbolView
140 name="line.3.horizontal"
141 size={22}
142 tintColorClassName="accent-foreground-muted"
143 />
144 </View>
145 </GestureDetector>
146 );
147}
148
149export function ThreadArrangementSheet(props: { onClose: () => void }) {
150 const insets = useSafeAreaInsets();
151 const threads = useAtomValue(environmentThreadShells.threadShellsAtom);
152 const configs = useAtomValue(environmentServerConfigsAtom);
153 const queuedThreadKeys = useAtomValue(queuedThreadKeysAtom);
154 const pendingOrder = useAtomValue(pendingThreadOrderAtom);
155 const dropBusy = useAtomValue(threadDropBusyAtom);
156 const { moveThread } = useThreadListActions();
157 const [now, setNow] = useState(() => new Date().toISOString());
158 const [expanded, setExpanded] = useState({ snoozed: false, settled: false });
159 useEffect(() => {
160 const wakeAt = Math.min(
161 ...threads.flatMap((thread) => {
162 const at = Date.parse(thread.snoozedUntil ?? "");
163 return at > Date.parse(now) ? [at] : [];
164 }),
165 );
166 if (!Number.isFinite(wakeAt)) return;
167 const timer = setTimeout(
168 () => setNow(new Date().toISOString()),
169 Math.min(Math.max(0, wakeAt - Date.now()) + 1, 2_147_483_647),
170 );
171 return () => clearTimeout(timer);
172 }, [threads, now]);
173 const sections = useMemo(() => {
174 const shared = {
175 threads,
176 now,
177 queuedThreadKeys,
178 pendingOrder,
179 settlementEnvironmentIds: new Set(
180 [...configs].flatMap(([id, config]) =>
181 config.environment.capabilities.threadSettlement ? [id] : [],
182 ),
183 ),
184 snoozeEnvironmentIds: new Set(
185 [...configs].flatMap(([id, config]) =>
186 config.environment.capabilities.threadSnooze ? [id] : [],
187 ),
188 ),
189 };
190 const pinned = getThreadListV2OrderedSection({ ...shared, section: "pinned" });
191 const active = getThreadListV2OrderedSection({ ...shared, section: "active" });
192 const visible = new Set([...pinned, ...active].map(keyOf));
193 const parked = threads.filter(
194 (thread) => thread.archivedAt === null && !visible.has(keyOf(thread)),
195 );
196 return {
197 pinned,
198 active,
199 snoozed: parked.filter((thread) => effectiveSnoozed(thread, { now })),
200 settled: parked.filter((thread) => !effectiveSnoozed(thread, { now })),
201 };
202 }, [threads, configs, now, queuedThreadKeys, pendingOrder]);
203 const planners = useMemo(() => {
204 const planner = (section: "pinned" | "active") =>
205 createThreadMovePlanner({
206 ordered: sections[section],
207 allThreads: threads,
208 section,
209 reorderableEnvironmentIds: new Set(
210 [...configs].flatMap(([id, config]) =>
211 (
212 section === "pinned"
213 ? config.environment.capabilities.threadPinReorder
214 : config.environment.capabilities.threadActiveReorder
215 )
216 ? [id]
217 : [],
218 ),
219 ),
220 });
221 return { pinned: planner("pinned"), active: planner("active") };
222 }, [sections, threads, configs]);
223 const rows = useMemo(() => {
224 const result: Row[] = [];
225 let offset = 0;
226 for (const section of ["pinned", "active", "snoozed", "settled"] as const) {
227 if (section === "snoozed" && sections[section].length === 0) continue;
228 result.push({ key: section, section, offset, height: HEADER_HEIGHT });
229 offset += HEADER_HEIGHT;
230 if ((section === "snoozed" || section === "settled") && !expanded[section]) continue;
231 for (const thread of sections[section]) {
232 result.push({ key: keyOf(thread), section, thread, offset, height: ROW_HEIGHT });
233 offset += ROW_HEIGHT;
234 }
235 }
236 return result;
237 }, [sections, expanded]);
238 const list = useRef<FlatList<Row>>(null);
239 const geometry = useRef({ height: 0, offset: 0 });
240 const drag = useRef<Drag | null>(null);
241 const frame = useRef<number | null>(null);
242 const [preview, setPreview] = useState<Drag | null>(null);
243 const translateY = useRef(new Animated.Value(0)).current;
244 const latest = useRef({ rows, planners, moveThread });
245 latest.current = { rows, planners, moveThread };
246
247 function stop() {
248 if (frame.current !== null) cancelAnimationFrame(frame.current);
249 frame.current = null;
250 drag.current = null;
251 setPreview(null);
252 }
253 const orderVersion = rows
254 .map((row) => `${row.key}:${row.thread?.pinOrderKey}:${row.thread?.activeOrderKey}`)
255 .join("|");
256 useEffect(() => {
257 stop();
258 }, [orderVersion]);
259 useEffect(
260 () => () => {
261 if (frame.current !== null) cancelAnimationFrame(frame.current);
262 },
263 [],
264 );
265
266 function update(translation: number) {
267 const current = drag.current;
268 if (current === null) return;
269 current.translation = translation;
270 const { height, offset } = geometry.current;
271 const y = current.startY + translation;
272 translateY.setValue(Math.max(0, Math.min(height - ROW_HEIGHT, y - ROW_HEIGHT / 2)));
273 const contentY = Math.max(0, y + offset);
274 const target =
275 latest.current.rows.find((row) => contentY < row.offset + row.height) ??
276 latest.current.rows.at(-1);
277 let destination: Destination | null = null;
278 if (
279 target &&
280 y >= 0 &&
281 y <= height &&
282 (target.section === "pinned" || target.section === "active" || target.section === "settled")
283 ) {
284 const candidate: Destination = {
285 section: target.section,
286 targetId: target.thread ? target.key : null,
287 placement:
288 !target.thread || contentY < target.offset + target.height / 2 ? "before" : "after",
289 };
290 if (target.section === "settled") {
291 if (
292 current.sourceSection !== "settled" &&
293 configs.get(current.thread.environmentId)?.environment.capabilities.threadSettlement
294 )
295 destination = { section: "settled", targetId: null, placement: "before" };
296 } else if (latest.current.planners[target.section](keyOf(current.thread), candidate) !== null)
297 destination = candidate;
298 }
299 if (
300 current.destination?.targetId !== destination?.targetId ||
301 current.destination?.section !== destination?.section ||
302 current.destination?.placement !== destination?.placement
303 ) {
304 current.destination = destination;
305 setPreview({ ...current });
306 }
307 }
308 function start(row: Row) {
309 if (!row.thread || preview !== null) return;
310 drag.current = {
311 orderVersion,
312 sourceSection: row.section,
313 thread: row.thread,
314 startY: row.offset + ROW_HEIGHT / 2 - geometry.current.offset,
315 translation: 0,
316 destination: null,
317 };
318 setPreview({ ...drag.current });
319 update(0);
320 let last = performance.now();
321 const tick = () => {
322 const current = drag.current;
323 if (!current) return;
324 const timestamp = performance.now();
325 const dt = Math.min(timestamp - last, 32);
326 last = timestamp;
327 const bounds = geometry.current;
328 const y = current.startY + current.translation;
329 const speed =
330 y < 48
331 ? -Math.min(1, (48 - y) / 48)
332 : y > bounds.height - 48
333 ? Math.min(1, (y - bounds.height + 48) / 48)
334 : 0;
335 const tail = latest.current.rows.at(-1);
336 const maximum = Math.max(0, (tail ? tail.offset + tail.height : 0) - bounds.height);
337 const offset = Math.max(0, Math.min(maximum, bounds.offset + speed * dt * 0.5));
338 if (offset !== bounds.offset) {
339 bounds.offset = offset;
340 list.current?.scrollToOffset({ offset, animated: false });
341 update(current.translation);
342 }
343 frame.current = requestAnimationFrame(tick);
344 };
345 frame.current = requestAnimationFrame(tick);
346 }
347 const visiblePreview = preview?.orderVersion === orderVersion ? preview : null;
348 const sourceRow = visiblePreview
349 ? rows.find((row) => row.key === keyOf(visiblePreview.thread))
350 : undefined;
351 const destination = visiblePreview?.destination;
352 const targetRow = destination
353 ? rows.find(
354 (row) =>
355 row.section === destination.section &&
356 row.key === (destination.targetId ?? destination.section),
357 )
358 : undefined;
359 const insertionOffset = targetRow
360 ? targetRow.offset +
361 (!targetRow.thread || destination?.placement === "after" ? targetRow.height : 0)
362 : sourceRow?.offset;
363 return (
364 <Modal
365 visible
366 animationType="slide"
367 presentationStyle="fullScreen"
368 onRequestClose={props.onClose}
369 >
370 <GestureHandlerRootView style={{ flex: 1 }}>
371 <View
372 className="flex-1 bg-screen"
373 style={{ paddingTop: insets.top, paddingBottom: insets.bottom }}
374 >
375 <View className="flex-row items-center justify-between gap-3 px-5 py-3">
376 <Text className="flex-1 text-xl font-t3-semibold">Arrange threads</Text>
377 <Pressable
378 accessibilityRole="button"
379 onPress={props.onClose}
380 className="min-h-11 justify-center px-3"
381 >
382 <Text className="text-base text-primary">Done</Text>
383 </Pressable>
384 </View>
385 <Text className="px-5 pb-3 text-sm text-foreground-muted">
386 Drag to reorder, pin, or settle. Changes save when you drop.
387 </Text>
388 <View
389 onLayout={(event) => {
390 geometry.current.height = event.nativeEvent.layout.height;
391 }}
392 className="flex-1"
393 style={{ overflow: "hidden" }}
394 >
395 <FlatList
396 ref={list}
397 data={rows}
398 keyExtractor={(row) => row.key}
399 scrollEnabled={visiblePreview === null}
400 removeClippedSubviews={false}
401 onScroll={(event) => {
402 geometry.current.offset = event.nativeEvent.contentOffset.y;
403 }}
404 scrollEventThrottle={16}
405 getItemLayout={(_, index) => ({
406 length: rows[index]!.height,
407 offset: rows[index]!.offset,
408 index,
409 })}
410 renderItem={({ item }) => {
411 const thread = item.thread;
412 const planner =
413 item.section === "pinned" || item.section === "active"
414 ? planners[item.section]
415 : null;
416 const capabilities =
417 thread && configs.get(thread.environmentId)?.environment.capabilities;
418 const sectionActions = thread
419 ? (["pinned", "active", "settled"] as const).flatMap<{
420 name: "pinned" | "active" | "settled";
421 label: string;
422 }>((section) => {
423 if (section === item.section) return [];
424 const label = threadDragAction(item.section, section);
425 if (!label) return [];
426 if (section === "settled")
427 return capabilities?.threadSettlement ? [{ name: section, label }] : [];
428 if (
429 ((section === "pinned" || thread.pinnedAt != null) &&
430 !capabilities?.threadPinning) ||
431 (section === "active" &&
432 item.section === "settled" &&
433 !capabilities?.threadSettlement) ||
434 (section === "active" &&
435 item.section === "snoozed" &&
436 !capabilities?.threadSnooze)
437 )
438 return [];
439 return planners[section](item.key, {
440 section,
441 targetId: null,
442 placement: "before",
443 })
444 ? [{ name: section, label }]
445 : [];
446 })
447 : [];
448 return (
449 <ArrangementRow
450 height={item.height}
451 dragging={visiblePreview !== null}
452 lifted={item.key === sourceRow?.key}
453 offset={
454 sourceRow && insertionOffset !== undefined
455 ? threadDragGapOffset(
456 item.offset,
457 sourceRow.offset,
458 sourceRow.height,
459 insertionOffset,
460 )
461 : 0
462 }
463 >
464 {thread ? (
465 <>
466 <Text numberOfLines={2} className="flex-1 text-base">
467 {thread.title}
468 </Text>
469 <DragHandle
470 title={thread.title}
471 disabled={
472 dropBusy ||
473 pendingOrder !== null ||
474 !(
475 configs.get(thread.environmentId)?.environment.capabilities
476 .threadPinReorder ||
477 configs.get(thread.environmentId)?.environment.capabilities
478 .threadActiveReorder
479 )
480 }
481 sectionActions={sectionActions}
482 onSectionMove={(section) => {
483 void moveThread(thread, {
484 section,
485 targetId: null,
486 placement: "before",
487 });
488 }}
489 canMoveUp={planner?.(item.key, "up") != null}
490 canMoveDown={planner?.(item.key, "down") != null}
491 onStep={(direction) => {
492 void moveThread(thread, direction);
493 }}
494 onStart={() => start(item)}
495 onMove={update}
496 onEnd={(cancelled) => {
497 const current = drag.current;
498 if (cancelled || !current?.destination) {
499 stop();
500 return;
501 }
502 if (frame.current !== null) cancelAnimationFrame(frame.current);
503 frame.current = null;
504 drag.current = null;
505 // Retain the gap until the saved order arrives, avoiding a flash back.
506 void moveThread(current.thread, current.destination).finally(stop);
507 }}
508 />
509 </>
510 ) : (
511 <Pressable
512 disabled={item.section === "pinned" || item.section === "active"}
513 className="flex-1 justify-center self-stretch"
514 onPress={() => {
515 const section = item.section;
516 if (section === "snoozed" || section === "settled")
517 setExpanded((value) => ({ ...value, [section]: !value[section] }));
518 }}
519 >
520 <Text className="text-sm font-t3-semibold text-foreground-muted">
521 {item.section[0]!.toUpperCase() + item.section.slice(1)} (
522 {sections[item.section].length})
523 </Text>
524 </Pressable>
525 )}
526 </ArrangementRow>
527 );
528 }}
529 />
530 {visiblePreview ? (
531 <Animated.View
532 pointerEvents="none"
533 className="absolute left-5 right-5 justify-center rounded-xl border border-border bg-screen px-4"
534 style={{ top: 0, height: ROW_HEIGHT, transform: [{ translateY }] }}
535 >
536 <Text
537 numberOfLines={visiblePreview.destination?.section ? 1 : 2}
538 className="text-base font-t3-medium"
539 >
540 {visiblePreview.thread.title}
541 </Text>
542 {visiblePreview.destination?.section ? (
543 <Text className="text-xs text-foreground-muted">
544 {threadDragAction(
545 visiblePreview.sourceSection,
546 visiblePreview.destination.section,
547 )}
548 </Text>
549 ) : null}
550 </Animated.View>
551 ) : null}
552 </View>
553 </View>
554 </GestureHandlerRootView>
555 </Modal>
556 );
557}
558
559export function ThreadArrangementHost() {
560 const open = useAtomValue(threadArrangementOpenAtom);
561 return open ? (
562 <ThreadArrangementSheet onClose={() => appAtomRegistry.set(threadArrangementOpenAtom, false)} />
563 ) : null;
564}
apps/mobile/src/features/threads/thread-list-v2-items.tsx+14-4
......@@ -1,4 +1,7 @@
11import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { appAtomRegistry } from "../../state/atom-registry";
3import { threadArrangementOpenAtom } from "../../state/thread-order";
4import type { ThreadMoveDestination } from "./threadOrder";
25import type {
36 EnvironmentProject,
47 EnvironmentThreadShell,
......@@ -395,7 +398,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
395398 readonly titleRegenerationSupported: boolean;
396399 /** Server supports reordering this card's section. */
397400 readonly reorderSupported?: boolean;
398 readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
401 readonly onMoveThread?: (
402 thread: EnvironmentThreadShell,
403 direction: ThreadMoveDestination,
404 ) => void;
399405 /** Position flags for the card's section so the menu disables the move that
400406 would fall off the end of the list. */
401407 readonly canMoveUp?: boolean;
......@@ -515,8 +521,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
515521 // hides the card until wake with the pin intact.)
516522 const arrangementMenuItems = useMemo<MenuAction[]>(
517523 () => [
518 ...(variant === "card" && props.reorderSupported === true
524 ...(props.reorderSupported === true
519525 ? [
526 { id: "arrange", title: "Arrange threads…", image: "line.3.horizontal" },
520527 {
521528 id: "move-up",
522529 title: "Move up",
......@@ -583,11 +590,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
583590 const slimMenuActions = useMemo<MenuAction[]>(
584591 () => [
585592 SLIM_MENU_ACTIONS[0]!,
586 ...(thread.pinnedAt != null ? arrangementMenuItems : []),
593 ...arrangementMenuItems.filter(
594 (action) => action.id !== "move-up" && action.id !== "move-down",
595 ),
587596 ...titleRegenerationMenuItems,
588597 SLIM_MENU_ACTIONS[1]!,
589598 ],
590 [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems],
599 [arrangementMenuItems, titleRegenerationMenuItems],
591600 );
592601 const snoozedMenuActions = useMemo<MenuAction[]>(
593602 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
......@@ -610,6 +619,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
610619 if (nativeEvent.event === "unsnooze") handleUnsnooze();
611620 if (nativeEvent.event === "pin") handlePin();
612621 if (nativeEvent.event === "unpin") handleUnpin();
622 if (nativeEvent.event === "arrange") appAtomRegistry.set(threadArrangementOpenAtom, true);
613623 if (nativeEvent.event === "move-up") handleMoveUp();
614624 if (nativeEvent.event === "move-down") handleMoveDown();
615625 if (nativeEvent.event === "archive") handleArchive();
apps/mobile/src/features/threads/threadDragGap.test.ts created+48
......@@ -0,0 +1,48 @@
1import { describe, expect, it } from "vite-plus/test";
2import { threadDragAction, threadOrderAfterMove } from "./threadOrder";
3import { threadDragGapOffset } from "./threadDragGap";
4
5describe("live thread insertion gap", () => {
6 // Header, pinned row, Active header, two active rows. Geometry stays fixed for hit testing.
7 const offsets = [0, 48, 120, 168, 240];
8 const shifts = (source: number, insertion: number) =>
9 offsets.map((offset) => threadDragGapOffset(offset, source, 72, insertion));
10
11 it("moves the Active header and intervening rows up when unpinning", () => {
12 expect(shifts(48, 312)).toEqual([0, 0, -72, -72, -72]);
13 });
14 it("opens a full gap below the Pinned header when pinning", () => {
15 expect(shifts(240, 48)).toEqual([0, 72, 72, 72, 0]);
16 });
17 it("leaves the source gap in place for cancellation or its current destination", () => {
18 expect(shifts(168, 168)).toEqual([0, 0, 0, 0, 0]);
19 expect(shifts(168, 240)).toEqual([0, 0, 0, 0, 0]);
20 });
21 it("moves only crossed rows for an adjacent reorder", () => {
22 expect(shifts(168, 312)).toEqual([0, 0, 0, 0, -72]);
23 expect(shifts(240, 168)).toEqual([0, 0, 0, 72, 0]);
24 });
25});
26
27describe("drag action labels", () => {
28 it("names the action for each destination instead of its section", () => {
29 expect(threadDragAction("active", "pinned")).toBe("Pin");
30 expect(threadDragAction("pinned", "active")).toBe("Unpin");
31 expect(threadDragAction("settled", "active")).toBe("Unsettle");
32 expect(threadDragAction("snoozed", "active")).toBe("Unsnooze");
33 expect(threadDragAction("active", "settled")).toBe("Settle");
34 expect(threadDragAction("pinned", "settled")).toBe("Settle");
35 expect(threadDragAction("active", "active")).toBe("Reorder");
36 });
37 it("does not offer a parked-section reorder or snooze without a wake time", () => {
38 expect(threadDragAction("settled", "settled")).toBeNull();
39 expect(threadDragAction("active", "snoozed")).toBeNull();
40 expect(
41 threadOrderAfterMove(["a", "b"], "a", {
42 section: "settled",
43 targetId: null,
44 placement: "before",
45 }),
46 ).toBeNull();
47 });
48});
apps/mobile/src/features/threads/threadDragGap.ts created+13
......@@ -0,0 +1,13 @@
1/** Keep hit testing in the original layout while rows make room for the lifted item. */
2export function threadDragGapOffset(
3 rowOffset: number,
4 sourceOffset: number,
5 sourceHeight: number,
6 insertionOffset: number,
7): number {
8 if (rowOffset === sourceOffset) return 0;
9 if (insertionOffset <= sourceOffset) {
10 return rowOffset >= insertionOffset && rowOffset < sourceOffset ? sourceHeight : 0;
11 }
12 return rowOffset > sourceOffset && rowOffset < insertionOffset ? -sourceHeight : 0;
13}
apps/mobile/src/features/threads/threadListV2.test.ts+201
......@@ -2,6 +2,8 @@ import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
22import {
33 createPendingThreadOrder,
44 createThreadMovePlanner,
5 threadOrderAfterMove,
6 threadDropLifecycle,
57 reconcilePendingThreadOrder,
68 type PendingThreadOrder,
79} from "./threadOrder";
......@@ -1276,3 +1278,202 @@ describe("mobile move availability", () => {
12761278 expect(assignments![0]!.orderKey < "dd").toBe(true);
12771279 });
12781280});
1281
1282describe("thread drag destinations", () => {
1283 it("moves across multiple rows while keeping hidden anchors in place", () => {
1284 expect(
1285 threadOrderAfterMove(["a", "hidden", "b", "c"], "c", {
1286 targetId: "a",
1287 placement: "before",
1288 }),
1289 ).toEqual(["c", "a", "hidden", "b"]);
1290 expect(
1291 threadOrderAfterMove(["a", "hidden", "b", "c"], "a", {
1292 targetId: "b",
1293 placement: "after",
1294 }),
1295 ).toEqual(["hidden", "b", "a", "c"]);
1296 });
1297
1298 it("rejects missing, self, and unchanged destinations", () => {
1299 for (const targetId of ["missing", "a", "b"]) {
1300 expect(
1301 threadOrderAfterMove(["a", "b", "c"], "a", {
1302 targetId,
1303 placement: "before",
1304 }),
1305 ).toBeNull();
1306 }
1307 expect(threadOrderAfterMove(["a", "b"], "missing", "down")).toBeNull();
1308 });
1309
1310 it.each(["active", "pinned"] as const)(
1311 "persists a dropped %s row and holds its order until confirmed",
1312 (section) => {
1313 const ordered = ["a", "b", "c", "d"].map((id) =>
1314 makeThread({
1315 id: ThreadId.make(id),
1316 title: id,
1317 pinnedAt: section === "pinned" ? NOW : null,
1318 }),
1319 );
1320 const ids = ordered.map((row) => `${row.environmentId}:${row.id}`);
1321 const direction = { targetId: ids[0]!, placement: "before" as const };
1322 const assignments = createThreadMovePlanner({
1323 ordered,
1324 section,
1325 reorderableEnvironmentIds: new Set([environmentId]),
1326 })(ids[3]!, direction)!;
1327 const pending = createPendingThreadOrder({
1328 section,
1329 ordered,
1330 movedId: ids[3]!,
1331 direction,
1332 assignments,
1333 });
1334 expect(pending.orderedIds).toEqual([ids[3], ids[0], ids[1], ids[2]]);
1335 const confirmed = ordered.map((row) => ({
1336 ...row,
1337 [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignments.find(
1338 (a) => a.id === `${row.environmentId}:${row.id}`,
1339 )!.orderKey,
1340 }));
1341 expect(
1342 getThreadListV2OrderedSection({ threads: confirmed, section, now: NOW }).map(
1343 (row) => `${row.environmentId}:${row.id}`,
1344 ),
1345 ).toEqual(pending.orderedIds);
1346 expect(
1347 reconcilePendingThreadOrder({ ...pending, commandsComplete: true }, confirmed),
1348 ).toBeNull();
1349 },
1350 );
1351
1352 it("refuses a drop that would need to rewrite an old server's keyless row", () => {
1353 const old = EnvironmentId.make("old-server");
1354 const ordered = [environmentId, old, environmentId].map((env, index) =>
1355 makeThread({
1356 id: ThreadId.make(String(index)),
1357 title: String(index),
1358 environmentId: env,
1359 }),
1360 );
1361 expect(
1362 createThreadMovePlanner({
1363 ordered,
1364 section: "active",
1365 reorderableEnvironmentIds: new Set([environmentId]),
1366 })(`${environmentId}:2`, { targetId: `${environmentId}:0`, placement: "before" }),
1367 ).toBeNull();
1368 });
1369});
1370
1371it("allows a long drop past an old server even when both adjacent moves fail", () => {
1372 const old = EnvironmentId.make("old-server");
1373 const ordered = [
1374 makeThread({ id: ThreadId.make("a"), title: "a" }),
1375 makeThread({ id: ThreadId.make("b"), title: "b", environmentId: old }),
1376 makeThread({ id: ThreadId.make("c"), title: "c", activeOrderKey: "h" }),
1377 makeThread({ id: ThreadId.make("d"), title: "d", activeOrderKey: "p" }),
1378 ];
1379 const planner = createThreadMovePlanner({
1380 ordered,
1381 section: "active",
1382 reorderableEnvironmentIds: new Set([environmentId]),
1383 });
1384 const movedId = `${environmentId}:a`;
1385 expect(planner(movedId, "up")).toBeNull();
1386 expect(planner(movedId, "down")).toBeNull();
1387 const assignments = planner(movedId, { targetId: `${environmentId}:d`, placement: "after" });
1388 expect(assignments).toHaveLength(1);
1389 expect(assignments![0]!.id).toBe(movedId);
1390 expect(assignments![0]!.orderKey > "p").toBe(true);
1391});
1392
1393describe("cross-section thread drops", () => {
1394 it.each(["pinned", "active"] as const)("inserts into an empty %s section", (section) => {
1395 const thread = makeThread({ id: ThreadId.make("source"), title: "source" });
1396 const id = `${thread.environmentId}:${thread.id}`;
1397 const destination = { section, targetId: null, placement: "before" as const };
1398 expect(threadOrderAfterMove([], id, destination)).toEqual([id]);
1399 const plan = createThreadMovePlanner({
1400 ordered: [],
1401 allThreads: [thread],
1402 section,
1403 reorderableEnvironmentIds: new Set([environmentId]),
1404 })(id, destination);
1405 expect(plan).toHaveLength(1);
1406 expect(plan![0]!.id).toBe(id);
1407 });
1408 it("places an incoming row between existing anchors without rewriting them", () => {
1409 const a = makeThread({ id: ThreadId.make("a"), title: "a", pinOrderKey: "h" });
1410 const b = makeThread({ id: ThreadId.make("b"), title: "b", pinOrderKey: "z" });
1411 const source = makeThread({ id: ThreadId.make("source"), title: "source" });
1412 const id = `${environmentId}:source`;
1413 const destination = {
1414 section: "pinned" as const,
1415 targetId: `${environmentId}:b`,
1416 placement: "before" as const,
1417 };
1418 const plan = createThreadMovePlanner({
1419 ordered: [a, b],
1420 allThreads: [a, b, source],
1421 section: "pinned",
1422 reorderableEnvironmentIds: new Set([environmentId]),
1423 })(id, destination);
1424 expect(plan).toHaveLength(1);
1425 expect(plan![0]!.orderKey > "h" && plan![0]!.orderKey < "z").toBe(true);
1426 expect(
1427 threadOrderAfterMove([`${environmentId}:a`, `${environmentId}:b`], id, destination),
1428 ).toEqual([`${environmentId}:a`, id, `${environmentId}:b`]);
1429 });
1430 it("rejects removed targets and unsupported incoming sources", () => {
1431 expect(
1432 threadOrderAfterMove(["a"], "source", {
1433 section: "active",
1434 targetId: "gone",
1435 placement: "before",
1436 }),
1437 ).toBeNull();
1438 const source = makeThread({ id: ThreadId.make("source"), title: "source" });
1439 expect(
1440 createThreadMovePlanner({
1441 ordered: [],
1442 allThreads: [source],
1443 section: "active",
1444 reorderableEnvironmentIds: new Set(),
1445 })(`${environmentId}:source`, { section: "active", targetId: null, placement: "before" }),
1446 ).toBeNull();
1447 });
1448 it("clears pinning, settlement and snooze when returning a parked thread to Active", () => {
1449 const thread = makeThread({
1450 id: ThreadId.make("parked"),
1451 title: "parked",
1452 pinnedAt: NOW,
1453 settledOverride: "settled",
1454 snoozedAt: NOW,
1455 snoozedUntil: "2099-01-01T00:00:00.000Z",
1456 });
1457 expect(threadDropLifecycle(thread, "active", NOW)).toEqual({
1458 pin: false,
1459 unpin: true,
1460 unsettle: true,
1461 unsnooze: true,
1462 });
1463 expect(threadDropLifecycle(thread, "pinned", NOW)).toEqual({
1464 pin: true,
1465 unpin: false,
1466 unsettle: false,
1467 unsnooze: false,
1468 });
1469 });
1470 it("does not send lifecycle commands for an ordinary Active reorder", () => {
1471 expect(
1472 threadDropLifecycle(
1473 makeThread({ id: ThreadId.make("active"), title: "active" }),
1474 "active",
1475 NOW,
1476 ),
1477 ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
1478 });
1479});
apps/mobile/src/features/threads/threadOrder.ts+78-9
......@@ -1,7 +1,47 @@
11import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
2import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
3import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
34import type { EnvironmentId } from "@t3tools/contracts";
45
6export type ThreadMoveDestination =
7 | "up"
8 | "down"
9 | {
10 readonly targetId: string | null;
11 readonly section?: "pinned" | "active" | "settled";
12 readonly placement: "before" | "after";
13 };
14
15/** Resolve against stable row identities, including rows hidden by a filter. */
16export function threadOrderAfterMove(
17 orderedIds: readonly string[],
18 movedId: string,
19 destination: ThreadMoveDestination,
20): string[] | null {
21 if (typeof destination === "object" && destination.section === "settled") return null;
22 const from = orderedIds.indexOf(movedId);
23 if (from < 0 && (typeof destination === "string" || destination.section === undefined))
24 return null;
25 const result = orderedIds.filter((id) => id !== movedId);
26 let to: number;
27 if (typeof destination === "string") {
28 to = from + (destination === "up" ? -1 : 1);
29 if (to < 0 || to >= orderedIds.length) return null;
30 } else {
31 if (destination.targetId === null) {
32 if (destination.section === undefined) return null;
33 to = destination.placement === "before" ? 0 : result.length;
34 } else {
35 const target = result.indexOf(destination.targetId);
36 if (target < 0) return null;
37 to = target + (destination.placement === "after" ? 1 : 0);
38 }
39 }
40 if (to === from) return null;
41 result.splice(to, 0, movedId);
42 return result;
43}
44
545type OrderRow = Pick<
646 EnvironmentThreadShell,
747 | "id"
......@@ -49,13 +89,15 @@ export function createThreadMovePlanner(input: {
4989 ]),
5090 );
5191 const writableIds = new Set(
52 input.ordered
92 (input.allThreads ?? input.ordered)
5393 .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
5494 .map(rowId),
5595 );
56 return (movedId: string, direction: "up" | "down") => {
96 return (movedId: string, direction: ThreadMoveDestination) => {
5797 if (!writableIds.has(movedId)) return null;
58 const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction });
98 const nextIds = threadOrderAfterMove(orderedIds, movedId, direction);
99 if (nextIds === null) return null;
100 const assignments = planPinnedReorder({ orderedIds: nextIds, keysById, movedId });
59101 return assignments === null ||
60102 assignments.length === 0 ||
61103 assignments.some((assignment) => !writableIds.has(assignment.id))
......@@ -68,13 +110,11 @@ export function createPendingThreadOrder(input: {
68110 readonly section: PendingThreadOrder["section"];
69111 readonly ordered: readonly OrderRow[];
70112 readonly movedId: string;
71 readonly direction: "up" | "down";
113 readonly direction: ThreadMoveDestination;
72114 readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
73115}): PendingThreadOrder {
74 const orderedIds = input.ordered.map(rowId);
75 const from = orderedIds.indexOf(input.movedId);
76 orderedIds.splice(from, 1);
77 orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId);
116 const orderedIds = threadOrderAfterMove(input.ordered.map(rowId), input.movedId, input.direction);
117 if (orderedIds === null) throw new Error("Cannot begin an invalid thread move");
78118 return {
79119 section: input.section,
80120 orderedIds,
......@@ -118,3 +158,32 @@ export function applyPendingThreadOrder<T extends OrderRow>(
118158 (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
119159 );
120160}
161
162/** Match desktop re-entry: a pin wakes the thread on the server; Active clears
163 * each underlying parked state before assigning its destination order key. */
164export function threadDropLifecycle(
165 thread: EnvironmentThreadShell,
166 section: "pinned" | "active",
167 now: string,
168) {
169 if (section === "pinned") return { pin: true, unpin: false, unsettle: false, unsnooze: false };
170 return {
171 pin: false,
172 unpin: thread.pinnedAt != null,
173 unsettle: thread.settledOverride === "settled",
174 unsnooze: effectiveSnoozed(thread, { now }),
175 };
176}
177
178export type ThreadDragSection = "pinned" | "active" | "snoozed" | "settled";
179
180/** The action shown during hover describes the lifecycle change made on drop. */
181export function threadDragAction(source: ThreadDragSection, destination: ThreadDragSection) {
182 if (destination === "snoozed") return null;
183 if (destination === "settled") return source === "settled" ? null : "Settle";
184 if (source === destination) return "Reorder";
185 if (destination === "pinned") return "Pin";
186 if (source === "pinned") return "Unpin";
187 if (source === "settled") return "Unsettle";
188 return "Unsnooze";
189}
apps/mobile/src/state/thread-order.ts+5
......@@ -12,6 +12,11 @@ import { environmentServerConfigsAtom } from "./server";
1212import { environmentThreadShells } from "./threads";
1313import { queuedThreadKeysAtom } from "./use-thread-outbox";
1414
15// Covers lifecycle commands before a cross-section move can acquire an order hold.
16export const threadArrangementOpenAtom = Atom.make(false).pipe(Atom.keepAlive);
17
18export const threadDropBusyAtom = Atom.make(false).pipe(Atom.keepAlive);
19
1520export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(
1621 Atom.keepAlive,
1722);
docs/user/thread-sidebar.md+5-1
......@@ -49,7 +49,11 @@ identify empty sections and a collapsed settled shelf.
4949
5050Drag within the pinned or active section to change its order. Other rows slide aside to show the
5151spot where the thread will land. Drops into either section keep the position you choose. On
52mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server
52mobile, open a thread's menu and choose **Arrange threads**. Drag a handle within or between
53**Pinned** and **Active** to reorder, pin, or unpin. Drop onto the **Settled** divider to
54settle a thread. The dragged card shows the action before you release it. Expand **Snoozed**
55or **Settled** to drag a parked thread back into either live section. Each drop saves; **Done** returns to the thread list.
56**Move up** and **Move down** are also available in the thread menu. The server
5357saves the order, so it survives a refresh and appears on your other connected devices.
5458
5559On web and desktop, the list also animates section changes made with thread actions such as