| author | |
| committer | |
| log | 6766e682ac1a0aa33020358ba98707f4f8fee467 |
| tree | 81138b63689c5d63820faaba488fe4952b9bf762 |
| parent | 2d645df474f0af5e261edb56a79f7a2a9a2d442e |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ |
| 1 | import { createThreadMovePlanner } from "../threads/threadOrder"; | ||
| 1 | import { | 2 | import { |
| 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"; |
| 14 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 15 | import { | 15 | import { |
| 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"; |
| 35 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 35 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 36 | import { useThreadSearch } from "../../state/queries"; | 36 | import { useThreadSearch } from "../../state/queries"; |
| 37 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 37 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 38 | import { usePendingThreadOrder } from "../../state/thread-order"; | ||
| 38 | import { environmentServerConfigsAtom } from "../../state/server"; | 39 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 39 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 40 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 40 | import { | 41 | import { |
| ... | @@ -51,6 +52,7 @@ import { | ... | @@ -51,6 +52,7 @@ import { |
| 51 | } from "../threads/thread-list-v2-items"; | 52 | } from "../threads/thread-list-v2-items"; |
| 52 | import { | 53 | import { |
| 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 the | 641 | const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); |
| 631 | // Move up/down position flags. Computed from all shells, not the rendered | 642 | 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 original | 686 | // 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 | <ThreadListV2Row | 823 | <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"; |
| 8 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; | 8 | import { showConfirmDialog } from "../../components/ConfirmDialogHost"; |
| 9 | import { scopedThreadKey } from "../../lib/scopedEntities"; | 9 | import { scopedThreadKey } from "../../lib/scopedEntities"; |
| 10 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; | 10 | import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; |
| 11 | import { | 11 | import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; |
| 12 | pinOrderKeyBetween, | ||
| 13 | planPinnedMove, | ||
| 14 | sortPinnedThreadsByOrderKey, | ||
| 15 | } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 16 | import { appAtomRegistry } from "../../state/atom-registry"; | 12 | import { appAtomRegistry } from "../../state/atom-registry"; |
| 17 | import { environmentServerConfigsAtom } from "../../state/server"; | 13 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 18 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; | 14 | import { environmentThreadShells, threadEnvironment } from "../../state/threads"; |
| 19 | import { useAtomCommand } from "../../state/use-atom-command"; | 15 | import { useAtomCommand } from "../../state/use-atom-command"; |
| 16 | import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; | ||
| 17 | import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; | ||
| 18 | import { getThreadListV2OrderedSection } from "../threads/threadListV2"; | ||
| 20 | 19 | ||
| 21 | /** Version skew: never send settle/unsettle to a server that predates them | 20 | /** 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 | ); |
| 453 | 452 | ||
| 454 | // Move up / Move down for the pinned block. Computed against the CANONICAL | 453 | // 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 lands | 457 | const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, { |
| 463 | // would plan from the same stale snapshot and silently collapse two moves | 458 | 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 > 0 | 529 | error instanceof Error && error.message.trim().length > 0 |
| 516 | ? error.message | 530 | ? 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 their | 533 | // 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 | ); |
| 532 | 546 | ||
| 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 @@ |
| 1 | import { createThreadMovePlanner } from "./threadOrder"; | ||
| 1 | import type { | 2 | import 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"; |
| 10 | import type { MenuAction } from "@react-native-menu/menu"; | 11 | import type { MenuAction } from "@react-native-menu/menu"; |
| 11 | import { useAtomValue } from "@effect/atom-react"; | 12 | import { useAtomValue } from "@effect/atom-react"; |
| 12 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; | 13 | import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; |
| 13 | import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 14 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 14 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 15 | import type { LayoutChangeEvent } from "react-native"; | 15 | import type { LayoutChangeEvent } from "react-native"; |
| 16 | import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; | 16 | import { 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"; |
| 30 | import { useThreadSearch } from "../../state/queries"; | 30 | import { useThreadSearch } from "../../state/queries"; |
| 31 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; | 31 | import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; |
| 32 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; | 32 | import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; |
| 33 | import { usePendingThreadOrder } from "../../state/thread-order"; | ||
| 33 | import { environmentServerConfigsAtom } from "../../state/server"; | 34 | import { environmentServerConfigsAtom } from "../../state/server"; |
| 34 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; | 35 | import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; |
| 35 | import { useWorkspaceState } from "../../state/workspace"; | 36 | import { 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"; |
| 79 | import { | 80 | import { |
| 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 from | 470 | 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 | <ThreadListV2Row | 887 | <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}`) > 0 | 934 | ? 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 pinned | 370 | /** 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]); |
| 448 | 441 | ||
| 449 | // Swipe: the v2 primary action is the lifecycle transition. Un-settling a | 442 | // 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 to | 475 | // Pinned cards keep the full lifecycle menu; only the pin item flips to |
| 483 | // Unpin. (Settling a pinned thread clears the pin server-side; snoozing | 476 | // 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.pinningSupported | 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 | ||
| 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 != null | 498 | 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 @@ |
| 1 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 2 | import { | ||
| 3 | createPendingThreadOrder, | ||
| 4 | createThreadMovePlanner, | ||
| 5 | reconcilePendingThreadOrder, | ||
| 6 | type PendingThreadOrder, | ||
| 7 | } from "./threadOrder"; | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 8 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 9 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 3 | import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; | 10 | import { 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"; |
| 16 | import { | 23 | import { |
| 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 | }); |
| 259 | 267 | ||
| 260 | describe("sortThreadsForListV2", () => { | 268 | describe("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 | }); |
| 283 | 300 | ||
| 301 | describe("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 | |||
| 284 | describe("buildThreadListV2Items", () => { | 350 | describe("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 | |||
| 1007 | describe("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 | |||
| 1140 | describe("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" |
| 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | 9 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 10 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 11 | import { | 11 | import { |
| 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"; |
| 17 | 17 | ||
| 18 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; | 18 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | 19 | ||
| 20 | import { | ||
| 21 | applyPendingThreadOrder, | ||
| 22 | reconcilePendingThreadOrder, | ||
| 23 | type PendingThreadOrder, | ||
| 24 | } from "./threadOrder"; | ||
| 25 | |||
| 20 | export { snoozeWakeLabel }; | 26 | export { snoozeWakeLabel }; |
| 21 | 27 | ||
| 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 | } |
| 152 | 158 | ||
| 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 the | 160 | 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 | */ | ||
| 161 | export function sortThreadsForListV2< | 161 | export 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 ES2023 | 170 | 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) || | 174 | export 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 | } |
| 176 | 208 | ||
| 177 | export interface ThreadListV2Item { | 209 | export interface ThreadListV2Item { |
| ... | @@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: { | ... | @@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: { |
| 299 | } | 331 | } |
| 300 | 332 | ||
| 301 | /** | 333 | /** |
| 302 | * Partitions visible threads into the active card block (creation order) and | 334 | * 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 | */ |
| 305 | export function buildThreadListV2Items(input: { | 337 | export 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.projectRefs | 379 | 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 | } |
| 384 | 428 | ||
| 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 | ); |
| 415 | 459 | ||
| 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 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; | ||
| 3 | import type { EnvironmentId } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | type OrderRow = Pick< | ||
| 6 | EnvironmentThreadShell, | ||
| 7 | | "id" | ||
| 8 | | "environmentId" | ||
| 9 | | "pinOrderKey" | ||
| 10 | | "activeOrderKey" | ||
| 11 | | "createdAt" | ||
| 12 | | "unsettledAt" | ||
| 13 | | "pinnedAt" | ||
| 14 | >; | ||
| 15 | |||
| 16 | export 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 | |||
| 25 | function rowId(row: OrderRow): string { | ||
| 26 | return `${row.environmentId}:${row.id}`; | ||
| 27 | } | ||
| 28 | |||
| 29 | function 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. */ | ||
| 38 | export 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 | |||
| 67 | export 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. */ | ||
| 90 | export 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. */ | ||
| 110 | export 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 @@ | ||
| 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; | ||
| 2 | import { EnvironmentId, ThreadId } from "@t3tools/contracts"; | ||
| 3 | import type { Atom } from "effect/unstable/reactivity"; | ||
| 4 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 5 | |||
| 6 | import { createPendingThreadOrder } from "../features/threads/threadOrder"; | ||
| 7 | import { appAtomRegistry } from "./atom-registry"; | ||
| 8 | import { | ||
| 9 | beginPendingThreadOrder, | ||
| 10 | getPendingThreadOrder, | ||
| 11 | pendingThreadOrderAtom, | ||
| 12 | } from "./thread-order"; | ||
| 13 | import { environmentThreadShells } from "./threads"; | ||
| 14 | |||
| 15 | vi.mock("./atom-registry", async () => { | ||
| 16 | const { AtomRegistry } = await import("effect/unstable/reactivity"); | ||
| 17 | return { appAtomRegistry: AtomRegistry.make() }; | ||
| 18 | }); | ||
| 19 | vi.mock("./threads", async () => { | ||
| 20 | const { Atom } = await import("effect/unstable/reactivity"); | ||
| 21 | return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; | ||
| 22 | }); | ||
| 23 | vi.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. | ||
| 29 | const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< | ||
| 30 | readonly EnvironmentThreadShell[], | ||
| 31 | readonly EnvironmentThreadShell[] | ||
| 32 | >; | ||
| 33 | |||
| 34 | function 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 | |||
| 69 | afterEach(() => appAtomRegistry.reset()); | ||
| 70 | |||
| 71 | describe("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 @@ | ||
| 1 | import { useAtomValue } from "@effect/atom-react"; | ||
| 2 | import { useEffect } from "react"; | ||
| 3 | import { Atom } from "effect/unstable/reactivity"; | ||
| 4 | |||
| 5 | import { | ||
| 6 | reconcilePendingThreadOrder, | ||
| 7 | type PendingThreadOrder, | ||
| 8 | } from "../features/threads/threadOrder"; | ||
| 9 | import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; | ||
| 10 | import { appAtomRegistry } from "./atom-registry"; | ||
| 11 | import { environmentServerConfigsAtom } from "./server"; | ||
| 12 | import { environmentThreadShells } from "./threads"; | ||
| 13 | |||
| 14 | export const pendingThreadOrderAtom = Atom.make<PendingThreadOrder | null>(null).pipe( | ||
| 15 | Atom.keepAlive, | ||
| 16 | ); | ||
| 17 | |||
| 18 | export 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 | |||
| 28 | let refreshPendingOrder: (() => void) | undefined; | ||
| 29 | |||
| 30 | /** Shared by Home and the navigation sidebar, including their action guards. */ | ||
| 31 | export function getPendingThreadOrder(): PendingThreadOrder | null { | ||
| 32 | refreshPendingOrder?.(); | ||
| 33 | return appAtomRegistry.get(pendingThreadOrderAtom); | ||
| 34 | } | ||
| 35 | |||
| 36 | export 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, |