# Files changed · PR \#9730 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/9730/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[feat\(mobile\): arrange active threads from both thread lists](https://git.cafe/pingdotgg/t3code/pull/9730.md)

Version: 3

Head commit: a41887d423c1d7ce46ca90b6f78a22251917b4b7

Base commit: d8f4d8673ec546b002ee3776bc91676cf5d895f8

Comparison base: d8f4d8673ec546b002ee3776bc91676cf5d895f8

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/features/home/HomeRouteScreen.tsx

Status: modified; +2; −2

```diff
@@ -49,7 +49,7 @@
     unsnoozeThread,
     pinThread,
     unpinThread,
-    movePinnedThread,
+    moveThread,
     regenerateThreadTitle,
     unsettleThread,
   } = useThreadListActions();
@@ -199,7 +199,7 @@
           onUnsettleThread={unsettleThread}
           onPinThread={pinThread}
           onUnpinThread={unpinThread}
-          onMovePinnedThread={movePinnedThread}
+          onMoveThread={moveThread}
           onRegenerateThreadTitle={regenerateThreadTitle}
           onEnvironmentChange={setSelectedEnvironmentId}
           onProjectChange={setSelectedProjectKey}
```

## apps/mobile/src/features/home/HomeScreen.tsx

Status: modified; +66; −28

```diff
@@ -1,3 +1,4 @@
+import { createThreadMovePlanner } from "../threads/threadOrder";
 import {
   LegendList,
   type LegendListRef,
@@ -11,7 +12,6 @@
   threadSearchMatchKey,
   type EnvironmentThreadSearchMatch,
 } from "@t3tools/client-runtime/state/thread-search";
-import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
 import {
   type EnvironmentId,
   resolveEnvironmentMachineKind,
@@ -35,6 +35,7 @@
 import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
 import { useThreadSearch } from "../../state/queries";
 import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
+import { usePendingThreadOrder } from "../../state/thread-order";
 import { environmentServerConfigsAtom } from "../../state/server";
 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
 import {
@@ -51,6 +52,7 @@
 } from "../threads/thread-list-v2-items";
 import {
   buildThreadListV2Items,
+  getThreadListV2OrderedSection,
   buildThreadListV2ListItems,
   THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
   THREAD_LIST_V2_SETTLED_PAGE_COUNT,
@@ -114,7 +116,7 @@
   readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
   readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
+  readonly onMoveThread: (
-  readonly onMovePinnedThread: (
     thread: EnvironmentThreadShell,
     direction: "up" | "down",
   ) => Promise<boolean>;
@@ -512,11 +514,11 @@
     },
     [props.onPinThread],
   );
-  const handleMovePinnedThread = useCallback(
+  const handleMoveThread = useCallback(
     (thread: EnvironmentThreadShell, direction: "up" | "down") => {
-      void props.onMovePinnedThread(thread, direction);
+      void props.onMoveThread(thread, direction);
     },
+    [props.onMoveThread],
-    [props.onMovePinnedThread],
   );
   const handleUnpinThread = useCallback(
     (thread: EnvironmentThreadShell) => {
@@ -608,6 +610,15 @@
     }
     return supported;
   }, [serverConfigs]);
+  const activeReorderEnvironmentIds = useMemo(() => {
+    const supported = new Set<EnvironmentId>();
+    for (const [environmentId, config] of serverConfigs) {
+      if (config.environment.capabilities.threadActiveReorder === true) {
+        supported.add(environmentId);
+      }
+    }
+    return supported;
+  }, [serverConfigs]);
   const titleRegenerationEnvironmentIds = useMemo(() => {
     const supported = new Set<EnvironmentId>();
     for (const [environmentId, config] of serverConfigs) {
@@ -627,20 +638,40 @@
       ),
     [serverConfigs],
   );
+  const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
+  const threadMovePlanners = useMemo(() => {
+    const sectionPlanner = (section: "pinned" | "active") =>
-  // Canonical arranged pinned order (reorder-capable threads only) for the
-  // Move up/down position flags. Computed from all shells, not the rendered
-  // list, so search/scope filtering never disables or misdirects a move.
+      createThreadMovePlanner({
+        allThreads: props.threads,
+        section,
+        reorderableEnvironmentIds: new Set(
+          [...serverConfigs].flatMap(([id, config]) =>
+            (section === "pinned"
+              ? config.environment.capabilities.threadPinReorder
+              : config.environment.capabilities.threadActiveReorder) === true
+              ? [id]
-  const arrangedPinnedKeys = useMemo(() => {
-    const pinned = sortPinnedThreadsByOrderKey(
-      props.threads.filter(
-        (thread) =>
-          thread.pinnedAt != null &&
-          thread.archivedAt === null &&
-          pinReorderEnvironmentIds.has(thread.environmentId),
-      ),
-    );
-    return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
-  }, [pinReorderEnvironmentIds, props.threads]);
+              : [],
+          ),
+        ),
+        ordered: getThreadListV2OrderedSection({
+          threads: props.threads,
+          section,
+          pendingOrder,
+          now: new Date().toISOString(),
+          settlementEnvironmentIds,
+          snoozeEnvironmentIds,
+        }),
+      });
+    return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+  }, [
+    serverConfigs,
+    props.threads,
+    pendingOrder,
+    settlementEnvironmentIds,
+    snoozeEnvironmentIds,
+    nowMinute,
+    snoozeWakeTick,
+  ]);
   const threadListV2Layout = useMemo(() => {
     if (!threadListV2Enabled)
       return {
@@ -655,6 +686,7 @@
     // Settled threads are live shells; archived threads keep their original
     // "hidden from lists" meaning.
     return buildThreadListV2Items({
+      pendingOrder,
       threads: props.threads.filter((thread) => thread.archivedAt === null),
       environmentId: props.selectedEnvironmentId,
       projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
@@ -669,6 +701,7 @@
       selectedThreadKey: null,
     });
   }, [
+    pendingOrder,
     nowMinute,
     snoozeWakeTick,
     snoozedShelfExpanded,
@@ -784,6 +817,8 @@
         );
       }
       const thread = item.item.thread;
+      const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
+      const movedId = `${thread.environmentId}:${thread.id}`;
       return (
         <ThreadListV2Row
           thread={thread}
@@ -830,18 +865,19 @@
           onSettleThread={handleSettleThread}
           snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
           pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
-          pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
-          canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0}
+          reorderSupported={
-          canMovePinnedDown={(() => {
+            item.item.pinned
+              ? pinReorderEnvironmentIds.has(thread.environmentId)
+              : activeReorderEnvironmentIds.has(thread.environmentId)
+          }
+          canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
+          canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
-            const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
-            return index !== -1 && index < arrangedPinnedKeys.length - 1;
-          })()}
           onSnoozeThread={handleSnoozeThread}
           onUnsnoozeThread={handleUnsnoozeThread}
           onUnsettleThread={handleUnsettleThread}
           onPinThread={handlePinThread}
           onUnpinThread={handleUnpinThread}
-          onMovePinnedThread={handleMovePinnedThread}
+          onMoveThread={handleMoveThread}
           onSwipeableClose={handleSwipeableClose}
           onSwipeableWillOpen={handleSwipeableWillOpen}
         />
@@ -849,8 +885,10 @@
     },
     [
       handleDeleteThread,
+      activeReorderEnvironmentIds,
+      threadMovePlanners,
+      pendingOrder,
+      handleMoveThread,
-      arrangedPinnedKeys,
-      handleMovePinnedThread,
       handlePinThread,
       handleRegenerateThreadTitle,
       handleSettleThread,
```

