authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-06 11:10:14-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-06 11:10:14-07:00
log6766e682ac1a0aa33020358ba98707f4f8fee467
tree81138b63689c5d63820faaba488fe4952b9bf762
parent2d645df474f0af5e261edb56a79f7a2a9a2d442e
signature Signed by PGP key B5690EEEBB952194

feat(mobile): arrange active threads from both thread lists (#9730)


11 files changed, 934 insertions(+), 173 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+2-2
......@@ -49,7 +49,7 @@ export function HomeRouteScreen() {
4949 unsnoozeThread,
5050 pinThread,
5151 unpinThread,
52 movePinnedThread,
52 moveThread,
5353 regenerateThreadTitle,
5454 unsettleThread,
5555 } = useThreadListActions();
......@@ -199,7 +199,7 @@ export function HomeRouteScreen() {
199199 onUnsettleThread={unsettleThread}
200200 onPinThread={pinThread}
201201 onUnpinThread={unpinThread}
202 onMovePinnedThread={movePinnedThread}
202 onMoveThread={moveThread}
203203 onRegenerateThreadTitle={regenerateThreadTitle}
204204 onEnvironmentChange={setSelectedEnvironmentId}
205205 onProjectChange={setSelectedProjectKey}
apps/mobile/src/features/home/HomeScreen.tsx+66-28
......@@ -1,3 +1,4 @@
1import { createThreadMovePlanner } from "../threads/threadOrder";
12import {
23 LegendList,
34 type LegendListRef,
......@@ -11,7 +12,6 @@ import {
1112 threadSearchMatchKey,
1213 type EnvironmentThreadSearchMatch,
1314} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1515import {
1616 type EnvironmentId,
1717 resolveEnvironmentMachineKind,
......@@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
3535import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
3636import { useThreadSearch } from "../../state/queries";
3737import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
38import { usePendingThreadOrder } from "../../state/thread-order";
3839import { environmentServerConfigsAtom } from "../../state/server";
3940import type { PendingNewTask } from "../../state/use-pending-new-tasks";
4041import {
......@@ -51,6 +52,7 @@ import {
5152} from "../threads/thread-list-v2-items";
5253import {
5354 buildThreadListV2Items,
55 getThreadListV2OrderedSection,
5456 buildThreadListV2ListItems,
5557 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
5658 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
......@@ -114,7 +116,7 @@ interface HomeScreenProps {
114116 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
115117 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
116118 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
117 readonly onMovePinnedThread: (
119 readonly onMoveThread: (
118120 thread: EnvironmentThreadShell,
119121 direction: "up" | "down",
120122 ) => Promise<boolean>;
......@@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) {
512514 },
513515 [props.onPinThread],
514516 );
515 const handleMovePinnedThread = useCallback(
517 const handleMoveThread = useCallback(
516518 (thread: EnvironmentThreadShell, direction: "up" | "down") => {
517 void props.onMovePinnedThread(thread, direction);
519 void props.onMoveThread(thread, direction);
518520 },
519 [props.onMovePinnedThread],
521 [props.onMoveThread],
520522 );
521523 const handleUnpinThread = useCallback(
522524 (thread: EnvironmentThreadShell) => {
......@@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) {
608610 }
609611 return supported;
610612 }, [serverConfigs]);
613 const activeReorderEnvironmentIds = useMemo(() => {
614 const supported = new Set<EnvironmentId>();
615 for (const [environmentId, config] of serverConfigs) {
616 if (config.environment.capabilities.threadActiveReorder === true) {
617 supported.add(environmentId);
618 }
619 }
620 return supported;
621 }, [serverConfigs]);
611622 const titleRegenerationEnvironmentIds = useMemo(() => {
612623 const supported = new Set<EnvironmentId>();
613624 for (const [environmentId, config] of serverConfigs) {
......@@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) {
627638 ),
628639 [serverConfigs],
629640 );
630 // Canonical arranged pinned order (reorder-capable threads only) for the
631 // Move up/down position flags. Computed from all shells, not the rendered
632 // list, so search/scope filtering never disables or misdirects a move.
633 const arrangedPinnedKeys = useMemo(() => {
634 const pinned = sortPinnedThreadsByOrderKey(
635 props.threads.filter(
636 (thread) =>
637 thread.pinnedAt != null &&
638 thread.archivedAt === null &&
639 pinReorderEnvironmentIds.has(thread.environmentId),
640 ),
641 );
642 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
643 }, [pinReorderEnvironmentIds, props.threads]);
641 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
642 const threadMovePlanners = useMemo(() => {
643 const sectionPlanner = (section: "pinned" | "active") =>
644 createThreadMovePlanner({
645 allThreads: props.threads,
646 section,
647 reorderableEnvironmentIds: new Set(
648 [...serverConfigs].flatMap(([id, config]) =>
649 (section === "pinned"
650 ? config.environment.capabilities.threadPinReorder
651 : config.environment.capabilities.threadActiveReorder) === true
652 ? [id]
653 : [],
654 ),
655 ),
656 ordered: getThreadListV2OrderedSection({
657 threads: props.threads,
658 section,
659 pendingOrder,
660 now: new Date().toISOString(),
661 settlementEnvironmentIds,
662 snoozeEnvironmentIds,
663 }),
664 });
665 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
666 }, [
667 serverConfigs,
668 props.threads,
669 pendingOrder,
670 settlementEnvironmentIds,
671 snoozeEnvironmentIds,
672 nowMinute,
673 snoozeWakeTick,
674 ]);
644675 const threadListV2Layout = useMemo(() => {
645676 if (!threadListV2Enabled)
646677 return {
......@@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {
655686 // Settled threads are live shells; archived threads keep their original
656687 // "hidden from lists" meaning.
657688 return buildThreadListV2Items({
689 pendingOrder,
658690 threads: props.threads.filter((thread) => thread.archivedAt === null),
659691 environmentId: props.selectedEnvironmentId,
660692 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
......@@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) {
669701 selectedThreadKey: null,
670702 });
671703 }, [
704 pendingOrder,
672705 nowMinute,
673706 snoozeWakeTick,
674707 snoozedShelfExpanded,
......@@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) {
784817 );
785818 }
786819 const thread = item.item.thread;
820 const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
821 const movedId = `${thread.environmentId}:${thread.id}`;
787822 return (
788823 <ThreadListV2Row
789824 thread={thread}
......@@ -830,18 +865,19 @@ export function HomeScreen(props: HomeScreenProps) {
830865 onSettleThread={handleSettleThread}
831866 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
832867 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
833 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
834 canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0}
835 canMovePinnedDown={(() => {
836 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
837 return index !== -1 && index < arrangedPinnedKeys.length - 1;
838 })()}
868 reorderSupported={
869 item.item.pinned
870 ? pinReorderEnvironmentIds.has(thread.environmentId)
871 : activeReorderEnvironmentIds.has(thread.environmentId)
872 }
873 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
874 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
839875 onSnoozeThread={handleSnoozeThread}
840876 onUnsnoozeThread={handleUnsnoozeThread}
841877 onUnsettleThread={handleUnsettleThread}
842878 onPinThread={handlePinThread}
843879 onUnpinThread={handleUnpinThread}
844 onMovePinnedThread={handleMovePinnedThread}
880 onMoveThread={handleMoveThread}
845881 onSwipeableClose={handleSwipeableClose}
846882 onSwipeableWillOpen={handleSwipeableWillOpen}
847883 />
......@@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) {
849885 },
850886 [
851887 handleDeleteThread,
852 arrangedPinnedKeys,
853 handleMovePinnedThread,
888 activeReorderEnvironmentIds,
889 threadMovePlanners,
890 pendingOrder,
891 handleMoveThread,
854892 handlePinThread,
855893 handleRegenerateThreadTitle,
856894 handleSettleThread,
apps/mobile/src/features/home/useThreadListActions.ts+61-47
......@@ -8,15 +8,14 @@ import { Alert } from "react-native";
88import { showConfirmDialog } from "../../components/ConfirmDialogHost";
99import { scopedThreadKey } from "../../lib/scopedEntities";
1010import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
11import {
12 pinOrderKeyBetween,
13 planPinnedMove,
14 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";
11import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
1612import { appAtomRegistry } from "../../state/atom-registry";
1713import { environmentServerConfigsAtom } from "../../state/server";
1814import { environmentThreadShells, threadEnvironment } from "../../state/threads";
1915import { useAtomCommand } from "../../state/use-atom-command";
16import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order";
17import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder";
18import { getThreadListV2OrderedSection } from "../threads/threadListV2";
2019
2120/** Version skew: never send settle/unsettle to a server that predates them
2221 (capability defaults false on decode for older servers). */
......@@ -222,7 +221,7 @@ export function useThreadListActions(): {
222221 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
223222 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
224223 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
225 readonly movePinnedThread: (
224 readonly moveThread: (
226225 thread: EnvironmentThreadShell,
227226 direction: "up" | "down",
228227 ) => Promise<boolean>;
......@@ -451,60 +450,75 @@ export function useThreadListActions(): {
451450 [updateThreadMetadata],
452451 );
453452
454 // Move up / Move down for the pinned block. Computed against the CANONICAL
455 // keyed pinned order (not the rendered list), so the move is valid even
456 // while search or a project scope filters rows: the same fractional-key
457 // scheme web dragging uses, one write to one thread per move (plus a
458 // one-time section materialization when legacy keyless pins are involved).
453 // Plan against the complete section so filtering does not change a move.
459454 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
460455 reportFailure: false,
461456 });
462 // One move at a time: a second tap before the first write's event lands
463 // would plan from the same stale snapshot and silently collapse two moves
464 // into one — same double-dispatch guard as snoozeThread.
465 const movePinnedInFlightRef = useRef(false);
466 const movePinnedThread = useCallback(
457 const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, {
458 reportFailure: false,
459 });
460 const moveThread = useCallback(
467461 async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
468 if (movePinnedInFlightRef.current) return false;
469 if (!environmentSupportsPinReorder(thread.environmentId)) {
462 if (getPendingThreadOrder() !== null) return false;
463 const section = thread.pinnedAt != null ? "pinned" : "active";
464 const configs = appAtomRegistry.get(environmentServerConfigsAtom);
465 const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => {
466 const capabilities = configs.get(environmentId)?.environment.capabilities;
467 return section === "pinned"
468 ? capabilities?.threadPinReorder === true
469 : capabilities?.threadActiveReorder === true;
470 };
471 if (!supportsReorder(thread.environmentId)) {
470472 Alert.alert(
471473 "Could not move thread",
472 "This environment's server does not support pinned reordering yet. Update the server to reorder pins.",
474 "This environment's server does not support reordering these threads. Update the server to arrange them.",
473475 );
474476 return false;
475477 }
476478 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
477 const pinned = sortPinnedThreadsByOrderKey(
478 shells.filter(
479 (shell) =>
480 shell.pinnedAt != null &&
481 shell.archivedAt === null &&
482 environmentSupportsPinReorder(shell.environmentId),
479 const ordered = getThreadListV2OrderedSection({
480 threads: shells,
481 section,
482 now: new Date().toISOString(),
483 settlementEnvironmentIds: new Set(
484 [...configs].flatMap(([id, config]) =>
485 config.environment.capabilities.threadSettlement === true ? [id] : [],
486 ),
483487 ),
484 );
485 const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));
486 const assignments = planPinnedMove({
487 orderedIds,
488 keysById: new Map(
489 pinned.map((shell) => [
490 scopedThreadKey(shell.environmentId, shell.id),
491 shell.pinOrderKey ?? null,
492 ]),
488 snoozeEnvironmentIds: new Set(
489 [...configs].flatMap(([id, config]) =>
490 config.environment.capabilities.threadSnooze === true ? [id] : [],
491 ),
493492 ),
494 movedId: scopedThreadKey(thread.environmentId, thread.id),
495 direction,
496493 });
497 if (assignments === null || assignments.length === 0) return false;
494 const assignments = createThreadMovePlanner({
495 allThreads: shells,
496 ordered,
497 section,
498 reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)),
499 })(scopedThreadKey(thread.environmentId, thread.id), direction);
500 if (assignments === null) return false;
498501 const shellByKey = new Map(
499 pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
502 ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
500503 );
501504 selectionHaptic();
502 movePinnedInFlightRef.current = true;
505 const pending = beginPendingThreadOrder(
506 createPendingThreadOrder({
507 section,
508 ordered,
509 movedId: scopedThreadKey(thread.environmentId, thread.id),
510 direction,
511 assignments,
512 }),
513 );
514 let succeeded = false;
515 const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation;
503516 try {
504517 for (const assignment of assignments) {
518 if (!pending.isPending()) return false;
505519 const target = shellByKey.get(assignment.id);
506520 if (target === undefined) continue;
507 const result = await reorderPinnedMutation({
521 const result = await reorder({
508522 environmentId: target.environmentId,
509523 input: { threadId: target.id, orderKey: assignment.orderKey },
510524 });
......@@ -514,20 +528,20 @@ export function useThreadListActions(): {
514528 "Could not move thread",
515529 error instanceof Error && error.message.trim().length > 0
516530 ? error.message
517 : "The pinned thread could not be moved.",
531 : "The thread could not be moved.",
518532 );
519 // No rollback: keys already written are valid orderings on their
520 // own (each write is a complete, consistent placement), so a
521 // partial materialization leaves the list sensible, not corrupt.
533 // Keep confirmed keys when a later environment rejects its write.
522534 return false;
523535 }
524536 }
537 succeeded = true;
538 pending.complete();
525539 return true;
526540 } finally {
527 movePinnedInFlightRef.current = false;
541 if (!succeeded) pending.cancel();
528542 }
529543 },
530 [reorderPinnedMutation],
544 [reorderActiveMutation, reorderPinnedMutation],
531545 );
532546
533547 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
......@@ -541,7 +555,7 @@ export function useThreadListActions(): {
541555 unsettleThread,
542556 pinThread,
543557 unpinThread,
544 movePinnedThread,
558 moveThread,
545559 regenerateThreadTitle,
546560 };
547561}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+64-25
......@@ -1,3 +1,4 @@
1import { createThreadMovePlanner } from "./threadOrder";
12import type {
23 EnvironmentProject,
34 EnvironmentThreadShell,
......@@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native";
1011import type { MenuAction } from "@react-native-menu/menu";
1112import { useAtomValue } from "@effect/atom-react";
1213import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
13import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
1414import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1515import type { LayoutChangeEvent } from "react-native";
1616import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
......@@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
3030import { useThreadSearch } from "../../state/queries";
3131import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
3232import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
33import { usePendingThreadOrder } from "../../state/thread-order";
3334import { environmentServerConfigsAtom } from "../../state/server";
3435import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
3536import { useWorkspaceState } from "../../state/workspace";
......@@ -78,6 +79,7 @@ import {
7879} from "./thread-list-v2-items";
7980import {
8081 buildThreadListV2Items,
82 getThreadListV2OrderedSection,
8183 buildThreadListV2ListItems,
8284 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
8385 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
......@@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane(
158160 unsettleThread,
159161 pinThread,
160162 unpinThread,
161 movePinnedThread,
163 moveThread,
162164 regenerateThreadTitle,
163165 } = useThreadListActions();
164166 const threadListV2Enabled = useThreadListV2Enabled();
......@@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane(
437439 }
438440 return supported;
439441 }, [serverConfigs]);
442 const activeReorderEnvironmentIds = useMemo(() => {
443 const supported = new Set<EnvironmentId>();
444 for (const [environmentId, config] of serverConfigs) {
445 if (config.environment.capabilities.threadActiveReorder === true) {
446 supported.add(environmentId);
447 }
448 }
449 return supported;
450 }, [serverConfigs]);
440451 const titleRegenerationEnvironmentIds = useMemo(() => {
441452 const supported = new Set<EnvironmentId>();
442453 for (const [environmentId, config] of serverConfigs) {
......@@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane(
456467 ),
457468 [serverConfigs],
458469 );
459 // Canonical arranged pinned order for Move up/down flags — computed from
460 // all shells so search/scope filtering never disables a valid move.
461 const arrangedPinnedKeys = useMemo(() => {
462 const pinned = sortPinnedThreadsByOrderKey(
463 threads.filter(
464 (thread) =>
465 thread.pinnedAt != null &&
466 thread.archivedAt === null &&
467 pinReorderEnvironmentIds.has(thread.environmentId),
468 ),
469 );
470 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);
471 }, [pinReorderEnvironmentIds, threads]);
470 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
471 const threadMovePlanners = useMemo(() => {
472 const sectionPlanner = (section: "pinned" | "active") =>
473 createThreadMovePlanner({
474 allThreads: threads,
475 section,
476 reorderableEnvironmentIds: new Set(
477 [...serverConfigs].flatMap(([id, config]) =>
478 (section === "pinned"
479 ? config.environment.capabilities.threadPinReorder
480 : config.environment.capabilities.threadActiveReorder) === true
481 ? [id]
482 : [],
483 ),
484 ),
485 ordered: getThreadListV2OrderedSection({
486 threads,
487 section,
488 pendingOrder,
489 now: new Date().toISOString(),
490 settlementEnvironmentIds,
491 snoozeEnvironmentIds,
492 }),
493 });
494 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
495 }, [
496 serverConfigs,
497 threads,
498 pendingOrder,
499 settlementEnvironmentIds,
500 snoozeEnvironmentIds,
501 nowMinute,
502 snoozeWakeTick,
503 ]);
472504 const threadListV2Layout = useMemo(() => {
473505 if (!threadListV2Enabled)
474506 return {
......@@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane(
481513 nextSnoozeWakeAt: null,
482514 };
483515 return buildThreadListV2Items({
516 pendingOrder,
484517 threads: threads.filter((thread) => thread.archivedAt === null),
485518 environmentId: options.selectedEnvironmentId,
486519 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
......@@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane(
495528 selectedThreadKey: props.selectedThreadKey ?? null,
496529 });
497530 }, [
531 pendingOrder,
498532 nowMinute,
499533 snoozeWakeTick,
500534 snoozedShelfExpanded,
......@@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane(
844878 }
845879 case "v2-thread": {
846880 const thread = item.item.thread;
881 const movePlanner = item.item.pinned
882 ? threadMovePlanners.pinned
883 : threadMovePlanners.active;
884 const movedId = `${thread.environmentId}:${thread.id}`;
847885 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
848886 return (
849887 <ThreadListV2Row
......@@ -891,20 +929,19 @@ function ThreadNavigationSidebarPane(
891929 onSettleThread={settleThread}
892930 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
893931 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
894 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}
895 canMovePinnedUp={
896 arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0
932 reorderSupported={
933 item.item.pinned
934 ? pinReorderEnvironmentIds.has(thread.environmentId)
935 : activeReorderEnvironmentIds.has(thread.environmentId)
897936 }
898 canMovePinnedDown={(() => {
899 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);
900 return index !== -1 && index < arrangedPinnedKeys.length - 1;
901 })()}
937 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
938 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
902939 onSnoozeThread={snoozeThread}
903940 onUnsnoozeThread={unsnoozeThread}
904941 onUnsettleThread={unsettleThread}
905942 onPinThread={pinThread}
906943 onUnpinThread={unpinThread}
907 onMovePinnedThread={movePinnedThread}
944 onMoveThread={moveThread}
908945 onSwipeableClose={handleSwipeableClose}
909946 onSwipeableWillOpen={handleSwipeableWillOpen}
910947 simultaneousSwipeGesture={sidebarScrollGesture}
......@@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane(
10271064 },
10281065 [
10291066 archiveThread,
1030 arrangedPinnedKeys,
1067 activeReorderEnvironmentIds,
1068 threadMovePlanners,
1069 pendingOrder,
10311070 confirmDeletePendingTask,
10321071 confirmDeleteThread,
10331072 handleSelectThread,
10341073 handleSwipeableClose,
10351074 handleSwipeableWillOpen,
10361075 machineByEnvironmentId,
1037 movePinnedThread,
1076 moveThread,
10381077 openPendingTask,
10391078 pinReorderEnvironmentIds,
10401079 pinThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+51-52
......@@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
367367 readonly pinningSupported: boolean;
368368 /** False on servers that predate thread title regeneration. */
369369 readonly titleRegenerationSupported: boolean;
370 /** False on servers that predate thread.pin.reorder. Gates the pinned
371 Move up / Move down menu items. */
372 readonly pinReorderSupported?: boolean;
373 readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
374 /** Position flags for the pinned block so the menu disables the move that
370 /** Server supports reordering this card's section. */
371 readonly reorderSupported?: boolean;
372 readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
373 /** Position flags for the card's section so the menu disables the move that
375374 would fall off the end of the list. */
376 readonly canMovePinnedUp?: boolean;
377 readonly canMovePinnedDown?: boolean;
375 readonly canMoveUp?: boolean;
376 readonly canMoveDown?: boolean;
378377 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
379378 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
380379 readonly searchMatch?: EnvironmentThreadSearchMatch;
......@@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
397396 onArchiveThread,
398397 onPinThread,
399398 onUnpinThread,
400 onMovePinnedThread,
399 onMoveThread,
401400 } = props;
402401 const snoozedRow = props.snoozed === true;
403402 const pinnedRow = props.pinned === true;
......@@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
436435 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
437436 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
438437 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
439 const handleMovePinnedUp = useCallback(
440 () => onMovePinnedThread?.(thread, "up"),
441 [onMovePinnedThread, thread],
442 );
443 const handleMovePinnedDown = useCallback(
444 () => onMovePinnedThread?.(thread, "down"),
445 [onMovePinnedThread, thread],
446 );
438 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
439 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
447440 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
448441
449442 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
......@@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
482475 // Pinned cards keep the full lifecycle menu; only the pin item flips to
483476 // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
484477 // hides the card until wake with the pin intact.)
485 const pinMenuItem = useMemo<MenuAction[]>(
486 () =>
487 props.pinningSupported
478 const arrangementMenuItems = useMemo<MenuAction[]>(
479 () => [
480 ...(variant === "card" && props.reorderSupported === true
481 ? [
482 {
483 id: "move-up",
484 title: "Move up",
485 image: "arrow.up",
486 attributes: { disabled: props.canMoveUp !== true },
487 } satisfies MenuAction,
488 {
489 id: "move-down",
490 title: "Move down",
491 image: "arrow.down",
492 attributes: { disabled: props.canMoveDown !== true },
493 } satisfies MenuAction,
494 ]
495 : []),
496 ...(props.pinningSupported
488497 ? [
489 ...(pinnedRow && props.pinReorderSupported === true
490 ? [
491 {
492 id: "move-pin-up",
493 title: "Move up",
494 image: "arrow.up",
495 attributes: { disabled: props.canMovePinnedUp !== true },
496 } satisfies MenuAction,
497 {
498 id: "move-pin-down",
499 title: "Move down",
500 image: "arrow.down",
501 attributes: { disabled: props.canMovePinnedDown !== true },
502 } satisfies MenuAction,
503 ]
504 : []),
505498 thread.pinnedAt != null
506499 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
507500 : { id: "pin", title: "Pin", image: "pin" },
508501 ]
509 : [],
502 : []),
503 ],
510504 [
511 pinnedRow,
512 props.canMovePinnedDown,
513 props.canMovePinnedUp,
514 props.pinReorderSupported,
505 props.canMoveDown,
506 props.canMoveUp,
507 props.reorderSupported,
515508 props.pinningSupported,
516509 thread.pinnedAt,
510 variant,
517511 ],
518512 );
519513 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
......@@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
533527 image: "clock",
534528 subactions: snoozePresetActions,
535529 },
536 ...pinMenuItem,
530 ...arrangementMenuItems,
537531 ...titleRegenerationMenuItems,
538532 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
539533 ],
540 [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],
534 [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],
541535 );
542536 const cardMenuActions = useMemo<MenuAction[]>(
543537 () => [
544538 CARD_MENU_ACTIONS[0]!,
545 ...pinMenuItem,
539 ...arrangementMenuItems,
546540 ...titleRegenerationMenuItems,
547541 ...CARD_MENU_ACTIONS.slice(1),
548542 ],
549 [pinMenuItem, titleRegenerationMenuItems],
543 [arrangementMenuItems, titleRegenerationMenuItems],
550544 );
551545 const slimMenuActions = useMemo<MenuAction[]>(
552546 () => [
553547 SLIM_MENU_ACTIONS[0]!,
554 ...(thread.pinnedAt != null ? pinMenuItem : []),
548 ...(thread.pinnedAt != null ? arrangementMenuItems : []),
555549 ...titleRegenerationMenuItems,
556550 SLIM_MENU_ACTIONS[1]!,
557551 ],
558 [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],
552 [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems],
559553 );
560554 const snoozedMenuActions = useMemo<MenuAction[]>(
561555 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
562556 [titleRegenerationMenuItems],
563557 );
564558 const legacyMenuActions = useMemo<MenuAction[]>(
565 () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!],
566 [titleRegenerationMenuItems],
559 () => [
560 LEGACY_MENU_ACTIONS[0]!,
561 ...arrangementMenuItems,
562 ...titleRegenerationMenuItems,
563 LEGACY_MENU_ACTIONS[1]!,
564 ],
565 [arrangementMenuItems, titleRegenerationMenuItems],
567566 );
568567 const handleMenuAction = useCallback(
569568 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
......@@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
572571 if (nativeEvent.event === "unsnooze") handleUnsnooze();
573572 if (nativeEvent.event === "pin") handlePin();
574573 if (nativeEvent.event === "unpin") handleUnpin();
575 if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();
576 if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();
574 if (nativeEvent.event === "move-up") handleMoveUp();
575 if (nativeEvent.event === "move-down") handleMoveDown();
577576 if (nativeEvent.event === "archive") handleArchive();
578577 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
579578 if (nativeEvent.event === "delete") handleDelete();
......@@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
592591 handleArchive,
593592 handleDelete,
594593 handleRegenerateTitle,
595 handleMovePinnedDown,
596 handleMovePinnedUp,
594 handleMoveDown,
595 handleMoveUp,
597596 handlePin,
598597 handleSettle,
599598 handleSnooze,
apps/mobile/src/features/threads/threadListV2.test.ts+293
......@@ -1,3 +1,10 @@
1import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
2import {
3 createPendingThreadOrder,
4 createThreadMovePlanner,
5 reconcilePendingThreadOrder,
6 type PendingThreadOrder,
7} from "./threadOrder";
18import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
29import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
310import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
......@@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1623import {
1724 buildThreadListV2Items,
1825 buildThreadListV2ListItems,
26 getThreadListV2OrderedSection,
1927 resolveThreadListV2Enabled,
2028 resolveThreadListV2SnoozeMenuSelection,
2129 resolveThreadListV2SnoozeGateExpiryMs,
......@@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => {
258266});
259267
260268describe("sortThreadsForListV2", () => {
269 it("honors a saved active order and leaves new threads above it", () => {
270 const sorted = sortThreadsForListV2([
271 { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" },
272 { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" },
273 { id: "new", createdAt: "2026-06-01T13:00:00.000Z" },
274 ]);
275 expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]);
276 });
277
261278 it("orders by creation time, newest first, ignoring activity", () => {
262279 const sorted = sortThreadsForListV2([
263280 { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
......@@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => {
281298 });
282299});
283300
301describe("getThreadListV2OrderedSection", () => {
302 it("uses each saved order and excludes settled, snoozed, and archived rows", () => {
303 const threads = [
304 makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }),
305 makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }),
306 makeThread({ id: ThreadId.make("active-new"), title: "New" }),
307 makeThread({
308 id: ThreadId.make("pinned-later"),
309 title: "Pinned later",
310 pinnedAt: NOW,
311 pinOrderKey: "t",
312 activeOrderKey: "f",
313 }),
314 makeThread({
315 id: ThreadId.make("pinned-first"),
316 title: "Pinned first",
317 pinnedAt: NOW,
318 pinOrderKey: "f",
319 activeOrderKey: "t",
320 }),
321 makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }),
322 makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }),
323 makeThread({
324 id: ThreadId.make("snoozed"),
325 title: "Snoozed",
326 snoozedUntil: "2026-06-03T10:00:00.000Z",
327 snoozedAt: NOW,
328 }),
329 makeThread({
330 id: ThreadId.make("pinned-snoozed"),
331 title: "Pinned snoozed",
332 pinnedAt: NOW,
333 snoozedUntil: "2026-06-03T10:00:00.000Z",
334 snoozedAt: NOW,
335 }),
336 ];
337 expect(
338 getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map(
339 (thread) => thread.id,
340 ),
341 ).toEqual(["active-new", "active-first", "active-later"]);
342 expect(
343 getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map(
344 (thread) => thread.id,
345 ),
346 ).toEqual(["pinned-first", "pinned-later"]);
347 });
348});
349
284350describe("buildThreadListV2Items", () => {
285351 it("places a persisted settled thread in the settled shelf", () => {
286352 const thread = makeThread({
......@@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => {
9371003 ]);
9381004 });
9391005});
1006
1007describe("pending mobile thread moves", () => {
1008 function fixture(section: "active" | "pinned" = "active") {
1009 const rows = ["a", "b", "c"].map((id, index) =>
1010 makeThread({
1011 id: ThreadId.make(id),
1012 title: id === "a" ? "hidden" : "match",
1013 createdAt: `2026-06-01T0${3 - index}:00:00.000Z`,
1014 pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null,
1015 }),
1016 );
1017 const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW });
1018 const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`);
1019 const movedId = orderedIds[2]!;
1020 const assignments = planPinnedMove({
1021 orderedIds,
1022 keysById: new Map(orderedIds.map((id) => [id, null])),
1023 movedId,
1024 direction: "up",
1025 })!;
1026 const pending = createPendingThreadOrder({
1027 section,
1028 ordered,
1029 movedId,
1030 direction: "up",
1031 assignments,
1032 });
1033 const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) =>
1034 current.map((row) =>
1035 `${row.environmentId}:${row.id}` === assignment.id
1036 ? {
1037 ...row,
1038 [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey,
1039 }
1040 : row,
1041 );
1042 return { rows, assignments, pending, update };
1043 }
1044
1045 function layout(
1046 rows: EnvironmentThreadShell[],
1047 pendingOrder: PendingThreadOrder | null,
1048 searchQuery = "",
1049 ) {
1050 return buildThreadListV2Items({
1051 threads: rows,
1052 pendingOrder,
1053 environmentId: null,
1054 searchQuery,
1055 now: NOW,
1056 }).items.map((item) => item.thread.id);
1057 }
1058
1059 it.each(["active", "pinned"] as const)(
1060 "holds %s order through every intermediate key upsert",
1061 (section) => {
1062 const { rows, assignments, pending, update } = fixture(section);
1063 let current = rows;
1064 let hold: PendingThreadOrder | null = pending;
1065 const desired = pending.orderedIds.map((id) => id.split(":")[1]);
1066 expect(layout(current, hold)).toEqual(desired);
1067 for (const assignment of assignments) {
1068 current = update(current, assignment);
1069 hold = reconcilePendingThreadOrder(
1070 hold!,
1071 getThreadListV2OrderedSection({ threads: current, section, now: NOW }),
1072 );
1073 expect(hold).not.toBeNull();
1074 expect(layout(current, hold)).toEqual(desired);
1075 }
1076 expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull();
1077 expect(layout(current, null)).toEqual(desired);
1078 },
1079 );
1080
1081 it("keeps the action guard pending when receipts precede canonical shells", () => {
1082 const { rows, assignments, pending, update } = fixture();
1083 let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true };
1084 let current = rows;
1085 expect(reconcilePendingThreadOrder(hold, current)).toBe(hold);
1086 for (const [index, assignment] of assignments.entries()) {
1087 current = update(current, assignment);
1088 hold = reconcilePendingThreadOrder(hold!, current);
1089 expect(hold === null).toBe(index === assignments.length - 1);
1090 expect(layout(current, hold)).toEqual(["a", "c", "b"]);
1091 }
1092 });
1093
1094 it("keeps search results in the full pending section order", () => {
1095 const { rows, assignments, pending, update } = fixture();
1096 const current = update(update(rows, assignments[0]!), assignments[1]!);
1097 expect(layout(current, pending, "match")).toEqual(["c", "b"]);
1098 });
1099
1100 it("releases for real section membership and foreign key changes", () => {
1101 const { rows, pending } = fixture();
1102 expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull();
1103 const newRow = makeThread({ id: ThreadId.make("new"), title: "new" });
1104 expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull();
1105 expect(
1106 reconcilePendingThreadOrder(
1107 pending,
1108 rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)),
1109 ),
1110 ).toBeNull();
1111 const settled = rows.map((row, index) =>
1112 index === 0 ? { ...row, settledOverride: "settled" as const } : row,
1113 );
1114 expect(layout(settled, pending)).toEqual(layout(settled, null));
1115 });
1116
1117 it("does not hide a concurrent return to a previously confirmed key", () => {
1118 const { rows, assignments, pending, update } = fixture();
1119 const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!;
1120 expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull();
1121 });
1122
1123 it("preserves the hold for activity but releases for a reopened sort anchor", () => {
1124 const { rows, pending } = fixture();
1125 expect(
1126 reconcilePendingThreadOrder(
1127 pending,
1128 rows.map((row) => ({ ...row, updatedAt: NOW })),
1129 ),
1130 ).toBe(pending);
1131 expect(
1132 reconcilePendingThreadOrder(
1133 pending,
1134 rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)),
1135 ),
1136 ).toBeNull();
1137 });
1138});
1139
1140describe("mobile move availability", () => {
1141 const oldEnvironment = EnvironmentId.make("older-server");
1142 function rows(section: "active" | "pinned", keys: readonly (string | null)[]) {
1143 return keys.map((key, index) =>
1144 makeThread({
1145 id: ThreadId.make(`move-${index}`),
1146 title: `Move ${index}`,
1147 environmentId: index === 1 ? oldEnvironment : environmentId,
1148 activeOrderKey: section === "active" ? key : null,
1149 pinOrderKey: section === "pinned" ? key : null,
1150 pinnedAt: section === "pinned" ? NOW : null,
1151 }),
1152 );
1153 }
1154
1155 it.each(["active", "pinned"] as const)(
1156 "keeps unsupported keyed %s neighbors as usable anchors",
1157 (section) => {
1158 const ordered = rows(section, ["bb", "dd", "ff"]);
1159 const plan = createThreadMovePlanner({
1160 ordered,
1161 section,
1162 reorderableEnvironmentIds: new Set([environmentId]),
1163 });
1164 const assignments = plan(`${environmentId}:move-0`, "down");
1165 expect(assignments).toHaveLength(1);
1166 expect(assignments![0]!.id).toBe(`${environmentId}:move-0`);
1167 expect(assignments![0]!.orderKey > "dd").toBe(true);
1168 expect(assignments![0]!.orderKey < "ff").toBe(true);
1169 expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull();
1170 expect(plan(`${environmentId}:move-0`, "up")).toBeNull();
1171 },
1172 );
1173
1174 it.each(["active", "pinned"] as const)(
1175 "disables %s moves requiring unsupported keyless materialization",
1176 (section) => {
1177 const ordered = rows(section, [null, null, null]);
1178 const plan = createThreadMovePlanner({
1179 ordered,
1180 section,
1181 reorderableEnvironmentIds: new Set([environmentId]),
1182 });
1183 expect(plan(`${environmentId}:move-0`, "down")).toBeNull();
1184 expect(plan(`${environmentId}:move-2`, "up")).toBeNull();
1185 const supported = createThreadMovePlanner({
1186 ordered,
1187 section,
1188 reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]),
1189 });
1190 expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3);
1191 },
1192 );
1193
1194 it.each(["active", "pinned"] as const)(
1195 "reserves snoozed %s keys when moving visible rows",
1196 (section) => {
1197 const ordered = rows(section, ["bb", "dd", "ff"]);
1198 const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) };
1199 const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]!
1200 .orderKey;
1201 const hidden = {
1202 ...ordered[0]!,
1203 id: ThreadId.make("snoozed"),
1204 snoozedAt: NOW,
1205 snoozedUntil: "2099-01-01T00:00:00.000Z",
1206 pinOrderKey: section === "pinned" ? collision : null,
1207 activeOrderKey: section === "active" ? collision : null,
1208 };
1209 const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })(
1210 `${environmentId}:move-0`,
1211 "down",
1212 );
1213 expect(assignments).toHaveLength(1);
1214 expect(assignments![0]!.orderKey).not.toBe(collision);
1215 expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true);
1216 },
1217 );
1218
1219 it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => {
1220 const ordered = rows("active", [null, null, "bb", "dd", "ff"]);
1221 const plan = createThreadMovePlanner({
1222 ordered,
1223 section: "active",
1224 reorderableEnvironmentIds: new Set([environmentId]),
1225 });
1226 const assignments = plan(`${environmentId}:move-4`, "up");
1227 expect(assignments).toHaveLength(1);
1228 expect(assignments![0]!.id).toBe(`${environmentId}:move-4`);
1229 expect(assignments![0]!.orderKey > "bb").toBe(true);
1230 expect(assignments![0]!.orderKey < "dd").toBe(true);
1231 });
1232});
apps/mobile/src/features/threads/threadListV2.ts+67-19
......@@ -9,7 +9,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
99import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1010import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
1111import {
12 activeThreadAnchorTimestampMs,
12 sortActiveThreadsByOrderKey,
1313 resolveSettledThreadTimestamp,
1414 sortPinnedThreadsByOrderKey,
1515} from "@t3tools/client-runtime/state/thread-sort";
......@@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1717
1818import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1919
20import {
21 applyPendingThreadOrder,
22 reconcilePendingThreadOrder,
23 type PendingThreadOrder,
24} from "./threadOrder";
25
2026export { snoozeWakeLabel };
2127
2228/**
......@@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number {
150156 return Number.isNaN(parsed) ? 0 : parsed;
151157}
152158
153/**
154 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the
155 * list — a row holds its position between lifecycle transitions. The anchor
156 * is creation time until an un-settle re-anchors it (see
157 * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the
158 * top instead of sinking back to its creation-order slot. Mirrors web's
159 * sortThreadsForSidebar.
160 */
159/** The active order shared by web and native: new/reopened rows, then the
160 saved arrangement. Activity does not move a thread. */
161161export function sortThreadsForListV2<
162162 T extends {
163163 readonly id: string;
164164 readonly createdAt: string;
165165 readonly unsettledAt?: string | null | undefined;
166 readonly activeOrderKey?: string | null | undefined;
167 readonly environmentId?: string | undefined;
166168 },
167169>(threads: readonly T[]): T[] {
168 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023
169 // change-by-copy array methods.
170 return [...threads].sort(
171 (left, right) =>
172 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||
173 left.id.localeCompare(right.id),
174 );
170 return sortActiveThreadsByOrderKey(threads);
171}
172
173/** Canonical card section for Move up/down, independent of search or scope. */
174export function getThreadListV2OrderedSection(input: {
175 readonly threads: readonly EnvironmentThreadShell[];
176 readonly section: "pinned" | "active";
177 readonly pendingOrder?: PendingThreadOrder | null;
178 readonly now: string;
179 readonly settlementEnvironmentIds?: ReadonlySet<EnvironmentId>;
180 readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>;
181}): EnvironmentThreadShell[] {
182 const threads = input.threads.filter((thread) => {
183 if (thread.archivedAt !== null) return false;
184 if (
185 (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) &&
186 thread.settledOverride === "settled"
187 ) {
188 return false;
189 }
190 if (
191 (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) &&
192 effectiveSnoozed(thread, { now: input.now })
193 ) {
194 return false;
195 }
196 return (thread.pinnedAt != null) === (input.section === "pinned");
197 });
198 const ordered =
199 input.section === "pinned"
200 ? sortPinnedThreadsByOrderKey(threads)
201 : sortActiveThreadsByOrderKey(threads);
202 const pending =
203 input.pendingOrder?.section === input.section
204 ? reconcilePendingThreadOrder(input.pendingOrder, ordered)
205 : null;
206 return applyPendingThreadOrder(ordered, input.section, pending);
175207}
176208
177209export interface ThreadListV2Item {
......@@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: {
299331}
300332
301333/**
302 * Partitions visible threads into the active card block (creation order) and
334 * Partitions visible threads into the active card block (saved order) and
303335 * the settled recency tail, matching the web v2 list.
304336 */
305337export function buildThreadListV2Items(input: {
338 readonly pendingOrder?: PendingThreadOrder | null;
306339 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
307340 readonly environmentId: EnvironmentId | null;
308341 readonly projectRefs?: ReadonlyArray<{
......@@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: {
331364 readonly selectedThreadKey?: string | null;
332365}): ThreadListV2Layout {
333366 const now = input.now;
367 const pending =
368 input.pendingOrder == null
369 ? null
370 : reconcilePendingThreadOrder(
371 input.pendingOrder,
372 getThreadListV2OrderedSection({
373 ...input,
374 section: input.pendingOrder.section,
375 pendingOrder: null,
376 }),
377 );
334378 const query = input.searchQuery.trim().toLocaleLowerCase();
335379 const projectKeys = input.projectRefs
336380 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
......@@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: {
382426 }
383427 }
384428
385 const orderedActive = sortThreadsForListV2(active);
429 const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
386430 const orderedSnoozed = [...snoozed].sort(
387431 (left, right) =>
388432 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
......@@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: {
414458 );
415459
416460 const items: ThreadListV2Item[] = [];
417 for (const thread of sortPinnedThreadsByOrderKey(pinned)) {
461 for (const thread of applyPendingThreadOrder(
462 sortPinnedThreadsByOrderKey(pinned),
463 "pinned",
464 pending,
465 )) {
418466 items.push({
419467 thread,
420468 variant: "card",
apps/mobile/src/features/threads/threadOrder.ts created+120
......@@ -0,0 +1,120 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort";
3import type { EnvironmentId } from "@t3tools/contracts";
4
5type OrderRow = Pick<
6 EnvironmentThreadShell,
7 | "id"
8 | "environmentId"
9 | "pinOrderKey"
10 | "activeOrderKey"
11 | "createdAt"
12 | "unsettledAt"
13 | "pinnedAt"
14>;
15
16export interface PendingThreadOrder {
17 readonly section: "pinned" | "active";
18 readonly orderedIds: readonly string[];
19 readonly before: ReadonlyMap<string, { readonly key: string | null; readonly anchor: string }>;
20 readonly assignments: ReadonlyMap<string, string>;
21 readonly confirmed: ReadonlySet<string>;
22 readonly commandsComplete: boolean;
23}
24
25function rowId(row: OrderRow): string {
26 return `${row.environmentId}:${row.id}`;
27}
28
29function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) {
30 return {
31 key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null,
32 anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt),
33 };
34}
35
36/** Keep every visible row as an anchor, but only offer plans whose key writes
37 * are supported. Menu availability and execution use this same planner. */
38export function createThreadMovePlanner(input: {
39 readonly ordered: readonly OrderRow[];
40 readonly allThreads?: readonly OrderRow[];
41 readonly section: PendingThreadOrder["section"];
42 readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>;
43}) {
44 const orderedIds = input.ordered.map(rowId);
45 const keysById = new Map(
46 (input.allThreads ?? input.ordered).map((row) => [
47 rowId(row),
48 rowOrder(row, input.section).key,
49 ]),
50 );
51 const writableIds = new Set(
52 input.ordered
53 .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
54 .map(rowId),
55 );
56 return (movedId: string, direction: "up" | "down") => {
57 if (!writableIds.has(movedId)) return null;
58 const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction });
59 return assignments === null ||
60 assignments.length === 0 ||
61 assignments.some((assignment) => !writableIds.has(assignment.id))
62 ? null
63 : assignments;
64 };
65}
66
67export function createPendingThreadOrder(input: {
68 readonly section: PendingThreadOrder["section"];
69 readonly ordered: readonly OrderRow[];
70 readonly movedId: string;
71 readonly direction: "up" | "down";
72 readonly assignments: readonly { readonly id: string; readonly orderKey: string }[];
73}): 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);
78 return {
79 section: input.section,
80 orderedIds,
81 before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])),
82 assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])),
83 confirmed: new Set(),
84 commandsComplete: false,
85 };
86}
87
88/** Receipts and shell updates arrive independently. Only our own key writes
89 * may pass through the hold; membership and other arrangement changes win. */
90export function reconcilePendingThreadOrder(
91 pending: PendingThreadOrder,
92 ordered: readonly OrderRow[],
93): PendingThreadOrder | null {
94 if (ordered.length !== pending.before.size) return null;
95 const confirmed = new Set(pending.confirmed);
96 for (const row of ordered) {
97 const id = rowId(row);
98 const before = pending.before.get(id);
99 const current = rowOrder(row, pending.section);
100 if (before === undefined || current.anchor !== before.anchor) return null;
101 const assigned = pending.assignments.get(id);
102 if (assigned !== undefined && current.key === assigned) confirmed.add(id);
103 else if (current.key !== before.key || confirmed.has(id)) return null;
104 }
105 if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null;
106 return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed };
107}
108
109/** Apply the full section's pending order after search/environment filtering. */
110export function applyPendingThreadOrder<T extends OrderRow>(
111 rows: readonly T[],
112 section: PendingThreadOrder["section"],
113 pending: PendingThreadOrder | null | undefined,
114): T[] {
115 if (pending == null || pending.section !== section) return [...rows];
116 const rank = new Map(pending.orderedIds.map((id, index) => [id, index]));
117 return [...rows].sort(
118 (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity),
119 );
120}
apps/mobile/src/state/thread-order.test.ts created+117
......@@ -0,0 +1,117 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { EnvironmentId, ThreadId } from "@t3tools/contracts";
3import type { Atom } from "effect/unstable/reactivity";
4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
5
6import { createPendingThreadOrder } from "../features/threads/threadOrder";
7import { appAtomRegistry } from "./atom-registry";
8import {
9 beginPendingThreadOrder,
10 getPendingThreadOrder,
11 pendingThreadOrderAtom,
12} from "./thread-order";
13import { environmentThreadShells } from "./threads";
14
15vi.mock("./atom-registry", async () => {
16 const { AtomRegistry } = await import("effect/unstable/reactivity");
17 return { appAtomRegistry: AtomRegistry.make() };
18});
19vi.mock("./threads", async () => {
20 const { Atom } = await import("effect/unstable/reactivity");
21 return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } };
22});
23vi.mock("./server", async () => {
24 const { Atom } = await import("effect/unstable/reactivity");
25 return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) };
26});
27
28// The mocked shell source is writable so tests can deliver canonical upserts.
29const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable<
30 readonly EnvironmentThreadShell[],
31 readonly EnvironmentThreadShell[]
32>;
33
34function fixture() {
35 // Only section membership and order fields are read by this coordinator.
36 const rows = ["a", "b"].map(
37 (id, index) =>
38 ({
39 id: ThreadId.make(id),
40 environmentId: EnvironmentId.make("env"),
41 createdAt: `2026-06-01T0${2 - index}:00:00.000Z`,
42 archivedAt: null,
43 pinnedAt: null,
44 activeOrderKey: null,
45 }) as EnvironmentThreadShell,
46 );
47 appAtomRegistry.set(shellsAtom, rows);
48 const pending = createPendingThreadOrder({
49 section: "active",
50 ordered: rows,
51 movedId: "env:b",
52 direction: "up",
53 assignments: [
54 { id: "env:b", orderKey: "aa" },
55 { id: "env:a", orderKey: "bb" },
56 ],
57 });
58 const start = () => beginPendingThreadOrder(pending);
59 const upsert = (id: string, key: string) => {
60 const current = appAtomRegistry.get(shellsAtom);
61 appAtomRegistry.set(
62 shellsAtom,
63 current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)),
64 );
65 };
66 return { rows, start, upsert };
67}
68
69afterEach(() => appAtomRegistry.reset());
70
71describe("shared mobile pending move", () => {
72 it("blocks another pickup after receipts and clears on final canonical upsert", () => {
73 const { start, upsert } = fixture();
74 const move = start();
75 move.complete();
76 expect(getPendingThreadOrder()).not.toBeNull();
77 upsert("b", "aa");
78 expect(getPendingThreadOrder()).not.toBeNull();
79 upsert("a", "bb");
80 expect(getPendingThreadOrder()).toBeNull();
81 expect(move.isPending()).toBe(false);
82 });
83
84 it("waits for receipts when shells arrive first", () => {
85 const { start, upsert } = fixture();
86 const move = start();
87 upsert("b", "aa");
88 upsert("a", "bb");
89 expect(getPendingThreadOrder()).not.toBeNull();
90 move.complete();
91 expect(getPendingThreadOrder()).toBeNull();
92 });
93
94 it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => {
95 const { start, upsert } = fixture();
96 const move = start();
97 upsert("b", "aa");
98 move.cancel();
99 expect(getPendingThreadOrder()).toBeNull();
100 expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa");
101 const next = start();
102 move.cancel();
103 expect(next.isPending()).toBe(true);
104 next.cancel();
105 });
106
107 it("stops remaining writes when a canonical membership change invalidates the move", () => {
108 const { rows, start } = fixture();
109 const move = start();
110 appAtomRegistry.set(shellsAtom, rows.slice(1));
111 expect(move.isPending()).toBe(false);
112 expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull();
113 move.complete();
114 appAtomRegistry.set(shellsAtom, rows);
115 expect(getPendingThreadOrder()).toBeNull();
116 });
117});
apps/mobile/src/state/thread-order.ts created+89
......@@ -0,0 +1,89 @@
1import { useAtomValue } from "@effect/atom-react";
2import { useEffect } from "react";
3import { Atom } from "effect/unstable/reactivity";
4
5import {
6 reconcilePendingThreadOrder,
7 type PendingThreadOrder,
8} from "../features/threads/threadOrder";
9import { getThreadListV2OrderedSection } from "../features/threads/threadListV2";
10import { appAtomRegistry } from "./atom-registry";
11import { environmentServerConfigsAtom } from "./server";
12import { environmentThreadShells } from "./threads";
13
14export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe(
15 Atom.keepAlive,
16);
17
18export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) {
19 const pending = useAtomValue(pendingThreadOrderAtom);
20 // A timed wake can change section membership without a shell event. Use the
21 // lists' existing clocks to retire that hold and re-enable their move menus.
22 useEffect(() => {
23 getPendingThreadOrder();
24 }, [nowMinute, snoozeWakeTick]);
25 return pending;
26}
27
28let refreshPendingOrder: (() => void) | undefined;
29
30/** Shared by Home and the navigation sidebar, including their action guards. */
31export function getPendingThreadOrder(): PendingThreadOrder | null {
32 refreshPendingOrder?.();
33 return appAtomRegistry.get(pendingThreadOrderAtom);
34}
35
36export function beginPendingThreadOrder(pending: PendingThreadOrder) {
37 const unsubscribers: (() => void)[] = [];
38 const cancel = () => {
39 if (refreshPendingOrder !== refresh) return;
40 refreshPendingOrder = undefined;
41 for (const unsubscribe of unsubscribers) unsubscribe();
42 appAtomRegistry.set(pendingThreadOrderAtom, null);
43 };
44 const refresh = () => {
45 if (refreshPendingOrder !== refresh) return;
46 const current = appAtomRegistry.get(pendingThreadOrderAtom);
47 if (current === null) return;
48 const configs = appAtomRegistry.get(environmentServerConfigsAtom);
49 const ordered = getThreadListV2OrderedSection({
50 threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom),
51 section: current.section,
52 now: new Date().toISOString(),
53 settlementEnvironmentIds: new Set(
54 [...configs].flatMap(([id, config]) =>
55 config.environment.capabilities.threadSettlement === true ? [id] : [],
56 ),
57 ),
58 snoozeEnvironmentIds: new Set(
59 [...configs].flatMap(([id, config]) =>
60 config.environment.capabilities.threadSnooze === true ? [id] : [],
61 ),
62 ),
63 });
64 const next = reconcilePendingThreadOrder(current, ordered);
65 if (next === null) cancel();
66 else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next);
67 };
68 refreshPendingOrder = refresh;
69 appAtomRegistry.set(pendingThreadOrderAtom, pending);
70 unsubscribers.push(
71 appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh),
72 appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh),
73 );
74 return {
75 isPending: () => {
76 refresh();
77 return refreshPendingOrder === refresh;
78 },
79 complete: () => {
80 if (refreshPendingOrder !== refresh) return;
81 const current = appAtomRegistry.get(pendingThreadOrderAtom);
82 if (current !== null) {
83 appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true });
84 refresh();
85 }
86 },
87 cancel,
88 };
89}
apps/mobile/src/state/use-thread-selection.ts+4
......@@ -62,6 +62,10 @@ function threadDetailToShell(
6262 archivedAt: thread.archivedAt,
6363 settledOverride: thread.settledOverride,
6464 settledAt: thread.settledAt,
65 unsettledAt: thread.unsettledAt,
66 activeOrderKey: thread.activeOrderKey,
67 pinnedAt: thread.pinnedAt,
68 pinOrderKey: thread.pinOrderKey,
6569 snoozedUntil: thread.snoozedUntil ?? null,
6670 snoozedAt: thread.snoozedAt ?? null,
6771 session: thread.session,