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() {...@@ -49,7 +49,7 @@ export function HomeRouteScreen() {
49 unsnoozeThread,49 unsnoozeThread,
50 pinThread,50 pinThread,
51 unpinThread,51 unpinThread,
52 movePinnedThread,52 moveThread,
53 regenerateThreadTitle,53 regenerateThreadTitle,
54 unsettleThread,54 unsettleThread,
55 } = useThreadListActions();55 } = useThreadListActions();
...@@ -199,7 +199,7 @@ export function HomeRouteScreen() {...@@ -199,7 +199,7 @@ export function HomeRouteScreen() {
199 onUnsettleThread={unsettleThread}199 onUnsettleThread={unsettleThread}
200 onPinThread={pinThread}200 onPinThread={pinThread}
201 onUnpinThread={unpinThread}201 onUnpinThread={unpinThread}
202 onMovePinnedThread={movePinnedThread}202 onMoveThread={moveThread}
203 onRegenerateThreadTitle={regenerateThreadTitle}203 onRegenerateThreadTitle={regenerateThreadTitle}
204 onEnvironmentChange={setSelectedEnvironmentId}204 onEnvironmentChange={setSelectedEnvironmentId}
205 onProjectChange={setSelectedProjectKey}205 onProjectChange={setSelectedProjectKey}
apps/mobile/src/features/home/HomeScreen.tsx+66-28
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { createThreadMovePlanner } from "../threads/threadOrder";
1import {2import {
2 LegendList,3 LegendList,
3 type LegendListRef,4 type LegendListRef,
...@@ -11,7 +12,6 @@ import {...@@ -11,7 +12,6 @@ import {
11 threadSearchMatchKey,12 threadSearchMatchKey,
12 type EnvironmentThreadSearchMatch,13 type EnvironmentThreadSearchMatch,
13} from "@t3tools/client-runtime/state/thread-search";14} from "@t3tools/client-runtime/state/thread-search";
14import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
15import {15import {
16 type EnvironmentId,16 type EnvironmentId,
17 resolveEnvironmentMachineKind,17 resolveEnvironmentMachineKind,
...@@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";...@@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
35import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";35import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
36import { useThreadSearch } from "../../state/queries";36import { useThreadSearch } from "../../state/queries";
37import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";37import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
38import { usePendingThreadOrder } from "../../state/thread-order";
38import { environmentServerConfigsAtom } from "../../state/server";39import { environmentServerConfigsAtom } from "../../state/server";
39import type { PendingNewTask } from "../../state/use-pending-new-tasks";40import type { PendingNewTask } from "../../state/use-pending-new-tasks";
40import {41import {
...@@ -51,6 +52,7 @@ import {...@@ -51,6 +52,7 @@ import {
51} from "../threads/thread-list-v2-items";52} from "../threads/thread-list-v2-items";
52import {53import {
53 buildThreadListV2Items,54 buildThreadListV2Items,
55 getThreadListV2OrderedSection,
54 buildThreadListV2ListItems,56 buildThreadListV2ListItems,
55 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,57 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
56 THREAD_LIST_V2_SETTLED_PAGE_COUNT,58 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
...@@ -114,7 +116,7 @@ interface HomeScreenProps {...@@ -114,7 +116,7 @@ interface HomeScreenProps {
114 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;116 readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void;
115 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;117 readonly onPinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
116 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;118 readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
117 readonly onMovePinnedThread: (119 readonly onMoveThread: (
118 thread: EnvironmentThreadShell,120 thread: EnvironmentThreadShell,
119 direction: "up" | "down",121 direction: "up" | "down",
120 ) => Promise<boolean>;122 ) => Promise<boolean>;
...@@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) {
512 },514 },
513 [props.onPinThread],515 [props.onPinThread],
514 );516 );
515 const handleMovePinnedThread = useCallback(517 const handleMoveThread = useCallback(
516 (thread: EnvironmentThreadShell, direction: "up" | "down") => {518 (thread: EnvironmentThreadShell, direction: "up" | "down") => {
517 void props.onMovePinnedThread(thread, direction);519 void props.onMoveThread(thread, direction);
518 },520 },
519 [props.onMovePinnedThread],521 [props.onMoveThread],
520 );522 );
521 const handleUnpinThread = useCallback(523 const handleUnpinThread = useCallback(
522 (thread: EnvironmentThreadShell) => {524 (thread: EnvironmentThreadShell) => {
...@@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) {
608 }610 }
609 return supported;611 return supported;
610 }, [serverConfigs]);612 }, [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]);
611 const titleRegenerationEnvironmentIds = useMemo(() => {622 const titleRegenerationEnvironmentIds = useMemo(() => {
612 const supported = new Set<EnvironmentId>();623 const supported = new Set<EnvironmentId>();
613 for (const [environmentId, config] of serverConfigs) {624 for (const [environmentId, config] of serverConfigs) {
...@@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) {
627 ),638 ),
628 [serverConfigs],639 [serverConfigs],
629 );640 );
630 // Canonical arranged pinned order (reorder-capable threads only) for the641 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
631 // Move up/down position flags. Computed from all shells, not the rendered642 const threadMovePlanners = useMemo(() => {
632 // list, so search/scope filtering never disables or misdirects a move.643 const sectionPlanner = (section: "pinned" | "active") =>
633 const arrangedPinnedKeys = useMemo(() => {644 createThreadMovePlanner({
634 const pinned = sortPinnedThreadsByOrderKey(645 allThreads: props.threads,
635 props.threads.filter(646 section,
636 (thread) =>647 reorderableEnvironmentIds: new Set(
637 thread.pinnedAt != null &&648 [...serverConfigs].flatMap(([id, config]) =>
638 thread.archivedAt === null &&649 (section === "pinned"
639 pinReorderEnvironmentIds.has(thread.environmentId),650 ? config.environment.capabilities.threadPinReorder
640 ),651 : config.environment.capabilities.threadActiveReorder) === true
641 );652 ? [id]
642 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);653 : [],
643 }, [pinReorderEnvironmentIds, props.threads]);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 ]);
644 const threadListV2Layout = useMemo(() => {675 const threadListV2Layout = useMemo(() => {
645 if (!threadListV2Enabled)676 if (!threadListV2Enabled)
646 return {677 return {
...@@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) {
655 // Settled threads are live shells; archived threads keep their original686 // Settled threads are live shells; archived threads keep their original
656 // "hidden from lists" meaning.687 // "hidden from lists" meaning.
657 return buildThreadListV2Items({688 return buildThreadListV2Items({
689 pendingOrder,
658 threads: props.threads.filter((thread) => thread.archivedAt === null),690 threads: props.threads.filter((thread) => thread.archivedAt === null),
659 environmentId: props.selectedEnvironmentId,691 environmentId: props.selectedEnvironmentId,
660 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,692 projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs,
...@@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) {
669 selectedThreadKey: null,701 selectedThreadKey: null,
670 });702 });
671 }, [703 }, [
704 pendingOrder,
672 nowMinute,705 nowMinute,
673 snoozeWakeTick,706 snoozeWakeTick,
674 snoozedShelfExpanded,707 snoozedShelfExpanded,
...@@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) {
784 );817 );
785 }818 }
786 const thread = item.item.thread;819 const thread = item.item.thread;
820 const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
821 const movedId = `${thread.environmentId}:${thread.id}`;
787 return (822 return (
788 <ThreadListV2Row823 <ThreadListV2Row
789 thread={thread}824 thread={thread}
...@@ -830,18 +865,19 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -830,18 +865,19 @@ export function HomeScreen(props: HomeScreenProps) {
830 onSettleThread={handleSettleThread}865 onSettleThread={handleSettleThread}
831 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}866 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
832 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}867 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
833 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}868 reorderSupported={
834 canMovePinnedUp={arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0}869 item.item.pinned
835 canMovePinnedDown={(() => {870 ? pinReorderEnvironmentIds.has(thread.environmentId)
836 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);871 : activeReorderEnvironmentIds.has(thread.environmentId)
837 return index !== -1 && index < arrangedPinnedKeys.length - 1;872 }
838 })()}873 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
874 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
839 onSnoozeThread={handleSnoozeThread}875 onSnoozeThread={handleSnoozeThread}
840 onUnsnoozeThread={handleUnsnoozeThread}876 onUnsnoozeThread={handleUnsnoozeThread}
841 onUnsettleThread={handleUnsettleThread}877 onUnsettleThread={handleUnsettleThread}
842 onPinThread={handlePinThread}878 onPinThread={handlePinThread}
843 onUnpinThread={handleUnpinThread}879 onUnpinThread={handleUnpinThread}
844 onMovePinnedThread={handleMovePinnedThread}880 onMoveThread={handleMoveThread}
845 onSwipeableClose={handleSwipeableClose}881 onSwipeableClose={handleSwipeableClose}
846 onSwipeableWillOpen={handleSwipeableWillOpen}882 onSwipeableWillOpen={handleSwipeableWillOpen}
847 />883 />
...@@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) {
849 },885 },
850 [886 [
851 handleDeleteThread,887 handleDeleteThread,
852 arrangedPinnedKeys,888 activeReorderEnvironmentIds,
853 handleMovePinnedThread,889 threadMovePlanners,
890 pendingOrder,
891 handleMoveThread,
854 handlePinThread,892 handlePinThread,
855 handleRegenerateThreadTitle,893 handleRegenerateThreadTitle,
856 handleSettleThread,894 handleSettleThread,
apps/mobile/src/features/home/useThreadListActions.ts+61-47
...@@ -8,15 +8,14 @@ import { Alert } from "react-native";...@@ -8,15 +8,14 @@ import { Alert } from "react-native";
8import { showConfirmDialog } from "../../components/ConfirmDialogHost";8import { showConfirmDialog } from "../../components/ConfirmDialogHost";
9import { scopedThreadKey } from "../../lib/scopedEntities";9import { scopedThreadKey } from "../../lib/scopedEntities";
10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";10import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots";
11import {11import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort";
12 pinOrderKeyBetween,
13 planPinnedMove,
14 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";
16import { appAtomRegistry } from "../../state/atom-registry";12import { appAtomRegistry } from "../../state/atom-registry";
17import { environmentServerConfigsAtom } from "../../state/server";13import { environmentServerConfigsAtom } from "../../state/server";
18import { environmentThreadShells, threadEnvironment } from "../../state/threads";14import { environmentThreadShells, threadEnvironment } from "../../state/threads";
19import { useAtomCommand } from "../../state/use-atom-command";15import { 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
21/** Version skew: never send settle/unsettle to a server that predates them20/** Version skew: never send settle/unsettle to a server that predates them
22 (capability defaults false on decode for older servers). */21 (capability defaults false on decode for older servers). */
...@@ -222,7 +221,7 @@ export function useThreadListActions(): {...@@ -222,7 +221,7 @@ export function useThreadListActions(): {
222 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;221 readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
223 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;222 readonly pinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
224 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;223 readonly unpinThread: (thread: EnvironmentThreadShell) => Promise<boolean>;
225 readonly movePinnedThread: (224 readonly moveThread: (
226 thread: EnvironmentThreadShell,225 thread: EnvironmentThreadShell,
227 direction: "up" | "down",226 direction: "up" | "down",
228 ) => Promise<boolean>;227 ) => Promise<boolean>;
...@@ -451,60 +450,75 @@ export function useThreadListActions(): {...@@ -451,60 +450,75 @@ export function useThreadListActions(): {
451 [updateThreadMetadata],450 [updateThreadMetadata],
452 );451 );
453452
454 // Move up / Move down for the pinned block. Computed against the CANONICAL453 // Plan against the complete section so filtering does not change a move.
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).
459 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {454 const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, {
460 reportFailure: false,455 reportFailure: false,
461 });456 });
462 // One move at a time: a second tap before the first write's event lands457 const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, {
463 // would plan from the same stale snapshot and silently collapse two moves458 reportFailure: false,
464 // into one — same double-dispatch guard as snoozeThread.459 });
465 const movePinnedInFlightRef = useRef(false);460 const moveThread = useCallback(
466 const movePinnedThread = useCallback(
467 async (thread: EnvironmentThreadShell, direction: "up" | "down") => {461 async (thread: EnvironmentThreadShell, direction: "up" | "down") => {
468 if (movePinnedInFlightRef.current) return false;462 if (getPendingThreadOrder() !== null) return false;
469 if (!environmentSupportsPinReorder(thread.environmentId)) {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)) {
470 Alert.alert(472 Alert.alert(
471 "Could not move thread",473 "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.",
473 );475 );
474 return false;476 return false;
475 }477 }
476 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);478 const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom);
477 const pinned = sortPinnedThreadsByOrderKey(479 const ordered = getThreadListV2OrderedSection({
478 shells.filter(480 threads: shells,
479 (shell) =>481 section,
480 shell.pinnedAt != null &&482 now: new Date().toISOString(),
481 shell.archivedAt === null &&483 settlementEnvironmentIds: new Set(
482 environmentSupportsPinReorder(shell.environmentId),484 [...configs].flatMap(([id, config]) =>
485 config.environment.capabilities.threadSettlement === true ? [id] : [],
486 ),
483 ),487 ),
484 );488 snoozeEnvironmentIds: new Set(
485 const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id));489 [...configs].flatMap(([id, config]) =>
486 const assignments = planPinnedMove({490 config.environment.capabilities.threadSnooze === true ? [id] : [],
487 orderedIds,491 ),
488 keysById: new Map(
489 pinned.map((shell) => [
490 scopedThreadKey(shell.environmentId, shell.id),
491 shell.pinOrderKey ?? null,
492 ]),
493 ),492 ),
494 movedId: scopedThreadKey(thread.environmentId, thread.id),
495 direction,
496 });493 });
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;
498 const shellByKey = new Map(501 const shellByKey = new Map(
499 pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),502 ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]),
500 );503 );
501 selectionHaptic();504 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;
503 try {516 try {
504 for (const assignment of assignments) {517 for (const assignment of assignments) {
518 if (!pending.isPending()) return false;
505 const target = shellByKey.get(assignment.id);519 const target = shellByKey.get(assignment.id);
506 if (target === undefined) continue;520 if (target === undefined) continue;
507 const result = await reorderPinnedMutation({521 const result = await reorder({
508 environmentId: target.environmentId,522 environmentId: target.environmentId,
509 input: { threadId: target.id, orderKey: assignment.orderKey },523 input: { threadId: target.id, orderKey: assignment.orderKey },
510 });524 });
...@@ -514,20 +528,20 @@ export function useThreadListActions(): {...@@ -514,20 +528,20 @@ export function useThreadListActions(): {
514 "Could not move thread",528 "Could not move thread",
515 error instanceof Error && error.message.trim().length > 0529 error instanceof Error && error.message.trim().length > 0
516 ? error.message530 ? error.message
517 : "The pinned thread could not be moved.",531 : "The thread could not be moved.",
518 );532 );
519 // No rollback: keys already written are valid orderings on their533 // Keep confirmed keys when a later environment rejects its write.
520 // own (each write is a complete, consistent placement), so a
521 // partial materialization leaves the list sensible, not corrupt.
522 return false;534 return false;
523 }535 }
524 }536 }
537 succeeded = true;
538 pending.complete();
525 return true;539 return true;
526 } finally {540 } finally {
527 movePinnedInFlightRef.current = false;541 if (!succeeded) pending.cancel();
528 }542 }
529 },543 },
530 [reorderPinnedMutation],544 [reorderActiveMutation, reorderPinnedMutation],
531 );545 );
532546
533 const confirmDeleteThread = useConfirmDeleteThread(executeAction);547 const confirmDeleteThread = useConfirmDeleteThread(executeAction);
...@@ -541,7 +555,7 @@ export function useThreadListActions(): {...@@ -541,7 +555,7 @@ export function useThreadListActions(): {
541 unsettleThread,555 unsettleThread,
542 pinThread,556 pinThread,
543 unpinThread,557 unpinThread,
544 movePinnedThread,558 moveThread,
545 regenerateThreadTitle,559 regenerateThreadTitle,
546 };560 };
547}561}
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+64-25
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { createThreadMovePlanner } from "./threadOrder";
1import type {2import type {
2 EnvironmentProject,3 EnvironmentProject,
3 EnvironmentThreadShell,4 EnvironmentThreadShell,
...@@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native";...@@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native";
10import type { MenuAction } from "@react-native-menu/menu";11import type { MenuAction } from "@react-native-menu/menu";
11import { useAtomValue } from "@effect/atom-react";12import { useAtomValue } from "@effect/atom-react";
12import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";13import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts";
13import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort";
14import { useCallback, useEffect, useMemo, useRef, useState } from "react";14import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15import type { LayoutChangeEvent } from "react-native";15import type { LayoutChangeEvent } from "react-native";
16import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";16import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native";
...@@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities";...@@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities";
30import { useThreadSearch } from "../../state/queries";30import { useThreadSearch } from "../../state/queries";
31import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";31import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
32import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";32import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
33import { usePendingThreadOrder } from "../../state/thread-order";
33import { environmentServerConfigsAtom } from "../../state/server";34import { environmentServerConfigsAtom } from "../../state/server";
34import { usePendingNewTasks } from "../../state/use-pending-new-tasks";35import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
35import { useWorkspaceState } from "../../state/workspace";36import { useWorkspaceState } from "../../state/workspace";
...@@ -78,6 +79,7 @@ import {...@@ -78,6 +79,7 @@ import {
78} from "./thread-list-v2-items";79} from "./thread-list-v2-items";
79import {80import {
80 buildThreadListV2Items,81 buildThreadListV2Items,
82 getThreadListV2OrderedSection,
81 buildThreadListV2ListItems,83 buildThreadListV2ListItems,
82 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,84 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
83 THREAD_LIST_V2_SETTLED_PAGE_COUNT,85 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
...@@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane(...@@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane(
158 unsettleThread,160 unsettleThread,
159 pinThread,161 pinThread,
160 unpinThread,162 unpinThread,
161 movePinnedThread,163 moveThread,
162 regenerateThreadTitle,164 regenerateThreadTitle,
163 } = useThreadListActions();165 } = useThreadListActions();
164 const threadListV2Enabled = useThreadListV2Enabled();166 const threadListV2Enabled = useThreadListV2Enabled();
...@@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane(...@@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane(
437 }439 }
438 return supported;440 return supported;
439 }, [serverConfigs]);441 }, [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]);
440 const titleRegenerationEnvironmentIds = useMemo(() => {451 const titleRegenerationEnvironmentIds = useMemo(() => {
441 const supported = new Set<EnvironmentId>();452 const supported = new Set<EnvironmentId>();
442 for (const [environmentId, config] of serverConfigs) {453 for (const [environmentId, config] of serverConfigs) {
...@@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane(...@@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane(
456 ),467 ),
457 [serverConfigs],468 [serverConfigs],
458 );469 );
459 // Canonical arranged pinned order for Move up/down flags — computed from470 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
460 // all shells so search/scope filtering never disables a valid move.471 const threadMovePlanners = useMemo(() => {
461 const arrangedPinnedKeys = useMemo(() => {472 const sectionPlanner = (section: "pinned" | "active") =>
462 const pinned = sortPinnedThreadsByOrderKey(473 createThreadMovePlanner({
463 threads.filter(474 allThreads: threads,
464 (thread) =>475 section,
465 thread.pinnedAt != null &&476 reorderableEnvironmentIds: new Set(
466 thread.archivedAt === null &&477 [...serverConfigs].flatMap(([id, config]) =>
467 pinReorderEnvironmentIds.has(thread.environmentId),478 (section === "pinned"
468 ),479 ? config.environment.capabilities.threadPinReorder
469 );480 : config.environment.capabilities.threadActiveReorder) === true
470 return pinned.map((thread) => `${thread.environmentId}:${thread.id}`);481 ? [id]
471 }, [pinReorderEnvironmentIds, threads]);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 ]);
472 const threadListV2Layout = useMemo(() => {504 const threadListV2Layout = useMemo(() => {
473 if (!threadListV2Enabled)505 if (!threadListV2Enabled)
474 return {506 return {
...@@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane(...@@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane(
481 nextSnoozeWakeAt: null,513 nextSnoozeWakeAt: null,
482 };514 };
483 return buildThreadListV2Items({515 return buildThreadListV2Items({
516 pendingOrder,
484 threads: threads.filter((thread) => thread.archivedAt === null),517 threads: threads.filter((thread) => thread.archivedAt === null),
485 environmentId: options.selectedEnvironmentId,518 environmentId: options.selectedEnvironmentId,
486 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,519 projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs,
...@@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane(...@@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane(
495 selectedThreadKey: props.selectedThreadKey ?? null,528 selectedThreadKey: props.selectedThreadKey ?? null,
496 });529 });
497 }, [530 }, [
531 pendingOrder,
498 nowMinute,532 nowMinute,
499 snoozeWakeTick,533 snoozeWakeTick,
500 snoozedShelfExpanded,534 snoozedShelfExpanded,
...@@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane(...@@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane(
844 }878 }
845 case "v2-thread": {879 case "v2-thread": {
846 const thread = item.item.thread;880 const thread = item.item.thread;
881 const movePlanner = item.item.pinned
882 ? threadMovePlanners.pinned
883 : threadMovePlanners.active;
884 const movedId = `${thread.environmentId}:${thread.id}`;
847 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);885 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
848 return (886 return (
849 <ThreadListV2Row887 <ThreadListV2Row
...@@ -891,20 +929,19 @@ function ThreadNavigationSidebarPane(...@@ -891,20 +929,19 @@ function ThreadNavigationSidebarPane(
891 onSettleThread={settleThread}929 onSettleThread={settleThread}
892 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}930 snoozeSupported={snoozeEnvironmentIds.has(thread.environmentId)}
893 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}931 pinningSupported={pinningEnvironmentIds.has(thread.environmentId)}
894 pinReorderSupported={pinReorderEnvironmentIds.has(thread.environmentId)}932 reorderSupported={
895 canMovePinnedUp={933 item.item.pinned
896 arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`) > 0934 ? pinReorderEnvironmentIds.has(thread.environmentId)
935 : activeReorderEnvironmentIds.has(thread.environmentId)
897 }936 }
898 canMovePinnedDown={(() => {937 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
899 const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`);938 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
900 return index !== -1 && index < arrangedPinnedKeys.length - 1;
901 })()}
902 onSnoozeThread={snoozeThread}939 onSnoozeThread={snoozeThread}
903 onUnsnoozeThread={unsnoozeThread}940 onUnsnoozeThread={unsnoozeThread}
904 onUnsettleThread={unsettleThread}941 onUnsettleThread={unsettleThread}
905 onPinThread={pinThread}942 onPinThread={pinThread}
906 onUnpinThread={unpinThread}943 onUnpinThread={unpinThread}
907 onMovePinnedThread={movePinnedThread}944 onMoveThread={moveThread}
908 onSwipeableClose={handleSwipeableClose}945 onSwipeableClose={handleSwipeableClose}
909 onSwipeableWillOpen={handleSwipeableWillOpen}946 onSwipeableWillOpen={handleSwipeableWillOpen}
910 simultaneousSwipeGesture={sidebarScrollGesture}947 simultaneousSwipeGesture={sidebarScrollGesture}
...@@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane(...@@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane(
1027 },1064 },
1028 [1065 [
1029 archiveThread,1066 archiveThread,
1030 arrangedPinnedKeys,1067 activeReorderEnvironmentIds,
1068 threadMovePlanners,
1069 pendingOrder,
1031 confirmDeletePendingTask,1070 confirmDeletePendingTask,
1032 confirmDeleteThread,1071 confirmDeleteThread,
1033 handleSelectThread,1072 handleSelectThread,
1034 handleSwipeableClose,1073 handleSwipeableClose,
1035 handleSwipeableWillOpen,1074 handleSwipeableWillOpen,
1036 machineByEnvironmentId,1075 machineByEnvironmentId,
1037 movePinnedThread,1076 moveThread,
1038 openPendingTask,1077 openPendingTask,
1039 pinReorderEnvironmentIds,1078 pinReorderEnvironmentIds,
1040 pinThread,1079 pinThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+51-52
...@@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
367 readonly pinningSupported: boolean;367 readonly pinningSupported: boolean;
368 /** False on servers that predate thread title regeneration. */368 /** False on servers that predate thread title regeneration. */
369 readonly titleRegenerationSupported: boolean;369 readonly titleRegenerationSupported: boolean;
370 /** False on servers that predate thread.pin.reorder. Gates the pinned370 /** Server supports reordering this card's section. */
371 Move up / Move down menu items. */371 readonly reorderSupported?: boolean;
372 readonly pinReorderSupported?: boolean;372 readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;
373 readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void;373 /** Position flags for the card's section so the menu disables the move that
374 /** Position flags for the pinned block so the menu disables the move that
375 would fall off the end of the list. */374 would fall off the end of the list. */
376 readonly canMovePinnedUp?: boolean;375 readonly canMoveUp?: boolean;
377 readonly canMovePinnedDown?: boolean;376 readonly canMoveDown?: boolean;
378 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;377 readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void;
379 readonly onSwipeableClose: (methods: SwipeableMethods) => void;378 readonly onSwipeableClose: (methods: SwipeableMethods) => void;
380 readonly searchMatch?: EnvironmentThreadSearchMatch;379 readonly searchMatch?: EnvironmentThreadSearchMatch;
...@@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
397 onArchiveThread,396 onArchiveThread,
398 onPinThread,397 onPinThread,
399 onUnpinThread,398 onUnpinThread,
400 onMovePinnedThread,399 onMoveThread,
401 } = props;400 } = props;
402 const snoozedRow = props.snoozed === true;401 const snoozedRow = props.snoozed === true;
403 const pinnedRow = props.pinned === true;402 const pinnedRow = props.pinned === true;
...@@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
436 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);435 const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]);
437 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);436 const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]);
438 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);437 const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]);
439 const handleMovePinnedUp = useCallback(438 const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]);
440 () => onMovePinnedThread?.(thread, "up"),439 const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]);
441 [onMovePinnedThread, thread],
442 );
443 const handleMovePinnedDown = useCallback(
444 () => onMovePinnedThread?.(thread, "down"),
445 [onMovePinnedThread, thread],
446 );
447 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);440 const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]);
448441
449 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a442 // Swipe: the v2 primary action is the lifecycle transition. Un-settling a
...@@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
482 // Pinned cards keep the full lifecycle menu; only the pin item flips to475 // Pinned cards keep the full lifecycle menu; only the pin item flips to
483 // Unpin. (Settling a pinned thread clears the pin server-side; snoozing476 // Unpin. (Settling a pinned thread clears the pin server-side; snoozing
484 // hides the card until wake with the pin intact.)477 // hides the card until wake with the pin intact.)
485 const pinMenuItem = useMemo<MenuAction[]>(478 const arrangementMenuItems = useMemo<MenuAction[]>(
486 () =>479 () => [
487 props.pinningSupported480 ...(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
488 ? [497 ? [
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 : []),
505 thread.pinnedAt != null498 thread.pinnedAt != null
506 ? { id: "unpin", title: "Unpin", image: "pin.slash" }499 ? { id: "unpin", title: "Unpin", image: "pin.slash" }
507 : { id: "pin", title: "Pin", image: "pin" },500 : { id: "pin", title: "Pin", image: "pin" },
508 ]501 ]
509 : [],502 : []),
503 ],
510 [504 [
511 pinnedRow,505 props.canMoveDown,
512 props.canMovePinnedDown,506 props.canMoveUp,
513 props.canMovePinnedUp,507 props.reorderSupported,
514 props.pinReorderSupported,
515 props.pinningSupported,508 props.pinningSupported,
516 thread.pinnedAt,509 thread.pinnedAt,
510 variant,
517 ],511 ],
518 );512 );
519 const titleRegenerationMenuItems = useMemo<MenuAction[]>(513 const titleRegenerationMenuItems = useMemo<MenuAction[]>(
...@@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
533 image: "clock",527 image: "clock",
534 subactions: snoozePresetActions,528 subactions: snoozePresetActions,
535 },529 },
536 ...pinMenuItem,530 ...arrangementMenuItems,
537 ...titleRegenerationMenuItems,531 ...titleRegenerationMenuItems,
538 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },532 { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } },
539 ],533 ],
540 [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems],534 [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems],
541 );535 );
542 const cardMenuActions = useMemo<MenuAction[]>(536 const cardMenuActions = useMemo<MenuAction[]>(
543 () => [537 () => [
544 CARD_MENU_ACTIONS[0]!,538 CARD_MENU_ACTIONS[0]!,
545 ...pinMenuItem,539 ...arrangementMenuItems,
546 ...titleRegenerationMenuItems,540 ...titleRegenerationMenuItems,
547 ...CARD_MENU_ACTIONS.slice(1),541 ...CARD_MENU_ACTIONS.slice(1),
548 ],542 ],
549 [pinMenuItem, titleRegenerationMenuItems],543 [arrangementMenuItems, titleRegenerationMenuItems],
550 );544 );
551 const slimMenuActions = useMemo<MenuAction[]>(545 const slimMenuActions = useMemo<MenuAction[]>(
552 () => [546 () => [
553 SLIM_MENU_ACTIONS[0]!,547 SLIM_MENU_ACTIONS[0]!,
554 ...(thread.pinnedAt != null ? pinMenuItem : []),548 ...(thread.pinnedAt != null ? arrangementMenuItems : []),
555 ...titleRegenerationMenuItems,549 ...titleRegenerationMenuItems,
556 SLIM_MENU_ACTIONS[1]!,550 SLIM_MENU_ACTIONS[1]!,
557 ],551 ],
558 [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems],552 [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems],
559 );553 );
560 const snoozedMenuActions = useMemo<MenuAction[]>(554 const snoozedMenuActions = useMemo<MenuAction[]>(
561 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],555 () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!],
562 [titleRegenerationMenuItems],556 [titleRegenerationMenuItems],
563 );557 );
564 const legacyMenuActions = useMemo<MenuAction[]>(558 const legacyMenuActions = useMemo<MenuAction[]>(
565 () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!],559 () => [
566 [titleRegenerationMenuItems],560 LEGACY_MENU_ACTIONS[0]!,
561 ...arrangementMenuItems,
562 ...titleRegenerationMenuItems,
563 LEGACY_MENU_ACTIONS[1]!,
564 ],
565 [arrangementMenuItems, titleRegenerationMenuItems],
567 );566 );
568 const handleMenuAction = useCallback(567 const handleMenuAction = useCallback(
569 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {568 ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => {
...@@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
572 if (nativeEvent.event === "unsnooze") handleUnsnooze();571 if (nativeEvent.event === "unsnooze") handleUnsnooze();
573 if (nativeEvent.event === "pin") handlePin();572 if (nativeEvent.event === "pin") handlePin();
574 if (nativeEvent.event === "unpin") handleUnpin();573 if (nativeEvent.event === "unpin") handleUnpin();
575 if (nativeEvent.event === "move-pin-up") handleMovePinnedUp();574 if (nativeEvent.event === "move-up") handleMoveUp();
576 if (nativeEvent.event === "move-pin-down") handleMovePinnedDown();575 if (nativeEvent.event === "move-down") handleMoveDown();
577 if (nativeEvent.event === "archive") handleArchive();576 if (nativeEvent.event === "archive") handleArchive();
578 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();577 if (nativeEvent.event === "regenerate-title") handleRegenerateTitle();
579 if (nativeEvent.event === "delete") handleDelete();578 if (nativeEvent.event === "delete") handleDelete();
...@@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
592 handleArchive,591 handleArchive,
593 handleDelete,592 handleDelete,
594 handleRegenerateTitle,593 handleRegenerateTitle,
595 handleMovePinnedDown,594 handleMoveDown,
596 handleMovePinnedUp,595 handleMoveUp,
597 handlePin,596 handlePin,
598 handleSettle,597 handleSettle,
599 handleSnooze,598 handleSnooze,
apps/mobile/src/features/threads/threadListV2.test.ts+293
...@@ -1,3 +1,10 @@...@@ -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";
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";8import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";9import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
3import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";10import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
...@@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";...@@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks";
16import {23import {
17 buildThreadListV2Items,24 buildThreadListV2Items,
18 buildThreadListV2ListItems,25 buildThreadListV2ListItems,
26 getThreadListV2OrderedSection,
19 resolveThreadListV2Enabled,27 resolveThreadListV2Enabled,
20 resolveThreadListV2SnoozeMenuSelection,28 resolveThreadListV2SnoozeMenuSelection,
21 resolveThreadListV2SnoozeGateExpiryMs,29 resolveThreadListV2SnoozeGateExpiryMs,
...@@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => {...@@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => {
258});266});
259267
260describe("sortThreadsForListV2", () => {268describe("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
261 it("orders by creation time, newest first, ignoring activity", () => {278 it("orders by creation time, newest first, ignoring activity", () => {
262 const sorted = sortThreadsForListV2([279 const sorted = sortThreadsForListV2([
263 { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },280 { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" },
...@@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => {...@@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => {
281 });298 });
282});299});
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
284describe("buildThreadListV2Items", () => {350describe("buildThreadListV2Items", () => {
285 it("places a persisted settled thread in the settled shelf", () => {351 it("places a persisted settled thread in the settled shelf", () => {
286 const thread = makeThread({352 const thread = makeThread({
...@@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => {...@@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => {
937 ]);1003 ]);
938 });1004 });
939});1005});
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"...@@ -9,7 +9,7 @@ import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"
9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";9import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";10import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
11import {11import {
12 activeThreadAnchorTimestampMs,12 sortActiveThreadsByOrderKey,
13 resolveSettledThreadTimestamp,13 resolveSettledThreadTimestamp,
14 sortPinnedThreadsByOrderKey,14 sortPinnedThreadsByOrderKey,
15} from "@t3tools/client-runtime/state/thread-sort";15} from "@t3tools/client-runtime/state/thread-sort";
...@@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";...@@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1717
18import type { PendingNewTask } from "../../state/use-pending-new-tasks";18import type { PendingNewTask } from "../../state/use-pending-new-tasks";
1919
20import {
21 applyPendingThreadOrder,
22 reconcilePendingThreadOrder,
23 type PendingThreadOrder,
24} from "./threadOrder";
25
20export { snoozeWakeLabel };26export { snoozeWakeLabel };
2127
22/**28/**
...@@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number {...@@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number {
150 return Number.isNaN(parsed) ? 0 : parsed;156 return Number.isNaN(parsed) ? 0 : parsed;
151}157}
152158
153/**159/** The active order shared by web and native: new/reopened rows, then the
154 * v2 sort: static order, newest anchor on top. Activity NEVER reorders the160 saved arrangement. Activity does not move a thread. */
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 */
161export function sortThreadsForListV2<161export function sortThreadsForListV2<
162 T extends {162 T extends {
163 readonly id: string;163 readonly id: string;
164 readonly createdAt: string;164 readonly createdAt: string;
165 readonly unsettledAt?: string | null | undefined;165 readonly unsettledAt?: string | null | undefined;
166 readonly activeOrderKey?: string | null | undefined;
167 readonly environmentId?: string | undefined;
166 },168 },
167>(threads: readonly T[]): T[] {169>(threads: readonly T[]): T[] {
168 // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023170 return sortActiveThreadsByOrderKey(threads);
169 // change-by-copy array methods.171}
170 return [...threads].sort(172
171 (left, right) =>173/** Canonical card section for Move up/down, independent of search or scope. */
172 activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) ||174export function getThreadListV2OrderedSection(input: {
173 left.id.localeCompare(right.id),175 readonly threads: readonly EnvironmentThreadShell[];
174 );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);
175}207}
176208
177export interface ThreadListV2Item {209export interface ThreadListV2Item {
...@@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: {...@@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: {
299}331}
300332
301/**333/**
302 * Partitions visible threads into the active card block (creation order) and334 * Partitions visible threads into the active card block (saved order) and
303 * the settled recency tail, matching the web v2 list.335 * the settled recency tail, matching the web v2 list.
304 */336 */
305export function buildThreadListV2Items(input: {337export function buildThreadListV2Items(input: {
338 readonly pendingOrder?: PendingThreadOrder | null;
306 readonly threads: ReadonlyArray<EnvironmentThreadShell>;339 readonly threads: ReadonlyArray<EnvironmentThreadShell>;
307 readonly environmentId: EnvironmentId | null;340 readonly environmentId: EnvironmentId | null;
308 readonly projectRefs?: ReadonlyArray<{341 readonly projectRefs?: ReadonlyArray<{
...@@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: {...@@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: {
331 readonly selectedThreadKey?: string | null;364 readonly selectedThreadKey?: string | null;
332}): ThreadListV2Layout {365}): ThreadListV2Layout {
333 const now = input.now;366 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 );
334 const query = input.searchQuery.trim().toLocaleLowerCase();378 const query = input.searchQuery.trim().toLocaleLowerCase();
335 const projectKeys = input.projectRefs379 const projectKeys = input.projectRefs
336 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))380 ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`))
...@@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: {...@@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: {
382 }426 }
383 }427 }
384428
385 const orderedActive = sortThreadsForListV2(active);429 const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending);
386 const orderedSnoozed = [...snoozed].sort(430 const orderedSnoozed = [...snoozed].sort(
387 (left, right) =>431 (left, right) =>
388 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),432 parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""),
...@@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: {...@@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: {
414 );458 );
415459
416 const items: ThreadListV2Item[] = [];460 const items: ThreadListV2Item[] = [];
417 for (const thread of sortPinnedThreadsByOrderKey(pinned)) {461 for (const thread of applyPendingThreadOrder(
462 sortPinnedThreadsByOrderKey(pinned),
463 "pinned",
464 pending,
465 )) {
418 items.push({466 items.push({
419 thread,467 thread,
420 variant: "card",468 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(...@@ -62,6 +62,10 @@ function threadDetailToShell(
62 archivedAt: thread.archivedAt,62 archivedAt: thread.archivedAt,
63 settledOverride: thread.settledOverride,63 settledOverride: thread.settledOverride,
64 settledAt: thread.settledAt,64 settledAt: thread.settledAt,
65 unsettledAt: thread.unsettledAt,
66 activeOrderKey: thread.activeOrderKey,
67 pinnedAt: thread.pinnedAt,
68 pinOrderKey: thread.pinOrderKey,
65 snoozedUntil: thread.snoozedUntil ?? null,69 snoozedUntil: thread.snoozedUntil ?? null,
66 snoozedAt: thread.snoozedAt ?? null,70 snoozedAt: thread.snoozedAt ?? null,
67 session: thread.session,71 session: thread.session,