## apps/mobile/src/features/home/useThreadListActions.ts

Status: modified; +61; −47

```diff
@@ -8,15 +8,14 @@
 import { showConfirmDialog } from "../../components/ConfirmDialogHost";
 import { scopedThreadKey } from "../../lib/scopedEntities";
 import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
-import {
-  pinOrderKeyBetween,
-  planPinnedMove,
+import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
-  sortPinnedThreadsByOrderKey,
-} from "@t3tools/client-runtime/state/thread-sort";
 import { appAtomRegistry } from "../../state/atom-registry";
 import { environmentServerConfigsAtom } from "../../state/server";
 import { environmentThreadShells, threadEnvironment } from "../../state/threads";
 import { useAtomCommand } from "../../state/use-atom-command";
+import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
+import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
+import { getThreadListV2OrderedSection } from "../threads/threadListV2";
 
 /** Version skew: never send settle/unsettle to a server that predates them
     (capability defaults false on decode for older servers). */
@@ -222,7 +221,7 @@
   readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
   readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
-  readonly movePinnedThread: (
+  readonly moveThread: (
     thread: EnvironmentThreadShell,
     direction: "up" | "down",
   ) => Promise<boolean>;
@@ -451,60 +450,75 @@
     [updateThreadMetadata],
   );
 
-  // Move up / Move down for the pinned block. Computed against the CANONICAL
+  // Plan against the complete section so filtering does not change a move.
-  // keyed pinned order (not the rendered list), so the move is valid even
-  // while search or a project scope filters rows: the same fractional-key
-  // scheme web dragging uses, one write to one thread per move (plus a
-  // one-time section materialization when legacy keyless pins are involved).
   const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
     reportFailure: false,
   });
+  const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, {
+    reportFailure: false,
+  });
+  const moveThread = useCallback(
-  // One move at a time: a second tap before the first write's event lands
-  // would plan from the same stale snapshot and silently collapse two moves
-  // into one — same double-dispatch guard as snoozeThread.
-  const movePinnedInFlightRef = useRef(false);
-  const movePinnedThread = useCallback(
     async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
-      if (movePinnedInFlightRef.current) return false;
+      if (getPendingThreadOrder() !== null) return false;
+      const section = thread.pinnedAt != null ? "pinned" : "active";
+      const configs = appAtomRegistry.get(environmentServerConfigsAtom);
+      const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
+        const capabilities = configs.get(environmentId)?.environment.capabilities;
+        return section === "pinned"
-      if (!environmentSupportsPinReorder(thread.environmentId)) {
+          ? capabilities?.threadPinReorder === true
+          : capabilities?.threadActiveReorder === true;
+      };
+      if (!supportsReorder(thread.environmentId)) {
         Alert.alert(
           "Could not move thread",
-          "This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
+          "This environment's server does not support reordering these threads. Update the server to arrange them.",
         );
         return false;
       }
       const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
+      const ordered = getThreadListV2OrderedSection({
+        threads: shells,
+        section,
+        now: new Date().toISOString(),
+        settlementEnvironmentIds: new Set(
+          [...configs].flatMap(([id, config]) =>
+            config.environment.capabilities.threadSettlement === true ? [id] : [],
+          ),
-      const pinned = sortPinnedThreadsByOrderKey(
-        shells.filter(
-          (shell) =>
-            shell.pinnedAt != null &&
-            shell.archivedAt === null &&
-            environmentSupportsPinReorder(shell.environmentId),
         ),
-      );
-      const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));
-      const assignments = planPinnedMove({
-        orderedIds,
-        keysById: new Map(
-          pinned.map((shell) => [
-            scopedThreadKey(shell.environmentId, shell.id),
+        snoozeEnvironmentIds: new Set(
+          [...configs].flatMap(([id, config]) =>
+            config.environment.capabilities.threadSnooze === true ? [id] : [],
+          ),
-            shell.pinOrderKey ?? null,
-          ]),
         ),
-        movedId: scopedThreadKey(thread.environmentId, thread.id),
-        direction,
       });
+      const assignments = createThreadMovePlanner({
+        allThreads: shells,
+        ordered,
+        section,
+        reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),
+      })(scopedThreadKey(thread.environmentId, thread.id), direction);
-      if (assignments === null || assignments.length === 0) return false;
+      if (assignments === null) return false;
       const shellByKey = new Map(
-        pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
+        ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
       );
       selectionHaptic();
+      const pending = beginPendingThreadOrder(
-      movePinnedInFlightRef.current = true;
+        createPendingThreadOrder({
+          section,
+          ordered,
+          movedId: scopedThreadKey(thread.environmentId, thread.id),
+          direction,
+          assignments,
+        }),
+      );
+      let succeeded = false;
+      const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation;
       try {
         for (const assignment of assignments) {
+          if (!pending.isPending()) return false;
           const target = shellByKey.get(assignment.id);
           if (target === undefined) continue;
-          const result = await reorderPinnedMutation({
+          const result = await reorder({
             environmentId: target.environmentId,
             input: { threadId: target.id, orderKey: assignment.orderKey },
           });
@@ -514,20 +528,20 @@
               "Could not move thread",
               error instanceof Error && error.message.trim().length > 0
                 ? error.message
+                : "The thread could not be moved.",
-                : "The pinned thread could not be moved.",
             );
-            // No rollback: keys already written are valid orderings on their
-            // own (each write is a complete, consistent placement), so a
+            // Keep confirmed keys when a later environment rejects its write.
-            // partial materialization leaves the list sensible, not corrupt.
             return false;
           }
         }
+        succeeded = true;
+        pending.complete();
         return true;
       } finally {
-        movePinnedInFlightRef.current = false;
+        if (!succeeded) pending.cancel();
       }
     },
-    [reorderPinnedMutation],
+    [reorderActiveMutation, reorderPinnedMutation],
   );
 
   const confirmDeleteThread = useConfirmDeleteThread(executeAction);
@@ -541,7 +555,7 @@
     unsettleThread,
     pinThread,
     unpinThread,
+    moveThread,
-    movePinnedThread,
     regenerateThreadTitle,
   };
 }
```

## apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx

Status: modified; +64; −25

```diff
@@ -1,3 +1,4 @@
+import { createThreadMovePlanner } from "./threadOrder";
 import type {
   EnvironmentProject,
   EnvironmentThreadShell,
@@ -10,7 +11,6 @@
 import type { MenuAction } from "@react-native-menu/menu";
 import { useAtomValue } from "@effect/atom-react";
 import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
-import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
 import type { LayoutChangeEvent } from "react-native";
 import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
@@ -30,6 +30,7 @@
 import { useThreadSearch } from "../../state/queries";
 import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
 import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
+import { usePendingThreadOrder } from "../../state/thread-order";
 import { environmentServerConfigsAtom } from "../../state/server";
 import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
 import { useWorkspaceState } from "../../state/workspace";
@@ -78,6 +79,7 @@
 } from "./thread-list-v2-items";
 import {
   buildThreadListV2Items,
+  getThreadListV2OrderedSection,
   buildThreadListV2ListItems,
   THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
   THREAD_LIST_V2_SETTLED_PAGE_COUNT,
@@ -158,7 +160,7 @@
     unsettleThread,
     pinThread,
     unpinThread,
+    moveThread,
-    movePinnedThread,
     regenerateThreadTitle,
   } = useThreadListActions();
   const threadListV2Enabled = useThreadListV2Enabled();
@@ -437,6 +439,15 @@
     }
     return supported;
   }, [serverConfigs]);
+  const activeReorderEnvironmentIds = useMemo(() => {
+    const supported = new Set<EnvironmentId>();
+    for (const [environmentId, config] of serverConfigs) {
+      if (config.environment.capabilities.threadActiveReorder === true) {
+        supported.add(environmentId);
+      }
+    }
+    return supported;
+  }, [serverConfigs]);
   const titleRegenerationEnvironmentIds = useMemo(() => {
     const supported = new Set<EnvironmentId>();
     for (const [environmentId, config] of serverConfigs) {
@@ -456,19 +467,40 @@
       ),
     [serverConfigs],
   );
+  const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
+  const threadMovePlanners = useMemo(() => {
+    const sectionPlanner = (section: "pinned" | "active") =>
+      createThreadMovePlanner({
+        allThreads: threads,
+        section,
+        reorderableEnvironmentIds: new Set(
+          [...serverConfigs].flatMap(([id, config]) =>
+            (section === "pinned"
+              ? config.environment.capabilities.threadPinReorder
+              : config.environment.capabilities.threadActiveReorder) === true
+              ? [id]
+              : [],
+          ),
+        ),
+        ordered: getThreadListV2OrderedSection({
+          threads,
+          section,
+          pendingOrder,
+          now: new Date().toISOString(),
+          settlementEnvironmentIds,
+          snoozeEnvironmentIds,
+        }),
+      });
+    return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+  }, [
+    serverConfigs,
+    threads,
+    pendingOrder,
+    settlementEnvironmentIds,
-  // Canonical arranged pinned order for Move up/down flags — computed from
-  // all shells so search/scope filtering never disables a valid move.
-  const arrangedPinnedKeys = useMemo(() => {
-    const pinned = sortPinnedThreadsByOrderKey(
-      threads.filter(
-        (thread) =>
-          thread.pinnedAt != null &&
-          thread.archivedAt === null &&
-          pinReorderEnvironmentIds.has(thread.environmentId),
-      ),
-    );
-    return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
+    snoozeEnvironmentIds,
-  }, [pinReorderEnvironmentIds, threads]);
+    nowMinute,
+    snoozeWakeTick,
+  ]);
   const threadListV2Layout = useMemo(() => {
     if (!threadListV2Enabled)
       return {
@@ -481,6 +513,7 @@
         nextSnoozeWakeAt: null,
       };
     return buildThreadListV2Items({
+      pendingOrder,
       threads: threads.filter((thread) => thread.archivedAt === null),
       environmentId: options.selectedEnvironmentId,
       projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
@@ -495,6 +528,7 @@
       selectedThreadKey: props.selectedThreadKey ?? null,
     });
   }, [
+    pendingOrder,
     nowMinute,
     snoozeWakeTick,
     snoozedShelfExpanded,
@@ -844,6 +878,10 @@
         }
         case "v2-thread": {
           const thread = item.item.thread;
+          const movePlanner = item.item.pinned
+            ? threadMovePlanners.pinned
+            : threadMovePlanners.active;
+          const movedId = `${thread.environmentId}:${thread.id}`;
           const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
           return (
             <ThreadListV2Row
@@ -891,20 +929,19 @@
               onSettleThread={settleThread}
               snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
               pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
+              reorderSupported={
-              pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
+                item.item.pinned
+                  ? pinReorderEnvironmentIds.has(thread.environmentId)
+                  : activeReorderEnvironmentIds.has(thread.environmentId)
-              canMovePinnedUp={
-                arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0
               }
-              canMovePinnedDown={(() => {
-                const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
-                return index !== -1 && index < arrangedPinnedKeys.length - 1;
+              canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
+              canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
-              })()}
               onSnoozeThread={snoozeThread}
               onUnsnoozeThread={unsnoozeThread}
               onUnsettleThread={unsettleThread}
               onPinThread={pinThread}
               onUnpinThread={unpinThread}
-              onMovePinnedThread={movePinnedThread}
+              onMoveThread={moveThread}
               onSwipeableClose={handleSwipeableClose}
               onSwipeableWillOpen={handleSwipeableWillOpen}
               simultaneousSwipeGesture={sidebarScrollGesture}
@@ -1027,14 +1064,16 @@
     },
     [
       archiveThread,
+      activeReorderEnvironmentIds,
+      threadMovePlanners,
+      pendingOrder,
-      arrangedPinnedKeys,
       confirmDeletePendingTask,
       confirmDeleteThread,
       handleSelectThread,
       handleSwipeableClose,
       handleSwipeableWillOpen,
       machineByEnvironmentId,
-      movePinnedThread,
+      moveThread,
       openPendingTask,
       pinReorderEnvironmentIds,
       pinThread,
```

## apps/mobile/src/features/threads/thread-list-v2-items.tsx

Status: modified; +51; −52

