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