```diff
@@ -367,14 +367,13 @@
   readonly pinningSupported: boolean;
   /** False on servers that predate thread title regeneration. */
   readonly titleRegenerationSupported: boolean;
+  /** Server supports reordering this card's section. */
+  readonly reorderSupported?: boolean;
+  readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
+  /** Position flags for the card's section so the menu disables the move that
-  /** False on servers that predate thread.pin.reorder. Gates the pinned
-      Move up / Move down menu items. */
-  readonly pinReorderSupported?: boolean;
-  readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
-  /** Position flags for the pinned block so the menu disables the move that
       would fall off the end of the list. */
-  readonly canMovePinnedUp?: boolean;
+  readonly canMoveUp?: boolean;
+  readonly canMoveDown?: boolean;
-  readonly canMovePinnedDown?: boolean;
   readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
   readonly onSwipeableClose: (methods: SwipeableMethods) => void;
   readonly searchMatch?: EnvironmentThreadSearchMatch;
@@ -397,7 +396,7 @@
     onArchiveThread,
     onPinThread,
     onUnpinThread,
-    onMovePinnedThread,
+    onMoveThread,
   } = props;
   const snoozedRow = props.snoozed === true;
   const pinnedRow = props.pinned === true;
@@ -436,14 +435,8 @@
   const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
   const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
   const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
+  const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
+  const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
-  const handleMovePinnedUp = useCallback(
-    () => onMovePinnedThread?.(thread, "up"),
-    [onMovePinnedThread, thread],
-  );
-  const handleMovePinnedDown = useCallback(
-    () => onMovePinnedThread?.(thread, "down"),
-    [onMovePinnedThread, thread],
-  );
   const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
 
   // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
@@ -482,38 +475,39 @@
   // Pinned cards keep the full lifecycle menu; only the pin item flips to
   // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
   // hides the card until wake with the pin intact.)
-  const pinMenuItem = useMemo<MenuAction[]>(
-    () =>
+  const arrangementMenuItems = useMemo<MenuAction[]>(
+    () => [
+      ...(variant === "card" && props.reorderSupported === true
-      props.pinningSupported
         ? [
+            {
+              id: "move-up",
-            ...(pinnedRow && props.pinReorderSupported === true
-              ? [
-                  {
-                    id: "move-pin-up",
+              title: "Move up",
-                    title: "Move up",
+              image: "arrow.up",
+              attributes: { disabled: props.canMoveUp !== true },
+            } satisfies MenuAction,
+            {
+              id: "move-down",
+              title: "Move down",
+              image: "arrow.down",
+              attributes: { disabled: props.canMoveDown !== true },
+            } satisfies MenuAction,
+          ]
+        : []),
+      ...(props.pinningSupported
+        ? [
-                    image: "arrow.up",
-                    attributes: { disabled: props.canMovePinnedUp !== true },
-                  } satisfies MenuAction,
-                  {
-                    id: "move-pin-down",
-                    title: "Move down",
-                    image: "arrow.down",
-                    attributes: { disabled: props.canMovePinnedDown !== true },
-                  } satisfies MenuAction,
-                ]
-              : []),
             thread.pinnedAt != null
               ? { id: "unpin", title: "Unpin", image: "pin.slash" }
               : { id: "pin", title: "Pin", image: "pin" },
           ]
+        : []),
-        : [],
+    ],
     [
+      props.canMoveDown,
+      props.canMoveUp,
+      props.reorderSupported,
-      pinnedRow,
-      props.canMovePinnedDown,
-      props.canMovePinnedUp,
-      props.pinReorderSupported,
       props.pinningSupported,
       thread.pinnedAt,
+      variant,
     ],
   );
   const titleRegenerationMenuItems = useMemo<MenuAction[]>(
@@ -533,37 +527,42 @@
         image: "clock",
         subactions: snoozePresetActions,
       },
-      ...pinMenuItem,
+      ...arrangementMenuItems,
       ...titleRegenerationMenuItems,
       { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
     ],
+    [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],
-    [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],
   );
   const cardMenuActions = useMemo<MenuAction[]>(
     () => [
       CARD_MENU_ACTIONS[0]!,
-      ...pinMenuItem,
+      ...arrangementMenuItems,
       ...titleRegenerationMenuItems,
       ...CARD_MENU_ACTIONS.slice(1),
     ],
-    [pinMenuItem, titleRegenerationMenuItems],
+    [arrangementMenuItems, titleRegenerationMenuItems],
   );
   const slimMenuActions = useMemo<MenuAction[]>(
     () => [
       SLIM_MENU_ACTIONS[0]!,
-      ...(thread.pinnedAt != null ? pinMenuItem : []),
+      ...(thread.pinnedAt != null ? arrangementMenuItems : []),
       ...titleRegenerationMenuItems,
       SLIM_MENU_ACTIONS[1]!,
     ],
+    [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems],
-    [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
   );
   const snoozedMenuActions = useMemo<MenuAction[]>(
     () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
     [titleRegenerationMenuItems],
   );
   const legacyMenuActions = useMemo<MenuAction[]>(
+    () => [
+      LEGACY_MENU_ACTIONS[0]!,
+      ...arrangementMenuItems,
+      ...titleRegenerationMenuItems,
+      LEGACY_MENU_ACTIONS[1]!,
-    () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!],
+    ],
-    [titleRegenerationMenuItems],
+    [arrangementMenuItems, titleRegenerationMenuItems],
   );
   const handleMenuAction = useCallback(
     ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
@@ -572,8 +571,8 @@
       if (nativeEvent.event === "unsnooze") handleUnsnooze();
       if (nativeEvent.event === "pin") handlePin();
       if (nativeEvent.event === "unpin") handleUnpin();
-      if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
-      if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
+      if (nativeEvent.event === "move-up") handleMoveUp();
+      if (nativeEvent.event === "move-down") handleMoveDown();
       if (nativeEvent.event === "archive") handleArchive();
       if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
       if (nativeEvent.event === "delete") handleDelete();
@@ -592,8 +591,8 @@
       handleArchive,
       handleDelete,
       handleRegenerateTitle,
-      handleMovePinnedDown,
+      handleMoveDown,
+      handleMoveUp,
-      handleMovePinnedUp,
       handlePin,
       handleSettle,
       handleSnooze,
```

## apps/mobile/src/features/threads/threadListV2.test.ts

Status: modified; +293; −0

```diff
@@ -1,3 +1,10 @@
+import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
+import {
+  createPendingThreadOrder,
+  createThreadMovePlanner,
+  reconcilePendingThreadOrder,
+  type PendingThreadOrder,
+} from "./threadOrder";
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
 import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
 import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
@@ -16,6 +23,7 @@
 import {
   buildThreadListV2Items,
   buildThreadListV2ListItems,
+  getThreadListV2OrderedSection,
   resolveThreadListV2Enabled,
   resolveThreadListV2SnoozeMenuSelection,
   resolveThreadListV2SnoozeGateExpiryMs,
@@ -258,6 +266,15 @@
 });
 
 describe("sortThreadsForListV2", () => {
+  it("honors a saved active order and leaves new threads above it", () => {
+    const sorted = sortThreadsForListV2([
+      { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" },
+      { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" },
+      { id: "new", createdAt: "2026-06-01T13:00:00.000Z" },
+    ]);
+    expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]);
+  });
+
   it("orders by creation time, newest first, ignoring activity", () => {
     const sorted = sortThreadsForListV2([
       { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
@@ -281,6 +298,55 @@
   });
 });
 
+describe("getThreadListV2OrderedSection", () => {
+  it("uses each saved order and excludes settled, snoozed, and archived rows", () => {
+    const threads = [
+      makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }),
+      makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }),
+      makeThread({ id: ThreadId.make("active-new"), title: "New" }),
+      makeThread({
+        id: ThreadId.make("pinned-later"),
+        title: "Pinned later",
+        pinnedAt: NOW,
+        pinOrderKey: "t",
+        activeOrderKey: "f",
+      }),
+      makeThread({
+        id: ThreadId.make("pinned-first"),
+        title: "Pinned first",
+        pinnedAt: NOW,
+        pinOrderKey: "f",
+        activeOrderKey: "t",
+      }),
+      makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
+      makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }),
+      makeThread({
+        id: ThreadId.make("snoozed"),
+        title: "Snoozed",
+        snoozedUntil: "2026-06-03T10:00:00.000Z",
+        snoozedAt: NOW,
+      }),
+      makeThread({
+        id: ThreadId.make("pinned-snoozed"),
+        title: "Pinned snoozed",
+        pinnedAt: NOW,
+        snoozedUntil: "2026-06-03T10:00:00.000Z",
+        snoozedAt: NOW,
+      }),
+    ];
+    expect(
+      getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
+        (thread) => thread.id,
+      ),
+    ).toEqual(["active-new", "active-first", "active-later"]);
+    expect(
+      getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map(
+        (thread) => thread.id,
+      ),
+    ).toEqual(["pinned-first", "pinned-later"]);
+  });
+});
+
 describe("buildThreadListV2Items", () => {
   it("places a persisted settled thread in the settled shelf", () => {
     const thread = makeThread({
@@ -937,3 +1003,230 @@
     ]);
   });
 });
+
+describe("pending mobile thread moves", () => {
+  function fixture(section: "active" | "pinned" = "active") {
+    const rows = ["a", "b", "c"].map((id, index) =>
+      makeThread({
+        id: ThreadId.make(id),
+        title: id === "a" ? "hidden" : "match",
+        createdAt: `2026-06-01T0${3 - index}:00:00.000Z`,
+        pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null,
+      }),
+    );
+    const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW });
+    const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`);
+    const movedId = orderedIds[2]!;
+    const assignments = planPinnedMove({
+      orderedIds,
+      keysById: new Map(orderedIds.map((id) => [id, null])),
+      movedId,
+      direction: "up",
+    })!;
+    const pending = createPendingThreadOrder({
+      section,
+      ordered,
+      movedId,
+      direction: "up",
+      assignments,
+    });
+    const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) =>
+      current.map((row) =>
+        `${row.environmentId}:${row.id}` === assignment.id
+          ? {
+              ...row,
+              [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey,
+            }
+          : row,
+      );
+    return { rows, assignments, pending, update };
+  }
+
+  function layout(
+    rows: EnvironmentThreadShell[],
+    pendingOrder: PendingThreadOrder | null,
+    searchQuery = "",
+  ) {
+    return buildThreadListV2Items({
+      threads: rows,
+      pendingOrder,
+      environmentId: null,
+      searchQuery,
+      now: NOW,
+    }).items.map((item) => item.thread.id);
+  }
+
+  it.each(["active", "pinned"] as const)(
+    "holds %s order through every intermediate key upsert",
+    (section) => {
+      const { rows, assignments, pending, update } = fixture(section);
+      let current = rows;
+      let hold: PendingThreadOrder | null = pending;
+      const desired = pending.orderedIds.map((id) => id.split(":")[1]);
+      expect(layout(current, hold)).toEqual(desired);
+      for (const assignment of assignments) {
+        current = update(current, assignment);
+        hold = reconcilePendingThreadOrder(
+          hold!,
+          getThreadListV2OrderedSection({ threads: current, section, now: NOW }),
+        );
+        expect(hold).not.toBeNull();
+        expect(layout(current, hold)).toEqual(desired);
+      }
+      expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull();
+      expect(layout(current, null)).toEqual(desired);
+    },
+  );
+
+  it("keeps the action guard pending when receipts precede canonical shells", () => {
+    const { rows, assignments, pending, update } = fixture();
+    let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true };
+    let current = rows;
+    expect(reconcilePendingThreadOrder(hold, current)).toBe(hold);
+    for (const [index, assignment] of assignments.entries()) {
+      current = update(current, assignment);
+      hold = reconcilePendingThreadOrder(hold!, current);
+      expect(hold === null).toBe(index === assignments.length - 1);
+      expect(layout(current, hold)).toEqual(["a", "c", "b"]);
+    }
+  });
+
+  it("keeps search results in the full pending section order", () => {
+    const { rows, assignments, pending, update } = fixture();
+    const current = update(update(rows, assignments[0]!), assignments[1]!);
+    expect(layout(current, pending, "match")).toEqual(["c", "b"]);
+  });
+
+  it("releases for real section membership and foreign key changes", () => {
+    const { rows, pending } = fixture();
+    expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull();
+    const newRow = makeThread({ id: ThreadId.make("new"), title: "new" });
+    expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull();
+    expect(
+      reconcilePendingThreadOrder(
+        pending,
+        rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)),
+      ),
+    ).toBeNull();
+    const settled = rows.map((row, index) =>
+      index === 0 ? { ...row, settledOverride: "settled" as const } : row,
+    );
+    expect(layout(settled, pending)).toEqual(layout(settled, null));
+  });
+
+  it("does not hide a concurrent return to a previously confirmed key", () => {
+    const { rows, assignments, pending, update } = fixture();
+    const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!;
+    expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull();
+  });
+
+  it("preserves the hold for activity but releases for a reopened sort anchor", () => {
+    const { rows, pending } = fixture();
+    expect(
+      reconcilePendingThreadOrder(
+        pending,
+        rows.map((row) => ({ ...row, updatedAt: NOW })),
+      ),
+    ).toBe(pending);
+    expect(
+      reconcilePendingThreadOrder(
+        pending,
+        rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)),
+      ),
+    ).toBeNull();
+  });
+});
+
+describe("mobile move availability", () => {
+  const oldEnvironment = EnvironmentId.make("older-server");
+  function rows(section: "active" | "pinned", keys: readonly (string | null)[]) {
+    return keys.map((key, index) =>
+      makeThread({
+        id: ThreadId.make(`move-${index}`),
+        title: `Move ${index}`,
+        environmentId: index === 1 ? oldEnvironment : environmentId,
+        activeOrderKey: section === "active" ? key : null,
+        pinOrderKey: section === "pinned" ? key : null,
+        pinnedAt: section === "pinned" ? NOW : null,
+      }),
+    );
+  }
+
+  it.each(["active", "pinned"] as const)(
+    "keeps unsupported keyed %s neighbors as usable anchors",
+    (section) => {
+      const ordered = rows(section, ["bb", "dd", "ff"]);
+      const plan = createThreadMovePlanner({
+        ordered,
+        section,
+        reorderableEnvironmentIds: new Set([environmentId]),
+      });
+      const assignments = plan(`${environmentId}:move-0`, "down");
+      expect(assignments).toHaveLength(1);
+      expect(assignments![0]!.id).toBe(`${environmentId}:move-0`);
+      expect(assignments![0]!.orderKey > "dd").toBe(true);
+      expect(assignments![0]!.orderKey < "ff").toBe(true);
+      expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull();
+      expect(plan(`${environmentId}:move-0`, "up")).toBeNull();
+    },
+  );
+
+  it.each(["active", "pinned"] as const)(
+    "disables %s moves requiring unsupported keyless materialization",
+    (section) => {
+      const ordered = rows(section, [null, null, null]);
+      const plan = createThreadMovePlanner({
+        ordered,
+        section,
+        reorderableEnvironmentIds: new Set([environmentId]),
+      });
+      expect(plan(`${environmentId}:move-0`, "down")).toBeNull();
+      expect(plan(`${environmentId}:move-2`, "up")).toBeNull();
+      const supported = createThreadMovePlanner({
+        ordered,
+        section,
+        reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]),
+      });
+      expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3);
+    },
+  );
+
+  it.each(["active", "pinned"] as const)(
+    "reserves snoozed %s keys when moving visible rows",
+    (section) => {
+      const ordered = rows(section, ["bb", "dd", "ff"]);
+      const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) };
+      const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]!
+        .orderKey;
+      const hidden = {
+        ...ordered[0]!,
+        id: ThreadId.make("snoozed"),
+        snoozedAt: NOW,
+        snoozedUntil: "2099-01-01T00:00:00.000Z",
+        pinOrderKey: section === "pinned" ? collision : null,
+        activeOrderKey: section === "active" ? collision : null,
+      };
+      const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })(
+        `${environmentId}:move-0`,
+        "down",
+      );
+      expect(assignments).toHaveLength(1);
+      expect(assignments![0]!.orderKey).not.toBe(collision);
+      expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true);
+    },
+  );
+
+  it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => {
+    const ordered = rows("active", [null, null, "bb", "dd", "ff"]);
+    const plan = createThreadMovePlanner({
+      ordered,
+      section: "active",
+      reorderableEnvironmentIds: new Set([environmentId]),
+    });
+    const assignments = plan(`${environmentId}:move-4`, "up");
+    expect(assignments).toHaveLength(1);
+    expect(assignments![0]!.id).toBe(`${environmentId}:move-4`);
+    expect(assignments![0]!.orderKey > "bb").toBe(true);
+    expect(assignments![0]!.orderKey < "dd").toBe(true);
+  });
+});
```

## apps/mobile/src/features/threads/threadListV2.ts

Status: modified; +67; −19

```diff
@@ -9,7 +9,7 @@
 import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
 import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
 import {
-  activeThreadAnchorTimestampMs,
+  sortActiveThreadsByOrderKey,
   resolveSettledThreadTimestamp,
   sortPinnedThreadsByOrderKey,
 } from "@t3tools/client-runtime/state/thread-sort";
@@ -17,6 +17,12 @@
 
 import type { PendingNewTask } from "../../state/use-pending-new-tasks";
 
+import {
+  applyPendingThreadOrder,
+  reconcilePendingThreadOrder,
+  type PendingThreadOrder,
+} from "./threadOrder";
+
 export { snoozeWakeLabel };
 
 /**
@@ -150,30 +156,56 @@
   return Number.isNaN(parsed) ? 0 : parsed;
 }
 
-/**
- * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
- * list — a row holds its position between lifecycle transitions. The anchor
- * is creation time until an un-settle re-anchors it (see
- * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
- * top instead of sinking back to its creation-order slot. Mirrors web's
+/** The active order shared by web and native: new/reopened rows, then the
+    saved arrangement. Activity does not move a thread. */
- * sortThreadsForSidebar.
- */
 export function sortThreadsForListV2<
   T extends {
     readonly id: string;
     readonly createdAt: string;
     readonly unsettledAt?: string | null | undefined;
+    readonly activeOrderKey?: string | null | undefined;
+    readonly environmentId?: string | undefined;
   },
 >(threads: readonly T[]): T[] {
-  // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
+  return sortActiveThreadsByOrderKey(threads);
-  // change-by-copy array methods.
-  return [...threads].sort(
-    (left, right) =>
-      activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
-      left.id.localeCompare(right.id),
-  );
 }
 
+/** Canonical card section for Move up/down, independent of search or scope. */
+export function getThreadListV2OrderedSection(input: {
+  readonly threads: readonly EnvironmentThreadShell[];
+  readonly section: "pinned" | "active";
+  readonly pendingOrder?: PendingThreadOrder | null;
+  readonly now: string;
+  readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
+  readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
+}): EnvironmentThreadShell[] {
+  const threads = input.threads.filter((thread) => {
+    if (thread.archivedAt !== null) return false;
+    if (
+      (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
+      thread.settledOverride === "settled"
+    ) {
+      return false;
+    }
+    if (
+      (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
+      effectiveSnoozed(thread, { now: input.now })
+    ) {
+      return false;
+    }
+    return (thread.pinnedAt != null) === (input.section === "pinned");
+  });
+  const ordered =
+    input.section === "pinned"
+      ? sortPinnedThreadsByOrderKey(threads)
+      : sortActiveThreadsByOrderKey(threads);
+  const pending =
+    input.pendingOrder?.section === input.section
+      ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
+      : null;
+  return applyPendingThreadOrder(ordered, input.section, pending);
+}
+
 export interface ThreadListV2Item {
   readonly thread: EnvironmentThreadShell;
   readonly variant: "card" | "slim";
@@ -299,10 +331,11 @@
 }
 
 /**
+ * Partitions visible threads into the active card block (saved order) and
- * Partitions visible threads into the active card block (creation order) and
  * the settled recency tail, matching the web v2 list.
  */
 export function buildThreadListV2Items(input: {
+  readonly pendingOrder?: PendingThreadOrder | null;
   readonly threads: ReadonlyArray<EnvironmentThreadShell>;
   readonly environmentId: EnvironmentId | null;
   readonly projectRefs?: ReadonlyArray<{
@@ -331,6 +364,17 @@
   readonly selectedThreadKey?: string | null;
 }): ThreadListV2Layout {
   const now = input.now;
+  const pending =
+    input.pendingOrder == null
+      ? null
+      : reconcilePendingThreadOrder(
+          input.pendingOrder,
+          getThreadListV2OrderedSection({
+            ...input,
+            section: input.pendingOrder.section,
+            pendingOrder: null,
+          }),
+        );
   const query = input.searchQuery.trim().toLocaleLowerCase();
   const projectKeys = input.projectRefs
     ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
@@ -382,7 +426,7 @@
     }
   }
 
+  const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
-  const orderedActive = sortThreadsForListV2(active);
   const orderedSnoozed = [...snoozed].sort(
     (left, right) =>
       parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
@@ -414,7 +458,11 @@
         );
 
   const items: ThreadListV2Item[] = [];
-  for (const thread of sortPinnedThreadsByOrderKey(pinned)) {
+  for (const thread of applyPendingThreadOrder(
+    sortPinnedThreadsByOrderKey(pinned),
+    "pinned",
+    pending,
+  )) {
     items.push({
       thread,
       variant: "card",
```

## apps/mobile/src/features/threads/threadOrder.ts

Status: added; +120; −0

```diff
@@ -1,0 +1,120 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+type OrderRow = Pick<
+  EnvironmentThreadShell,
+  | "id"
+  | "environmentId"
+  | "pinOrderKey"
+  | "activeOrderKey"
+  | "createdAt"
+  | "unsettledAt"
+  | "pinnedAt"
+>;
+
+export interface PendingThreadOrder {
+  readonly section: "pinned" | "active";
+  readonly orderedIds: readonly string[];
+  readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;
+  readonly assignments: ReadonlyMap<string, string>;
+  readonly confirmed: ReadonlySet<string>;
+  readonly commandsComplete: boolean;
+}
+
+function rowId(row: OrderRow): string {
+  return `${row.environmentId}:${row.id}`;
+}
+
+function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
+  return {
+    key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
+    anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
+  };
+}
+
+/** Keep every visible row as an anchor, but only offer plans whose key writes
+ * are supported. Menu availability and execution use this same planner. */
+export function createThreadMovePlanner(input: {
+  readonly ordered: readonly OrderRow[];
+  readonly allThreads?: readonly OrderRow[];
+  readonly section: PendingThreadOrder["section"];
+  readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
+}) {
+  const orderedIds = input.ordered.map(rowId);
+  const keysById = new Map(
+    (input.allThreads ?? input.ordered).map((row) => [
+      rowId(row),
+      rowOrder(row, input.section).key,
+    ]),
+  );
+  const writableIds = new Set(
+    input.ordered
+      .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
+      .map(rowId),
+  );
+  return (movedId: string, direction: "up" | "down") => {
+    if (!writableIds.has(movedId)) return null;
+    const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction });
+    return assignments === null ||
+      assignments.length === 0 ||
+      assignments.some((assignment) => !writableIds.has(assignment.id))
+      ? null
+      : assignments;
+  };
+}
+
+export function createPendingThreadOrder(input: {
+  readonly section: PendingThreadOrder["section"];
+  readonly ordered: readonly OrderRow[];
+  readonly movedId: string;
+  readonly direction: "up" | "down";
+  readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
+}): PendingThreadOrder {
+  const orderedIds = input.ordered.map(rowId);
+  const from = orderedIds.indexOf(input.movedId);
+  orderedIds.splice(from, 1);
+  orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId);
+  return {
+    section: input.section,
+    orderedIds,
+    before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
+    assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
+    confirmed: new Set(),
+    commandsComplete: false,
+  };
+}
+
+/** Receipts and shell updates arrive independently. Only our own key writes
+ * may pass through the hold; membership and other arrangement changes win. */
+export function reconcilePendingThreadOrder(
+  pending: PendingThreadOrder,
+  ordered: readonly OrderRow[],
+): PendingThreadOrder | null {
+  if (ordered.length !== pending.before.size) return null;
+  const confirmed = new Set(pending.confirmed);
+  for (const row of ordered) {
+    const id = rowId(row);
+    const before = pending.before.get(id);
+    const current = rowOrder(row, pending.section);
+    if (before === undefined || current.anchor !== before.anchor) return null;
+    const assigned = pending.assignments.get(id);
+    if (assigned !== undefined && current.key === assigned) confirmed.add(id);
+    else if (current.key !== before.key || confirmed.has(id)) return null;
+  }
+  if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
+  return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
+}
+
+/** Apply the full section's pending order after search/environment filtering. */
+export function applyPendingThreadOrder<T extends OrderRow>(
+  rows: readonly T[],
+  section: PendingThreadOrder["section"],
+  pending: PendingThreadOrder | null | undefined,
+): T[] {
+  if (pending == null || pending.section !== section) return [...rows];
+  const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
+  return [...rows].sort(
+    (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
+  );
+}
```

## apps/mobile/src/state/thread-order.test.ts

Status: added; +117; −0

```diff
@@ -1,0 +1,117 @@
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { EnvironmentId, ThreadId } from "@t3tools/contracts";
+import type { Atom } from "effect/unstable/reactivity";
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { createPendingThreadOrder } from "../features/threads/threadOrder";
+import { appAtomRegistry } from "./atom-registry";
+import {
+  beginPendingThreadOrder,
+  getPendingThreadOrder,
+  pendingThreadOrderAtom,
+} from "./thread-order";
+import { environmentThreadShells } from "./threads";
+
+vi.mock("./atom-registry", async () => {
+  const { AtomRegistry } = await import("effect/unstable/reactivity");
+  return { appAtomRegistry: AtomRegistry.make() };
+});
+vi.mock("./threads", async () => {
+  const { Atom } = await import("effect/unstable/reactivity");
+  return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } };
+});
+vi.mock("./server", async () => {
+  const { Atom } = await import("effect/unstable/reactivity");
+  return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
+});
+
+// The mocked shell source is writable so tests can deliver canonical upserts.
+const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
+  readonly EnvironmentThreadShell[],
+  readonly EnvironmentThreadShell[]
+>;
+
+function fixture() {
+  // Only section membership and order fields are read by this coordinator.
+  const rows = ["a", "b"].map(
+    (id, index) =>
+      ({
+        id: ThreadId.make(id),
+        environmentId: EnvironmentId.make("env"),
+        createdAt: `2026-06-01T0${2 - index}:00:00.000Z`,
+        archivedAt: null,
+        pinnedAt: null,
+        activeOrderKey: null,
+      }) as EnvironmentThreadShell,
+  );
+  appAtomRegistry.set(shellsAtom, rows);
+  const pending = createPendingThreadOrder({
+    section: "active",
+    ordered: rows,
+    movedId: "env:b",
+    direction: "up",
+    assignments: [
+      { id: "env:b", orderKey: "aa" },
+      { id: "env:a", orderKey: "bb" },
+    ],
+  });
+  const start = () => beginPendingThreadOrder(pending);
+  const upsert = (id: string, key: string) => {
+    const current = appAtomRegistry.get(shellsAtom);
+    appAtomRegistry.set(
+      shellsAtom,
+      current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)),
+    );
+  };
+  return { rows, start, upsert };
+}
+
+afterEach(() => appAtomRegistry.reset());
+
+describe("shared mobile pending move", () => {
+  it("blocks another pickup after receipts and clears on final canonical upsert", () => {
+    const { start, upsert } = fixture();
+    const move = start();
+    move.complete();
+    expect(getPendingThreadOrder()).not.toBeNull();
+    upsert("b", "aa");
+    expect(getPendingThreadOrder()).not.toBeNull();
+    upsert("a", "bb");
+    expect(getPendingThreadOrder()).toBeNull();
+    expect(move.isPending()).toBe(false);
+  });
+
+  it("waits for receipts when shells arrive first", () => {
+    const { start, upsert } = fixture();
+    const move = start();
+    upsert("b", "aa");
+    upsert("a", "bb");
+    expect(getPendingThreadOrder()).not.toBeNull();
+    move.complete();
+    expect(getPendingThreadOrder()).toBeNull();
+  });
+
+  it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => {
+    const { start, upsert } = fixture();
+    const move = start();
+    upsert("b", "aa");
+    move.cancel();
+    expect(getPendingThreadOrder()).toBeNull();
+    expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa");
+    const next = start();
+    move.cancel();
+    expect(next.isPending()).toBe(true);
+    next.cancel();
+  });
+
+  it("stops remaining writes when a canonical membership change invalidates the move", () => {
+    const { rows, start } = fixture();
+    const move = start();
+    appAtomRegistry.set(shellsAtom, rows.slice(1));
+    expect(move.isPending()).toBe(false);
+    expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
+    move.complete();
+    appAtomRegistry.set(shellsAtom, rows);
+    expect(getPendingThreadOrder()).toBeNull();
+  });
+});
```

## apps/mobile/src/state/thread-order.ts

Status: added; +89; −0

```diff
@@ -1,0 +1,89 @@
+import { useAtomValue } from "@effect/atom-react";
+import { useEffect } from "react";
+import { Atom } from "effect/unstable/reactivity";
+
+import {
+  reconcilePendingThreadOrder,
+  type PendingThreadOrder,
+} from "../features/threads/threadOrder";
+import { getThreadListV2OrderedSection } from "../features/threads/threadListV2";
+import { appAtomRegistry } from "./atom-registry";
+import { environmentServerConfigsAtom } from "./server";
+import { environmentThreadShells } from "./threads";
+
+export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(
+  Atom.keepAlive,
+);
+
+export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) {
+  const pending = useAtomValue(pendingThreadOrderAtom);
+  // A timed wake can change section membership without a shell event. Use the
+  // lists' existing clocks to retire that hold and re-enable their move menus.
+  useEffect(() => {
+    getPendingThreadOrder();
+  }, [nowMinute, snoozeWakeTick]);
+  return pending;
+}
+
+let refreshPendingOrder: (() => void) | undefined;
+
+/** Shared by Home and the navigation sidebar, including their action guards. */
+export function getPendingThreadOrder(): PendingThreadOrder | null {
+  refreshPendingOrder?.();
+  return appAtomRegistry.get(pendingThreadOrderAtom);
+}
+
+export function beginPendingThreadOrder(pending: PendingThreadOrder) {
+  const unsubscribers: (() => void)[] = [];
+  const cancel = () => {
+    if (refreshPendingOrder !== refresh) return;
+    refreshPendingOrder = undefined;
+    for (const unsubscribe of unsubscribers) unsubscribe();
+    appAtomRegistry.set(pendingThreadOrderAtom, null);
+  };
+  const refresh = () => {
+    if (refreshPendingOrder !== refresh) return;
+    const current = appAtomRegistry.get(pendingThreadOrderAtom);
+    if (current === null) return;
+    const configs = appAtomRegistry.get(environmentServerConfigsAtom);
+    const ordered = getThreadListV2OrderedSection({
+      threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
+      section: current.section,
+      now: new Date().toISOString(),
+      settlementEnvironmentIds: new Set(
+        [...configs].flatMap(([id, config]) =>
+          config.environment.capabilities.threadSettlement === true ? [id] : [],
+        ),
+      ),
+      snoozeEnvironmentIds: new Set(
+        [...configs].flatMap(([id, config]) =>
+          config.environment.capabilities.threadSnooze === true ? [id] : [],
+        ),
+      ),
+    });
+    const next = reconcilePendingThreadOrder(current, ordered);
+    if (next === null) cancel();
+    else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next);
+  };
+  refreshPendingOrder = refresh;
+  appAtomRegistry.set(pendingThreadOrderAtom, pending);
+  unsubscribers.push(
+    appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
+    appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
+  );
+  return {
+    isPending: () => {
+      refresh();
+      return refreshPendingOrder === refresh;
+    },
+    complete: () => {
+      if (refreshPendingOrder !== refresh) return;
+      const current = appAtomRegistry.get(pendingThreadOrderAtom);
+      if (current !== null) {
+        appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true });
+        refresh();
+      }
+    },
+    cancel,
+  };
+}
```

## apps/mobile/src/state/use-thread-selection.ts

Status: modified; +4; −0

```diff
@@ -62,6 +62,10 @@
     archivedAt: thread.archivedAt,
     settledOverride: thread.settledOverride,
     settledAt: thread.settledAt,
+    unsettledAt: thread.unsettledAt,
+    activeOrderKey: thread.activeOrderKey,
+    pinnedAt: thread.pinnedAt,
+    pinOrderKey: thread.pinOrderKey,
     snoozedUntil: thread.snoozedUntil ?? null,
     snoozedAt: thread.snoozedAt ?? null,
     session: thread.session,
```
