| author | |
| committer | |
| log | 151324b2c0bf0ace789820b8ea4ec8ceb55753d9 |
| tree | aba27e126283e9111980d76145b7e6dd3ed9a7df |
| parent | 219c1d265d917f0e7083b9033f4014c0c2940463 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 1378 insertions(+), 121 deletions(-)
apps/mobile/src/features/home/HomeScreen.tsx+70-37| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import type { ThreadMoveDestination } from "../threads/threadOrder"; |
| 2 | import { createThreadMovePlanner } from "../threads/threadOrder"; | |
| 2 | import { computeThreadMoveAvailability } from "../threads/threadOrder"; | |
| 3 | 3 | import { |
| 4 | 4 | LegendList, |
| 5 | 5 | type LegendListRef, |
| ... | ... | @@ -23,7 +23,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; |
| 23 | 23 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 24 | 24 | import { useFocusEffect } from "@react-navigation/native"; |
| 25 | 25 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 26 | import { ActivityIndicator, FlatList, Platform, View } from "react-native"; | |
| 26 | import { ActivityIndicator, Platform, View } from "react-native"; | |
| 27 | 27 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 28 | 28 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 29 | 29 | |
| ... | ... | @@ -55,11 +55,12 @@ import { |
| 55 | 55 | ThreadListV2ShowMoreRow, |
| 56 | 56 | ThreadListV2SnoozedShelfHeader, |
| 57 | 57 | } from "../threads/thread-list-v2-items"; |
| 58 | import { resolveThreadProviderInstance } from "../threads/thread-provider-instance"; | |
| 58 | import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance"; | |
| 59 | 59 | import { |
| 60 | 60 | buildThreadListV2Items, |
| 61 | 61 | getThreadListV2OrderedSection, |
| 62 | 62 | buildThreadListV2ListItems, |
| 63 | threadListV2ListItemsAreEqual, | |
| 63 | 64 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 64 | 65 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 65 | 66 | type ThreadListV2ListItem, |
| ... | ... | @@ -138,6 +139,21 @@ interface HomeScreenProps { |
| 138 | 139 | /* ─── Layout constants ───────────────────────────────────────────────── */ |
| 139 | 140 | |
| 140 | 141 | const ESTIMATED_THREAD_ROW_HEIGHT = 72; |
| 142 | // v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards | |
| 143 | // measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line | |
| 144 | // cards ~94dp. The estimate seeds the recycler's initial container count, | |
| 145 | // `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the | |
| 146 | // initial draw distance capped at 50, so an estimate at or below the average | |
| 147 | // row height starts the pool at or above the item count for the short lists | |
| 148 | // that LegendList otherwise keeps pooling to exactly its item count — that is | |
| 149 | // what stopped the dev-mode "no unused container available" warning on the | |
| 150 | // seeded short-list device passes. It is a mitigation, not an elimination: | |
| 151 | // after first layout the full drawDistance applies, and a sudden expansion | |
| 152 | // past the pooled headroom (~25+ items appearing at once) still creates a | |
| 153 | // container on demand with the dev-only warning one pass ahead of the | |
| 154 | // measured-height pool expansion. The old tallest-card estimate (~92) fired | |
| 155 | // that warning on every ordinary shelf expand, so the average wins. | |
| 156 | const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; | |
| 141 | 157 | const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; |
| 142 | 158 | /** |
| 143 | 159 | * Top spacing between the list and the Android custom header. The Android |
| ... | ... | @@ -655,12 +671,19 @@ export function HomeScreen(props: HomeScreenProps) { |
| 655 | 671 | ), |
| 656 | 672 | [serverConfigs], |
| 657 | 673 | ); |
| 674 | // Reference-stable provider glyphs: a fresh object per render would break | |
| 675 | // the memoized rows' props comparison on every parent render. | |
| 676 | const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); | |
| 658 | 677 | const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); |
| 659 | const threadMovePlanners = useMemo(() => { | |
| 660 | const sectionPlanner = (section: "pinned" | "active") => | |
| 661 | createThreadMovePlanner({ | |
| 678 | // Up/down menu availability for every card, computed once per section per | |
| 679 | // rebuild (see computeThreadMoveAvailability): per-thread planner calls made | |
| 680 | // list construction quadratic, and this list rebuilds on every minute tick. | |
| 681 | const threadMoveAvailability = useMemo(() => { | |
| 682 | const sectionAvailability = (section: "pinned" | "active") => | |
| 683 | computeThreadMoveAvailability({ | |
| 662 | 684 | allThreads: props.threads, |
| 663 | 685 | section, |
| 686 | pendingOrder, | |
| 664 | 687 | reorderableEnvironmentIds: new Set( |
| 665 | 688 | [...serverConfigs].flatMap(([id, config]) => |
| 666 | 689 | (section === "pinned" |
| ... | ... | @@ -680,7 +703,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 680 | 703 | queuedThreadKeys, |
| 681 | 704 | }), |
| 682 | 705 | }); |
| 683 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; | |
| 706 | return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); | |
| 684 | 707 | }, [ |
| 685 | 708 | serverConfigs, |
| 686 | 709 | props.threads, |
| ... | ... | @@ -783,8 +806,22 @@ export function HomeScreen(props: HomeScreenProps) { |
| 783 | 806 | settledShelfExpanded, |
| 784 | 807 | settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, |
| 785 | 808 | snoozeLabelNow: `${nowMinute}:00.000Z`, |
| 809 | snoozeEnvironmentIds, | |
| 810 | queuedThreadKeys, | |
| 811 | moveAvailability: threadMoveAvailability, | |
| 812 | shelfPreferencesLoading: !shelfPreferencesLoaded, | |
| 786 | 813 | }), |
| 787 | [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks], | |
| 814 | [ | |
| 815 | nowMinute, | |
| 816 | queuedThreadKeys, | |
| 817 | threadMoveAvailability, | |
| 818 | settledShelfExpanded, | |
| 819 | shelfPreferencesLoaded, | |
| 820 | snoozedShelfExpanded, | |
| 821 | snoozeEnvironmentIds, | |
| 822 | threadListV2Layout, | |
| 823 | v2PendingTasks, | |
| 824 | ], | |
| 788 | 825 | ); |
| 789 | 826 | |
| 790 | 827 | useThreadJumpShortcuts( |
| ... | ... | @@ -793,11 +830,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 793 | 830 | ); |
| 794 | 831 | |
| 795 | 832 | const renderV2Item = useCallback( |
| 796 | ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => { | |
| 797 | const nextItem = threadListV2Items[index + 1]; | |
| 798 | const showTrailingDivider = | |
| 799 | nextItem?.type === "v2-thread" || | |
| 800 | (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider); | |
| 833 | ({ item }: { readonly item: ThreadListV2ListItem }) => { | |
| 801 | 834 | if (item.type === "v2-pending") { |
| 802 | 835 | const pendingScopeKey = scopedProjectKey( |
| 803 | 836 | item.pendingTask.environmentId, |
| ... | ... | @@ -816,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 816 | 849 | } |
| 817 | 850 | environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)} |
| 818 | 851 | showPendingDivider={item.showPendingDivider} |
| 819 | showTrailingDivider={showTrailingDivider} | |
| 852 | showTrailingDivider={item.showTrailingDivider} | |
| 820 | 853 | onSelectPendingTask={props.onSelectPendingTask} |
| 821 | 854 | onDeletePendingTask={props.onDeletePendingTask} |
| 822 | 855 | /> |
| ... | ... | @@ -826,7 +859,7 @@ export function HomeScreen(props: HomeScreenProps) { |
| 826 | 859 | return ( |
| 827 | 860 | <ThreadListV2SnoozedShelfHeader |
| 828 | 861 | count={item.count} |
| 829 | disabled={!shelfPreferencesLoaded} | |
| 862 | disabled={item.disabled} | |
| 830 | 863 | expanded={item.expanded} |
| 831 | 864 | onToggle={toggleSnoozedShelf} |
| 832 | 865 | /> |
| ... | ... | @@ -836,33 +869,32 @@ export function HomeScreen(props: HomeScreenProps) { |
| 836 | 869 | return ( |
| 837 | 870 | <ThreadListV2SettledShelfHeader |
| 838 | 871 | count={item.count} |
| 839 | disabled={!shelfPreferencesLoaded} | |
| 872 | disabled={item.disabled} | |
| 840 | 873 | expanded={item.expanded} |
| 841 | 874 | onToggle={toggleSettledShelf} |
| 842 | 875 | /> |
| 843 | 876 | ); |
| 844 | 877 | } |
| 845 | 878 | const thread = item.item.thread; |
| 846 | const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; | |
| 847 | const movedId = `${thread.environmentId}:${thread.id}`; | |
| 848 | 879 | return ( |
| 849 | 880 | <ThreadListV2Row |
| 850 | 881 | onNewThreadOnBranch={props.onNewThreadOnBranch} |
| 851 | 882 | thread={thread} |
| 852 | 883 | variant={item.item.variant} |
| 853 | hasQueuedMessages={queuedThreadKeys.has(movedId)} | |
| 884 | hasQueuedMessages={item.hasQueuedMessages} | |
| 854 | 885 | snoozed={item.item.snoozed} |
| 855 | 886 | pinned={item.item.pinned} |
| 856 | snoozePresetMinute={nowMinute} | |
| 887 | snoozePresetMinute={item.snoozePresetMinute ?? ""} | |
| 857 | 888 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 858 | showTrailingDivider={showTrailingDivider} | |
| 889 | timeLabel={item.timeLabel} | |
| 890 | showTrailingDivider={item.showTrailingDivider} | |
| 859 | 891 | project={ |
| 860 | 892 | projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null |
| 861 | 893 | } |
| 862 | 894 | projectTitle={v2ProjectTitleByProjectKey.get( |
| 863 | 895 | scopedProjectKey(thread.environmentId, thread.projectId), |
| 864 | 896 | )} |
| 865 | providerInstance={resolveThreadProviderInstance(serverConfigs, thread)} | |
| 897 | providerInstance={resolveProviderInstance(thread)} | |
| 866 | 898 | environmentLabel={ |
| 867 | 899 | Object.keys(props.savedConnectionsById).length > 1 |
| 868 | 900 | ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) |
| ... | ... | @@ -891,8 +923,8 @@ export function HomeScreen(props: HomeScreenProps) { |
| 891 | 923 | ? pinReorderEnvironmentIds.has(thread.environmentId) |
| 892 | 924 | : activeReorderEnvironmentIds.has(thread.environmentId) |
| 893 | 925 | } |
| 894 | canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} | |
| 895 | canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} | |
| 926 | canMoveUp={item.canMoveUp} | |
| 927 | canMoveDown={item.canMoveDown} | |
| 896 | 928 | onSnoozeThread={handleSnoozeThread} |
| 897 | 929 | onUnsnoozeThread={handleUnsnoozeThread} |
| 898 | 930 | onUnsettleThread={handleUnsettleThread} |
| ... | ... | @@ -907,9 +939,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 907 | 939 | [ |
| 908 | 940 | handleDeleteThread, |
| 909 | 941 | activeReorderEnvironmentIds, |
| 910 | threadMovePlanners, | |
| 911 | pendingOrder, | |
| 912 | queuedThreadKeys, | |
| 913 | 942 | handleMoveThread, |
| 914 | 943 | handlePinThread, |
| 915 | 944 | handleRegenerateThreadTitle, |
| ... | ... | @@ -931,25 +960,23 @@ export function HomeScreen(props: HomeScreenProps) { |
| 931 | 960 | props.onSelectThread, |
| 932 | 961 | props.onNewThreadOnBranch, |
| 933 | 962 | props.savedConnectionsById, |
| 934 | serverConfigs, | |
| 935 | shelfPreferencesLoaded, | |
| 963 | resolveProviderInstance, | |
| 936 | 964 | settlementEnvironmentIds, |
| 937 | 965 | snoozeEnvironmentIds, |
| 938 | threadListV2Items, | |
| 939 | 966 | threadSearchMatchByKey, |
| 940 | 967 | titleRegenerationEnvironmentIds, |
| 941 | 968 | toggleSettledShelf, |
| 942 | 969 | toggleSnoozedShelf, |
| 943 | 970 | v2ProjectTitleByProjectKey, |
| 944 | 971 | props.searchQuery, |
| 945 | nowMinute, | |
| 946 | 972 | ], |
| 947 | 973 | ); |
| 948 | 974 | const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []); |
| 949 | 975 | |
| 950 | // FlatList treats a changed extraData identity as "re-render every visible | |
| 951 | // row", so an inline object literal would invalidate all rows on every | |
| 952 | // HomeScreen render. | |
| 976 | // FlatList/LegendList treat a changed extraData identity as "re-render every | |
| 977 | // visible row", so an inline object literal would invalidate all rows on | |
| 978 | // every HomeScreen render — and the minute clock must stay out of it for | |
| 979 | // the same reason: the clock text is precomputed per item instead. | |
| 953 | 980 | const v2ExtraData = useMemo( |
| 954 | 981 | () => ({ |
| 955 | 982 | projectByKey, |
| ... | ... | @@ -957,7 +984,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 957 | 984 | serverConfigs, |
| 958 | 985 | savedConnectionsById: props.savedConnectionsById, |
| 959 | 986 | searchQuery: props.searchQuery, |
| 960 | snoozePresetMinute: nowMinute, | |
| 961 | 987 | threadSearchMatchByKey, |
| 962 | 988 | }), |
| 963 | 989 | [ |
| ... | ... | @@ -965,7 +991,6 @@ export function HomeScreen(props: HomeScreenProps) { |
| 965 | 991 | props.searchQuery, |
| 966 | 992 | props.savedConnectionsById, |
| 967 | 993 | serverConfigs, |
| 968 | nowMinute, | |
| 969 | 994 | threadSearchMatchByKey, |
| 970 | 995 | v2ProjectTitleByProjectKey, |
| 971 | 996 | ], |
| ... | ... | @@ -1225,11 +1250,19 @@ export function HomeScreen(props: HomeScreenProps) { |
| 1225 | 1250 | : "flex-1 bg-screen" |
| 1226 | 1251 | } |
| 1227 | 1252 | > |
| 1253 | {/* Same recycler the iPad sidebar and the legacy list use: cells are | |
| 1254 | reused across data rebuilds and `itemsAreEqual` keeps a minute tick | |
| 1255 | (or an unrelated shell update) from re-rendering untouched rows. */} | |
| 1228 | 1256 | <SwipeableScrollGateProvider enabled={swipeEnabled}> |
| 1229 | <FlatList | |
| 1257 | <LegendList | |
| 1230 | 1258 | data={threadListV2Items} |
| 1231 | 1259 | renderItem={renderV2Item} |
| 1232 | 1260 | keyExtractor={v2KeyExtractor} |
| 1261 | getItemType={(item) => item.type} | |
| 1262 | itemsAreEqual={threadListV2ListItemsAreEqual} | |
| 1263 | estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} | |
| 1264 | drawDistance={500} | |
| 1265 | recycleItems | |
| 1233 | 1266 | extraData={v2ExtraData} |
| 1234 | 1267 | ListHeaderComponent={v2ListHeader} |
| 1235 | 1268 | ListFooterComponent={ |
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-57| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 1 | import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; |
| 2 | import { createThreadMovePlanner } from "./threadOrder"; | |
| 2 | import { computeThreadMoveAvailability } from "./threadOrder"; | |
| 3 | 3 | import type { |
| 4 | 4 | EnvironmentProject, |
| 5 | 5 | EnvironmentThreadShell, |
| ... | ... | @@ -84,11 +84,13 @@ import { |
| 84 | 84 | ThreadListV2ShowMoreRow, |
| 85 | 85 | ThreadListV2SnoozedShelfHeader, |
| 86 | 86 | } from "./thread-list-v2-items"; |
| 87 | import { resolveThreadProviderInstance } from "./thread-provider-instance"; | |
| 87 | import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance"; | |
| 88 | 88 | import { |
| 89 | 89 | buildThreadListV2Items, |
| 90 | 90 | getThreadListV2OrderedSection, |
| 91 | 91 | buildThreadListV2ListItems, |
| 92 | isThreadListV2ListItem, | |
| 93 | threadListV2ListItemsAreEqual, | |
| 92 | 94 | THREAD_LIST_V2_SETTLED_INITIAL_COUNT, |
| 93 | 95 | THREAD_LIST_V2_SETTLED_PAGE_COUNT, |
| 94 | 96 | type ThreadListV2ListItem, |
| ... | ... | @@ -483,12 +485,19 @@ function ThreadNavigationSidebarPane( |
| 483 | 485 | ), |
| 484 | 486 | [serverConfigs], |
| 485 | 487 | ); |
| 488 | // Reference-stable provider glyphs: a fresh object per render would break | |
| 489 | // the memoized rows' props comparison on every parent render. | |
| 490 | const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs); | |
| 486 | 491 | const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); |
| 487 | const threadMovePlanners = useMemo(() => { | |
| 488 | const sectionPlanner = (section: "pinned" | "active") => | |
| 489 | createThreadMovePlanner({ | |
| 492 | // Up/down menu availability for every card, computed once per section per | |
| 493 | // rebuild (see computeThreadMoveAvailability): per-thread planner calls made | |
| 494 | // list construction quadratic, and this list rebuilds on every minute tick. | |
| 495 | const threadMoveAvailability = useMemo(() => { | |
| 496 | const sectionAvailability = (section: "pinned" | "active") => | |
| 497 | computeThreadMoveAvailability({ | |
| 490 | 498 | allThreads: threads, |
| 491 | 499 | section, |
| 500 | pendingOrder, | |
| 492 | 501 | reorderableEnvironmentIds: new Set( |
| 493 | 502 | [...serverConfigs].flatMap(([id, config]) => |
| 494 | 503 | (section === "pinned" |
| ... | ... | @@ -508,7 +517,7 @@ function ThreadNavigationSidebarPane( |
| 508 | 517 | queuedThreadKeys, |
| 509 | 518 | }), |
| 510 | 519 | }); |
| 511 | return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; | |
| 520 | return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]); | |
| 512 | 521 | }, [ |
| 513 | 522 | serverConfigs, |
| 514 | 523 | threads, |
| ... | ... | @@ -607,6 +616,10 @@ function ThreadNavigationSidebarPane( |
| 607 | 616 | settledShelfExpanded, |
| 608 | 617 | settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex, |
| 609 | 618 | snoozeLabelNow: `${nowMinute}:00.000Z`, |
| 619 | snoozeEnvironmentIds, | |
| 620 | queuedThreadKeys, | |
| 621 | moveAvailability: threadMoveAvailability, | |
| 622 | shelfPreferencesLoading: !shelfPreferencesLoaded, | |
| 610 | 623 | }); |
| 611 | 624 | if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) { |
| 612 | 625 | items.push({ |
| ... | ... | @@ -622,9 +635,13 @@ function ThreadNavigationSidebarPane( |
| 622 | 635 | options.selectedEnvironmentId, |
| 623 | 636 | pendingTasks, |
| 624 | 637 | props.searchQuery, |
| 638 | queuedThreadKeys, | |
| 639 | threadMoveAvailability, | |
| 625 | 640 | selectedProjectRefs, |
| 626 | 641 | settledShelfExpanded, |
| 642 | shelfPreferencesLoaded, | |
| 627 | 643 | snoozedShelfExpanded, |
| 644 | snoozeEnvironmentIds, | |
| 628 | 645 | threadListV2Enabled, |
| 629 | 646 | threadListV2Layout, |
| 630 | 647 | ]); |
| ... | ... | @@ -789,9 +806,11 @@ function ThreadNavigationSidebarPane( |
| 789 | 806 | onScroll: onMaterialFabScroll, |
| 790 | 807 | onScrollBeginDrag: handleScrollBeginDrag, |
| 791 | 808 | }); |
| 792 | // Project shells load after the first rows draw, so the maps they feed have | |
| 793 | // to bust the recycler's memoization — otherwise a row keeps the blank | |
| 794 | // favicon and fallback title it was first rendered with. | |
| 809 | // The sticky header's project shells and search maps feed row props, so | |
| 810 | // they have to bust the recycler's memoization — otherwise a row keeps the | |
| 811 | // blank favicon and fallback title it was first rendered with. The minute | |
| 812 | // clock deliberately stays out: its per-row text lives on the items, so a | |
| 813 | // tick only re-renders rows whose displayed text actually moved. | |
| 795 | 814 | const listExtraData = useMemo( |
| 796 | 815 | () => ({ |
| 797 | 816 | selectedThreadKey: props.selectedThreadKey ?? "", |
| ... | ... | @@ -799,7 +818,6 @@ function ThreadNavigationSidebarPane( |
| 799 | 818 | projectTitleByProjectKey, |
| 800 | 819 | savedConnectionsById, |
| 801 | 820 | serverConfigs, |
| 802 | snoozePresetMinute: nowMinute, | |
| 803 | 821 | threadSearchMatchByKey, |
| 804 | 822 | }), |
| 805 | 823 | [ |
| ... | ... | @@ -808,49 +826,23 @@ function ThreadNavigationSidebarPane( |
| 808 | 826 | projectTitleByProjectKey, |
| 809 | 827 | savedConnectionsById, |
| 810 | 828 | serverConfigs, |
| 811 | nowMinute, | |
| 812 | 829 | threadSearchMatchByKey, |
| 813 | 830 | ], |
| 814 | 831 | ); |
| 815 | 832 | useThreadJumpShortcuts(listItems, handleSelectThread); |
| 816 | 833 | const sidebarItemsAreEqual = useCallback( |
| 817 | 834 | (previous: SidebarListItem, item: SidebarListItem): boolean => { |
| 818 | if (previous.type === "v2-thread" && item.type === "v2-thread") { | |
| 819 | return ( | |
| 820 | previous.key === item.key && | |
| 821 | previous.item.thread === item.item.thread && | |
| 822 | previous.item.variant === item.item.variant && | |
| 823 | previous.item.snoozed === item.item.snoozed && | |
| 824 | previous.item.pinned === item.item.pinned && | |
| 825 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText | |
| 826 | ); | |
| 835 | if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) { | |
| 836 | return threadListV2ListItemsAreEqual(previous, item); | |
| 827 | 837 | } |
| 828 | 838 | if (previous.type === "v2-show-more" && item.type === "v2-show-more") { |
| 829 | 839 | return previous.hiddenCount === item.hiddenCount; |
| 830 | 840 | } |
| 831 | if (previous.type === "v2-pending" && item.type === "v2-pending") { | |
| 832 | return ( | |
| 833 | previous.pendingTask === item.pendingTask && | |
| 834 | previous.showPendingDivider === item.showPendingDivider | |
| 835 | ); | |
| 836 | } | |
| 837 | if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") { | |
| 838 | return previous.count === item.count && previous.expanded === item.expanded; | |
| 839 | } | |
| 840 | if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") { | |
| 841 | return previous.count === item.count && previous.expanded === item.expanded; | |
| 842 | } | |
| 843 | 841 | if ( |
| 844 | previous.type === "v2-thread" || | |
| 842 | isThreadListV2ListItem(previous) || | |
| 845 | 843 | previous.type === "v2-show-more" || |
| 846 | previous.type === "v2-pending" || | |
| 847 | previous.type === "v2-snoozed-shelf" || | |
| 848 | previous.type === "v2-settled-shelf" || | |
| 849 | item.type === "v2-thread" || | |
| 850 | item.type === "v2-show-more" || | |
| 851 | item.type === "v2-pending" || | |
| 852 | item.type === "v2-snoozed-shelf" || | |
| 853 | item.type === "v2-settled-shelf" | |
| 844 | isThreadListV2ListItem(item) || | |
| 845 | item.type === "v2-show-more" | |
| 854 | 846 | ) { |
| 855 | 847 | return false; |
| 856 | 848 | } |
| ... | ... | @@ -904,24 +896,29 @@ function ThreadNavigationSidebarPane( |
| 904 | 896 | } |
| 905 | 897 | case "v2-thread": { |
| 906 | 898 | const thread = item.item.thread; |
| 907 | const movePlanner = item.item.pinned | |
| 908 | ? threadMovePlanners.pinned | |
| 909 | : threadMovePlanners.active; | |
| 910 | const movedId = `${thread.environmentId}:${thread.id}`; | |
| 911 | 899 | const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); |
| 900 | // Intentional difference from Home: the sidebar never passes | |
| 901 | // `showTrailingDivider` because its rows render no Home-style row | |
| 902 | // hairline at all — card rows carry tonal containers in this pane | |
| 903 | // (the hairline branch is !sidebarPane-only) and slim rows have no | |
| 904 | // hairline branch. The stamp still rides the shared list items | |
| 905 | // because Home's boundary suppression consumes it; the sidebar's | |
| 906 | // only cost is the occasional divider-only equality invalidation, | |
| 907 | // which re-renders identically. | |
| 912 | 908 | return ( |
| 913 | 909 | <ThreadListV2Row |
| 914 | 910 | onNewThreadOnBranch={props.onNewThreadOnBranch} |
| 915 | 911 | thread={thread} |
| 916 | 912 | variant={item.item.variant} |
| 917 | hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)} | |
| 913 | hasQueuedMessages={item.hasQueuedMessages} | |
| 918 | 914 | snoozed={item.item.snoozed} |
| 919 | 915 | pinned={item.item.pinned} |
| 920 | snoozePresetMinute={nowMinute} | |
| 916 | snoozePresetMinute={item.snoozePresetMinute ?? ""} | |
| 921 | 917 | snoozeWakeLabelText={item.snoozeWakeLabelText} |
| 918 | timeLabel={item.timeLabel} | |
| 922 | 919 | project={projectByKey.get(scopeKey) ?? null} |
| 923 | 920 | projectTitle={projectTitleByProjectKey.get(scopeKey)} |
| 924 | providerInstance={resolveThreadProviderInstance(serverConfigs, thread)} | |
| 921 | providerInstance={resolveProviderInstance(thread)} | |
| 925 | 922 | environmentLabel={ |
| 926 | 923 | Object.keys(savedConnectionsById).length > 1 |
| 927 | 924 | ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) |
| ... | ... | @@ -955,8 +952,8 @@ function ThreadNavigationSidebarPane( |
| 955 | 952 | ? pinReorderEnvironmentIds.has(thread.environmentId) |
| 956 | 953 | : activeReorderEnvironmentIds.has(thread.environmentId) |
| 957 | 954 | } |
| 958 | canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} | |
| 959 | canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} | |
| 955 | canMoveUp={item.canMoveUp} | |
| 956 | canMoveDown={item.canMoveDown} | |
| 960 | 957 | onSnoozeThread={snoozeThread} |
| 961 | 958 | onUnsnoozeThread={unsnoozeThread} |
| 962 | 959 | onUnsettleThread={unsettleThread} |
| ... | ... | @@ -973,7 +970,7 @@ function ThreadNavigationSidebarPane( |
| 973 | 970 | return ( |
| 974 | 971 | <ThreadListV2SnoozedShelfHeader |
| 975 | 972 | count={item.count} |
| 976 | disabled={!shelfPreferencesLoaded} | |
| 973 | disabled={item.disabled} | |
| 977 | 974 | expanded={item.expanded} |
| 978 | 975 | onToggle={toggleSnoozedShelf} |
| 979 | 976 | pane="sidebar" |
| ... | ... | @@ -983,7 +980,7 @@ function ThreadNavigationSidebarPane( |
| 983 | 980 | return ( |
| 984 | 981 | <ThreadListV2SettledShelfHeader |
| 985 | 982 | count={item.count} |
| 986 | disabled={!shelfPreferencesLoaded} | |
| 983 | disabled={item.disabled} | |
| 987 | 984 | expanded={item.expanded} |
| 988 | 985 | onToggle={toggleSettledShelf} |
| 989 | 986 | pane="sidebar" |
| ... | ... | @@ -1080,8 +1077,6 @@ function ThreadNavigationSidebarPane( |
| 1080 | 1077 | [ |
| 1081 | 1078 | archiveThread, |
| 1082 | 1079 | activeReorderEnvironmentIds, |
| 1083 | threadMovePlanners, | |
| 1084 | pendingOrder, | |
| 1085 | 1080 | queuedThreadKeys, |
| 1086 | 1081 | confirmDeletePendingTask, |
| 1087 | 1082 | confirmDeleteThread, |
| ... | ... | @@ -1104,8 +1099,6 @@ function ThreadNavigationSidebarPane( |
| 1104 | 1099 | props.selectedThreadKey, |
| 1105 | 1100 | props.width, |
| 1106 | 1101 | savedConnectionsById, |
| 1107 | serverConfigs, | |
| 1108 | shelfPreferencesLoaded, | |
| 1109 | 1102 | threadSearchMatchByKey, |
| 1110 | 1103 | titleRegenerationEnvironmentIds, |
| 1111 | 1104 | settleThread, |
| ... | ... | @@ -1114,7 +1107,7 @@ function ThreadNavigationSidebarPane( |
| 1114 | 1107 | sidebarScrollGesture, |
| 1115 | 1108 | snoozeEnvironmentIds, |
| 1116 | 1109 | snoozeThread, |
| 1117 | nowMinute, | |
| 1110 | resolveProviderInstance, | |
| 1118 | 1111 | toggleSettledShelf, |
| 1119 | 1112 | toggleSnoozedShelf, |
| 1120 | 1113 | unpinThread, |
apps/mobile/src/features/threads/thread-list-v2-items.tsx+26-15| ... | ... | @@ -18,7 +18,6 @@ import type { |
| 18 | 18 | import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; |
| 19 | 19 | import type { EnvironmentMachineKind } from "@t3tools/contracts"; |
| 20 | 20 | import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; |
| 21 | import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; | |
| 22 | 21 | import type { MenuAction } from "@react-native-menu/menu"; |
| 23 | 22 | import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; |
| 24 | 23 | import { Alert, Pressable, useWindowDimensions, View } from "react-native"; |
| ... | ... | @@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon"; |
| 33 | 32 | import type { ThreadRowProviderInstance } from "./thread-provider-instance"; |
| 34 | 33 | import { cn } from "../../lib/cn"; |
| 35 | 34 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; |
| 36 | import { relativeTime } from "../../lib/time"; | |
| 37 | 35 | import { useUniwindTheme } from "../../lib/useUniwindTheme"; |
| 38 | 36 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 39 | 37 | import { useThreadPr } from "../../state/use-thread-pr"; |
| ... | ... | @@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial< |
| 69 | 67 | failed: { label: "Failed", className: "text-danger-foreground" }, |
| 70 | 68 | }; |
| 71 | 69 | |
| 72 | function threadTimeLabel(thread: EnvironmentThreadShell): string { | |
| 73 | return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt); | |
| 74 | } | |
| 75 | ||
| 76 | 70 | // Menus keep lifecycle and title regeneration together. Archive keeps its |
| 77 | 71 | // own surface (thread screen / settings) rather than crowding v2 rows. |
| 78 | 72 | const CARD_MENU_ACTIONS: MenuAction[] = [ |
| ... | ... | @@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 453 | 447 | /** Preformatted against the parent minute tick so this memoized row's |
| 454 | 448 | countdown keeps moving. */ |
| 455 | 449 | readonly snoozeWakeLabelText?: string; |
| 456 | /** Parent minute tick passed as a prop so this memoized row refreshes its | |
| 457 | native snooze menu while mounted. */ | |
| 450 | /** Preformatted against the parent clock (row order timestamp: settle stamp | |
| 451 | on settled rows, latest activity otherwise). Blank while a status label | |
| 452 | or the wake countdown owns that slot. Precomputed per row — not via the | |
| 453 | list's extraData — so the minute tick re-renders only rows whose | |
| 454 | displayed text moved. */ | |
| 455 | readonly timeLabel: string; | |
| 456 | /** Parent minute tick carried on the row's list item, present only when the | |
| 457 | row's menu offers snooze presets, so those menus refresh while mounted | |
| 458 | without invalidating every other row. */ | |
| 458 | 459 | readonly snoozePresetMinute: string; |
| 459 | 460 | readonly project: EnvironmentProject | null; |
| 460 | 461 | readonly projectTitle?: string; |
| ... | ... | @@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 549 | 550 | |
| 550 | 551 | const status = resolveThreadListV2Status(thread); |
| 551 | 552 | const statusLabel = STATUS_LABEL_BY_STATUS[status]; |
| 552 | // Settled rows label by the same stamp they sort by, so order and label | |
| 553 | // can't disagree. updatedAt is always present, so the resolver never | |
| 554 | // returns null here. | |
| 555 | const settledTimestamp = | |
| 556 | variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null; | |
| 557 | const timeLabel = | |
| 558 | settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread); | |
| 553 | // The timestamp is precomputed on the list item (same stamps the settled | |
| 554 | // tail sorts by) so a minute tick only re-renders rows that draw it. | |
| 555 | const timeLabel = props.timeLabel; | |
| 559 | 556 | |
| 560 | 557 | const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); |
| 561 | 558 | const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); |
| ... | ... | @@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 565 | 562 | ); |
| 566 | 563 | const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]); |
| 567 | 564 | const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false); |
| 565 | // A recycled cell reassigns this mounted row to a different thread without | |
| 566 | // remounting it, and the render closure stops running while list equality | |
| 567 | // says the item is unchanged — so any row-local UI state must be dismissed | |
| 568 | // when the identity under it changes. Without this, a custom snooze sheet | |
| 569 | // opened for one thread survives the thread's removal/reorder and its | |
| 570 | // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable | |
| 571 | // enforces the same contract on the swipe layer with its resetKey.) | |
| 572 | const rowIdentity = `${thread.environmentId}:${thread.id}`; | |
| 573 | const [boundIdentity, setBoundIdentity] = useState(rowIdentity); | |
| 574 | if (boundIdentity !== rowIdentity) { | |
| 575 | setBoundIdentity(rowIdentity); | |
| 576 | setCustomSnoozeOpen(false); | |
| 577 | } | |
| 568 | 578 | const handleSnooze = useCallback( |
| 569 | 579 | (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil), |
| 570 | 580 | [onSnoozeThread, thread], |
| ... | ... | @@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { |
| 758 | 768 | handleUnsettle, |
| 759 | 769 | handleUnsnooze, |
| 760 | 770 | snoozePresets, |
| 771 | setCustomSnoozeOpen, | |
| 761 | 772 | ], |
| 762 | 773 | ); |
| 763 | 774 | const primaryAction = useMemo(() => { |
apps/mobile/src/features/threads/thread-provider-instance.test.ts+54-1| ... | ... | @@ -8,7 +8,10 @@ import { |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { describe, expect, it } from "vite-plus/test"; |
| 10 | 10 | |
| 11 | import { resolveThreadProviderInstance } from "./thread-provider-instance"; | |
| 11 | import { | |
| 12 | createThreadRowProviderInstanceResolver, | |
| 13 | resolveThreadProviderInstance, | |
| 14 | } from "./thread-provider-instance"; | |
| 12 | 15 | |
| 13 | 16 | function makeConfig( |
| 14 | 17 | providers: ReadonlyArray<{ |
| ... | ... | @@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => { |
| 96 | 99 | expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false); |
| 97 | 100 | }); |
| 98 | 101 | }); |
| 102 | ||
| 103 | describe("createThreadRowProviderInstanceResolver", () => { | |
| 104 | const environmentId = EnvironmentId.make("environment-a"); | |
| 105 | const serverConfigs = new Map<EnvironmentId, ServerConfig>([ | |
| 106 | [ | |
| 107 | environmentId, | |
| 108 | makeConfig([ | |
| 109 | { instanceId: "codex", driver: "codex", displayName: "Codex" }, | |
| 110 | { instanceId: "codex_work", driver: "codex", displayName: "Codex" }, | |
| 111 | ]), | |
| 112 | ], | |
| 113 | ]); | |
| 114 | ||
| 115 | it("hands out the same reference for repeated lookups of one instance", () => { | |
| 116 | const resolve = createThreadRowProviderInstanceResolver(serverConfigs); | |
| 117 | const first = resolve(makeThread(environmentId, "codex")); | |
| 118 | const second = resolve(makeThread(environmentId, "codex")); | |
| 119 | // Memoized rows compare props by reference: a fresh object per call would | |
| 120 | // re-render every row on every parent render (minute tick included). | |
| 121 | expect(first).not.toBeNull(); | |
| 122 | expect(second).toBe(first); | |
| 123 | }); | |
| 124 | ||
| 125 | it("distinguishes instances of the same driver", () => { | |
| 126 | const resolve = createThreadRowProviderInstanceResolver(serverConfigs); | |
| 127 | const personal = resolve(makeThread(environmentId, "codex")); | |
| 128 | const work = resolve(makeThread(environmentId, "codex_work")); | |
| 129 | expect(personal).not.toBeNull(); | |
| 130 | expect(work).not.toBeNull(); | |
| 131 | expect(work).not.toBe(personal); | |
| 132 | expect(work?.displayName).toBe("Codex Work"); | |
| 133 | }); | |
| 134 | ||
| 135 | it("hands out a new identity when the server-config generation changes", () => { | |
| 136 | const before = createThreadRowProviderInstanceResolver(serverConfigs); | |
| 137 | const nextConfigs = new Map<EnvironmentId, ServerConfig>([ | |
| 138 | [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])], | |
| 139 | ]); | |
| 140 | const after = createThreadRowProviderInstanceResolver(nextConfigs); | |
| 141 | expect(after(makeThread(environmentId, "codex"))).not.toBe( | |
| 142 | before(makeThread(environmentId, "codex")), | |
| 143 | ); | |
| 144 | }); | |
| 145 | ||
| 146 | it("resolves unknown instances to null without throwing", () => { | |
| 147 | const resolve = createThreadRowProviderInstanceResolver(serverConfigs); | |
| 148 | expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); | |
| 149 | expect(resolve(makeThread(environmentId, "ghost"))).toBeNull(); | |
| 150 | }); | |
| 151 | }); |
apps/mobile/src/features/threads/thread-provider-instance.ts+32| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { useMemo } from "react"; | |
| 2 | ||
| 1 | 3 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | 4 | import { |
| 3 | 5 | normalizeProviderAccentColor, |
| ... | ... | @@ -40,3 +42,33 @@ export function resolveThreadProviderInstance( |
| 40 | 42 | ), |
| 41 | 43 | }; |
| 42 | 44 | } |
| 45 | ||
| 46 | /** | |
| 47 | * Builds a resolver handing out reference-stable `ThreadRowProviderInstance` | |
| 48 | * objects. `resolveThreadProviderInstance` builds a fresh object per call, | |
| 49 | * which breaks the memoized row's props comparison on every parent render — | |
| 50 | * the result only depends on (environment, instance id), so one cache per | |
| 51 | * server-config generation keeps each row's `providerInstance` prop stable | |
| 52 | * until the instance behind the row actually changes. | |
| 53 | */ | |
| 54 | export function createThreadRowProviderInstanceResolver( | |
| 55 | serverConfigs: ReadonlyMap<EnvironmentId, ServerConfig>, | |
| 56 | ): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { | |
| 57 | const cache = new Map<string, ThreadRowProviderInstance | null>(); | |
| 58 | return (thread) => { | |
| 59 | const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; | |
| 60 | const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`; | |
| 61 | const cached = cache.get(cacheKey); | |
| 62 | if (cached !== undefined) return cached; | |
| 63 | const resolved = resolveThreadProviderInstance(serverConfigs, thread); | |
| 64 | cache.set(cacheKey, resolved); | |
| 65 | return resolved; | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | /** List-scoped wrapper: one cache per server-config generation. */ | |
| 70 | export function useThreadRowProviderInstanceResolver( | |
| 71 | serverConfigs: ReadonlyMap<EnvironmentId, ServerConfig>, | |
| 72 | ): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null { | |
| 73 | return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]); | |
| 74 | } |
apps/mobile/src/features/threads/threadListV2.test.ts+554-1| ... | ... | @@ -5,7 +5,9 @@ import { |
| 5 | 5 | threadOrderAfterMove, |
| 6 | 6 | threadDropLifecycle, |
| 7 | 7 | reconcilePendingThreadOrder, |
| 8 | computeThreadMoveAvailability, | |
| 8 | 9 | type PendingThreadOrder, |
| 10 | type ThreadMoveAvailability, | |
| 9 | 11 | } from "./threadOrder"; |
| 10 | 12 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 11 | 13 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| ... | ... | @@ -19,7 +21,7 @@ import { |
| 19 | 21 | ThreadId, |
| 20 | 22 | TurnId, |
| 21 | 23 | } from "@t3tools/contracts"; |
| 22 | import { describe, expect, it } from "vite-plus/test"; | |
| 24 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 23 | 25 | |
| 24 | 26 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 25 | 27 | import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts"; |
| ... | ... | @@ -27,12 +29,15 @@ import { |
| 27 | 29 | buildThreadListV2Items, |
| 28 | 30 | buildThreadListV2ListItems, |
| 29 | 31 | getThreadListV2OrderedSection, |
| 32 | isThreadListV2ListItem, | |
| 30 | 33 | resolveThreadListV2Enabled, |
| 31 | 34 | resolveThreadListV2SnoozeMenuSelection, |
| 32 | 35 | resolveThreadListV2SnoozeGateExpiryMs, |
| 33 | 36 | resolveThreadListV2Status, |
| 34 | 37 | resolveThreadListV2SwipeActions, |
| 35 | 38 | sortThreadsForListV2, |
| 39 | threadListV2ListItemsAreEqual, | |
| 40 | type ThreadListV2ListItem, | |
| 36 | 41 | } from "./threadListV2"; |
| 37 | 42 | |
| 38 | 43 | const environmentId = EnvironmentId.make("environment-1"); |
| ... | ... | @@ -1482,3 +1487,551 @@ describe("cross-section thread drops", () => { |
| 1482 | 1487 | ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false }); |
| 1483 | 1488 | }); |
| 1484 | 1489 | }); |
| 1490 | ||
| 1491 | /* ─── Recycled-list equality + per-row clock scoping ─────────────────── */ | |
| 1492 | ||
| 1493 | const BASE_MS = Date.parse(NOW); | |
| 1494 | const isoAt = (ms: number) => new Date(ms).toISOString(); | |
| 1495 | const MINUTE_MS = 60_000; | |
| 1496 | ||
| 1497 | function runningSession(threadId: string) { | |
| 1498 | return { | |
| 1499 | threadId: ThreadId.make(threadId), | |
| 1500 | status: "running" as const, | |
| 1501 | providerName: "Codex", | |
| 1502 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 1503 | runtimeMode: "full-access" as const, | |
| 1504 | activeTurnId: null, | |
| 1505 | lastError: null, | |
| 1506 | updatedAt: NOW, | |
| 1507 | }; | |
| 1508 | } | |
| 1509 | ||
| 1510 | function buildTickThreads() { | |
| 1511 | return { | |
| 1512 | ready: makeThread({ | |
| 1513 | id: ThreadId.make("tick-ready"), | |
| 1514 | title: "tick ready", | |
| 1515 | latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), | |
| 1516 | }), | |
| 1517 | approval: makeThread({ | |
| 1518 | id: ThreadId.make("tick-approval"), | |
| 1519 | title: "tick approval", | |
| 1520 | hasPendingApprovals: true, | |
| 1521 | latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), | |
| 1522 | }), | |
| 1523 | settled: makeThread({ | |
| 1524 | id: ThreadId.make("tick-settled"), | |
| 1525 | title: "tick settled", | |
| 1526 | settledOverride: "settled", | |
| 1527 | settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), | |
| 1528 | }), | |
| 1529 | snoozed: makeThread({ | |
| 1530 | id: ThreadId.make("tick-snoozed"), | |
| 1531 | title: "tick snoozed", | |
| 1532 | snoozedAt: isoAt(BASE_MS - MINUTE_MS), | |
| 1533 | snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS), | |
| 1534 | }), | |
| 1535 | }; | |
| 1536 | } | |
| 1537 | ||
| 1538 | function buildTickList( | |
| 1539 | threads: ReadonlyArray<EnvironmentThreadShell>, | |
| 1540 | clockMs: number, | |
| 1541 | pendingTasks: ReadonlyArray<PendingNewTask>, | |
| 1542 | options?: { | |
| 1543 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | |
| 1544 | readonly queuedThreadKeys?: ReadonlySet<string>; | |
| 1545 | readonly moveAvailability?: ReadonlyMap<string, ThreadMoveAvailability>; | |
| 1546 | readonly shelfPreferencesLoading?: boolean; | |
| 1547 | }, | |
| 1548 | ): ThreadListV2ListItem[] { | |
| 1549 | const now = isoAt(clockMs); | |
| 1550 | const layout = buildThreadListV2Items({ | |
| 1551 | threads, | |
| 1552 | environmentId: null, | |
| 1553 | searchQuery: "", | |
| 1554 | now, | |
| 1555 | snoozedShelfExpanded: true, | |
| 1556 | }); | |
| 1557 | return buildThreadListV2ListItems({ | |
| 1558 | items: layout.items, | |
| 1559 | pendingTasks, | |
| 1560 | snoozedCount: layout.snoozedCount, | |
| 1561 | snoozedShelfExpanded: true, | |
| 1562 | snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, | |
| 1563 | settledCount: layout.settledCount, | |
| 1564 | settledShelfHeaderIndex: layout.settledShelfHeaderIndex, | |
| 1565 | snoozeLabelNow: now, | |
| 1566 | ...(options?.snoozeEnvironmentIds | |
| 1567 | ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds } | |
| 1568 | : {}), | |
| 1569 | ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}), | |
| 1570 | ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}), | |
| 1571 | ...(options?.shelfPreferencesLoading !== undefined | |
| 1572 | ? { shelfPreferencesLoading: options.shelfPreferencesLoading } | |
| 1573 | : {}), | |
| 1574 | }); | |
| 1575 | } | |
| 1576 | ||
| 1577 | function itemsByThreadKey(items: ReadonlyArray<ThreadListV2ListItem>) { | |
| 1578 | const byKey = new Map<string, ThreadListV2ListItem>(); | |
| 1579 | for (const item of items) byKey.set(item.key, item); | |
| 1580 | return byKey; | |
| 1581 | } | |
| 1582 | ||
| 1583 | describe("threadListV2ListItemsAreEqual", () => { | |
| 1584 | const thread = makeThread({ | |
| 1585 | id: ThreadId.make("eq"), | |
| 1586 | title: "eq", | |
| 1587 | latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), | |
| 1588 | }); | |
| 1589 | const layout = buildThreadListV2Items({ | |
| 1590 | threads: [thread], | |
| 1591 | environmentId: null, | |
| 1592 | searchQuery: "", | |
| 1593 | now: NOW, | |
| 1594 | }); | |
| 1595 | // One queued task object shared across builds: identity, not content, is | |
| 1596 | // what the row equality compares (mirrors the store's stable references). | |
| 1597 | const queued = makePendingTask("eq-queued"); | |
| 1598 | const build = () => | |
| 1599 | buildThreadListV2ListItems({ | |
| 1600 | items: layout.items, | |
| 1601 | pendingTasks: [queued], | |
| 1602 | snoozeLabelNow: NOW, | |
| 1603 | }); | |
| 1604 | ||
| 1605 | it("treats rebuilt wrappers over identical rows as equal", () => { | |
| 1606 | const first = build(); | |
| 1607 | const second = build(); | |
| 1608 | expect(first.length).toBe(second.length); | |
| 1609 | for (let index = 0; index < first.length; index += 1) { | |
| 1610 | expect(first[index]).not.toBe(second[index]); | |
| 1611 | expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true); | |
| 1612 | } | |
| 1613 | }); | |
| 1614 | ||
| 1615 | it("notices a replaced thread shell", () => { | |
| 1616 | const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" }); | |
| 1617 | const rebuilt = buildThreadListV2Items({ | |
| 1618 | threads: [replacement], | |
| 1619 | environmentId: null, | |
| 1620 | searchQuery: "", | |
| 1621 | now: NOW, | |
| 1622 | }); | |
| 1623 | const next = buildThreadListV2ListItems({ | |
| 1624 | items: rebuilt.items, | |
| 1625 | pendingTasks: [], | |
| 1626 | snoozeLabelNow: NOW, | |
| 1627 | }); | |
| 1628 | const previousThread = build().find((item) => item.type === "v2-thread")!; | |
| 1629 | const nextThread = next.find((item) => item.type === "v2-thread")!; | |
| 1630 | expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false); | |
| 1631 | }); | |
| 1632 | ||
| 1633 | it("notices a changed wake countdown label", () => { | |
| 1634 | const snoozedLayout = buildThreadListV2Items({ | |
| 1635 | threads: [ | |
| 1636 | makeThread({ | |
| 1637 | id: ThreadId.make("wake"), | |
| 1638 | title: "wake", | |
| 1639 | snoozedAt: NOW, | |
| 1640 | snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS), | |
| 1641 | }), | |
| 1642 | ], | |
| 1643 | environmentId: null, | |
| 1644 | searchQuery: "", | |
| 1645 | now: NOW, | |
| 1646 | snoozedShelfExpanded: true, | |
| 1647 | }); | |
| 1648 | const earlier = buildThreadListV2ListItems({ | |
| 1649 | items: snoozedLayout.items, | |
| 1650 | pendingTasks: [], | |
| 1651 | snoozedShelfExpanded: true, | |
| 1652 | snoozeLabelNow: NOW, | |
| 1653 | }); | |
| 1654 | const later = buildThreadListV2ListItems({ | |
| 1655 | items: snoozedLayout.items, | |
| 1656 | pendingTasks: [], | |
| 1657 | snoozedShelfExpanded: true, | |
| 1658 | snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS), | |
| 1659 | }); | |
| 1660 | const rows = (items: ThreadListV2ListItem[]) => { | |
| 1661 | const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`); | |
| 1662 | expect(row?.type).toBe("v2-thread"); | |
| 1663 | if (row?.type !== "v2-thread") throw new Error("missing snoozed row"); | |
| 1664 | return row; | |
| 1665 | }; | |
| 1666 | expect(rows(earlier).snoozeWakeLabelText).toBe("2h"); | |
| 1667 | expect(rows(later).snoozeWakeLabelText).toBe("1h"); | |
| 1668 | expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false); | |
| 1669 | }); | |
| 1670 | ||
| 1671 | it("notices shelf count, expansion, and loading-disabled changes", () => { | |
| 1672 | const shelf = { | |
| 1673 | type: "v2-settled-shelf", | |
| 1674 | key: "v2-settled-shelf", | |
| 1675 | count: 2, | |
| 1676 | expanded: true, | |
| 1677 | disabled: false, | |
| 1678 | } as const; | |
| 1679 | expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true); | |
| 1680 | expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false); | |
| 1681 | expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false); | |
| 1682 | // A recycled cell ignores the render closure, so the shelf header's | |
| 1683 | // preference-loading disabled state has to ride on the item too. | |
| 1684 | expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false); | |
| 1685 | }); | |
| 1686 | ||
| 1687 | it("treats different item kinds as unequal", () => { | |
| 1688 | const built = build(); | |
| 1689 | const threadItem = built.find((item) => item.type === "v2-thread")!; | |
| 1690 | const pendingItem = built.find((item) => item.type === "v2-pending")!; | |
| 1691 | expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false); | |
| 1692 | }); | |
| 1693 | ||
| 1694 | it("notices a trailing-divider flip caused by a neighbour change", () => { | |
| 1695 | const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" }); | |
| 1696 | const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" }); | |
| 1697 | const bare = buildThreadListV2ListItems({ | |
| 1698 | items: buildThreadListV2Items({ | |
| 1699 | threads: [threadA, threadB], | |
| 1700 | environmentId: null, | |
| 1701 | searchQuery: "", | |
| 1702 | now: NOW, | |
| 1703 | }).items, | |
| 1704 | pendingTasks: [], | |
| 1705 | snoozeLabelNow: NOW, | |
| 1706 | }); | |
| 1707 | // The same shells with B settled: A now sits above the Settled section | |
| 1708 | // rule instead of another row, so A's hairline must flip through the | |
| 1709 | // recycled equality — its own shell reference never changed. | |
| 1710 | const settledB = makeThread({ | |
| 1711 | id: ThreadId.make("flip-b"), | |
| 1712 | title: "flip b", | |
| 1713 | settledOverride: "settled", | |
| 1714 | settledAt: NOW, | |
| 1715 | }); | |
| 1716 | const withSettled = buildThreadListV2ListItems({ | |
| 1717 | items: buildThreadListV2Items({ | |
| 1718 | threads: [threadA, settledB], | |
| 1719 | environmentId: null, | |
| 1720 | searchQuery: "", | |
| 1721 | now: NOW, | |
| 1722 | }).items, | |
| 1723 | pendingTasks: [], | |
| 1724 | settledCount: 1, | |
| 1725 | settledShelfHeaderIndex: 1, | |
| 1726 | snoozeLabelNow: NOW, | |
| 1727 | }); | |
| 1728 | const firstA = bare[0]!; | |
| 1729 | const secondA = withSettled[0]!; | |
| 1730 | expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true); | |
| 1731 | expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false); | |
| 1732 | expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false); | |
| 1733 | }); | |
| 1734 | }); | |
| 1735 | ||
| 1736 | describe("isThreadListV2ListItem", () => { | |
| 1737 | it("narrows the v2 kinds and rejects the legacy discriminators", () => { | |
| 1738 | expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true); | |
| 1739 | expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true); | |
| 1740 | expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true); | |
| 1741 | expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true); | |
| 1742 | expect(isThreadListV2ListItem({ type: "thread" })).toBe(false); | |
| 1743 | expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false); | |
| 1744 | }); | |
| 1745 | }); | |
| 1746 | ||
| 1747 | describe("buildThreadListV2ListItems clock scoping", () => { | |
| 1748 | const allEnvironments = new Set<EnvironmentId>([environmentId]); | |
| 1749 | const tickQueued = () => [makePendingTask("tick-queued")]; | |
| 1750 | ||
| 1751 | it("carries the snooze menu clock on every row whose swipe menu offers presets", () => { | |
| 1752 | const threads = buildTickThreads(); | |
| 1753 | const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), { | |
| 1754 | snoozeEnvironmentIds: allEnvironments, | |
| 1755 | }); | |
| 1756 | const byKey = itemsByThreadKey(items); | |
| 1757 | const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!; | |
| 1758 | const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!; | |
| 1759 | const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!; | |
| 1760 | const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; | |
| 1761 | expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW); | |
| 1762 | // The swipe-revealed snooze action exists on slim rows too (the variant | |
| 1763 | // only swaps the primary action), so settled rows need the fresh clock. | |
| 1764 | expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW); | |
| 1765 | // Approval rows are never snoozable; snoozed rows only offer Wake. | |
| 1766 | expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined(); | |
| 1767 | expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined(); | |
| 1768 | }); | |
| 1769 | ||
| 1770 | it("keeps the snooze menu clock off rows on servers without the capability", () => { | |
| 1771 | const threads = buildTickThreads(); | |
| 1772 | const items = buildTickList([threads.ready], BASE_MS, [], { | |
| 1773 | snoozeEnvironmentIds: new Set<EnvironmentId>(), | |
| 1774 | }); | |
| 1775 | const ready = items.find((item) => item.type === "v2-thread")!; | |
| 1776 | expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined(); | |
| 1777 | }); | |
| 1778 | ||
| 1779 | it("blanks the precomputed time for rows that render a label instead", () => { | |
| 1780 | vi.useFakeTimers(); | |
| 1781 | try { | |
| 1782 | vi.setSystemTime(BASE_MS); | |
| 1783 | const working = makeThread({ | |
| 1784 | id: ThreadId.make("tick-working"), | |
| 1785 | title: "tick working", | |
| 1786 | latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), | |
| 1787 | session: runningSession("tick-working"), | |
| 1788 | }); | |
| 1789 | const threads = buildTickThreads(); | |
| 1790 | const items = buildTickList( | |
| 1791 | [threads.ready, working, threads.settled, threads.snoozed], | |
| 1792 | BASE_MS, | |
| 1793 | tickQueued(), | |
| 1794 | ); | |
| 1795 | const byKey = itemsByThreadKey(items); | |
| 1796 | const label = (key: string) => { | |
| 1797 | const item = byKey.get(key)!; | |
| 1798 | return item.type === "v2-thread" ? item.timeLabel : "<shelf>"; | |
| 1799 | }; | |
| 1800 | // Ready cards and settled/snoozed slim rows draw a time; the wake | |
| 1801 | // countdown outranks the time on snoozed rows; cards with a status | |
| 1802 | // label never draw one. | |
| 1803 | expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m"); | |
| 1804 | expect(label(`v2-thread:${environmentId}:tick-working`)).toBe(""); | |
| 1805 | expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d"); | |
| 1806 | expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe(""); | |
| 1807 | const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!; | |
| 1808 | expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h"); | |
| 1809 | } finally { | |
| 1810 | vi.useRealTimers(); | |
| 1811 | } | |
| 1812 | }); | |
| 1813 | }); | |
| 1814 | ||
| 1815 | describe("thread list v2 minute tick invalidation", () => { | |
| 1816 | it("only invalidates rows whose clock-driven content moved", () => { | |
| 1817 | vi.useFakeTimers(); | |
| 1818 | try { | |
| 1819 | vi.setSystemTime(BASE_MS); | |
| 1820 | const threads = buildTickThreads(); | |
| 1821 | const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed]; | |
| 1822 | // One queued-task reference shared by both builds: the store hands the | |
| 1823 | // list the same pending-task objects between rebuilds. | |
| 1824 | const pendingTasks = [makePendingTask("tick-queued")]; | |
| 1825 | const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks); | |
| 1826 | vi.setSystemTime(BASE_MS + MINUTE_MS); | |
| 1827 | const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks); | |
| 1828 | ||
| 1829 | expect(atStart.length).toBe(atNextMinute.length); | |
| 1830 | const invalidated: string[] = []; | |
| 1831 | for (let index = 0; index < atStart.length; index += 1) { | |
| 1832 | if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) { | |
| 1833 | invalidated.push(atStart[index]!.key); | |
| 1834 | } | |
| 1835 | } | |
| 1836 | // The ready row draws a minute-granular time and carries the snooze | |
| 1837 | // menu, and the settled slim row's swipe-revealed snooze menu shows | |
| 1838 | // preset times too, so both rows' menu content moved. Every other row | |
| 1839 | // — the approval card (status label, never snoozable), the snoozed | |
| 1840 | // shelf row ("2h" unchanged, Wake only), the shelf headers, and the | |
| 1841 | // queued row — survives the tick untouched. | |
| 1842 | expect(invalidated).toEqual([ | |
| 1843 | `v2-thread:${environmentId}:tick-ready`, | |
| 1844 | `v2-thread:${environmentId}:tick-settled`, | |
| 1845 | ]); | |
| 1846 | } finally { | |
| 1847 | vi.useRealTimers(); | |
| 1848 | } | |
| 1849 | }); | |
| 1850 | ||
| 1851 | it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => { | |
| 1852 | vi.useFakeTimers(); | |
| 1853 | try { | |
| 1854 | vi.setSystemTime(BASE_MS); | |
| 1855 | const staleReady = makeThread({ | |
| 1856 | id: ThreadId.make("tick-stale"), | |
| 1857 | title: "tick stale", | |
| 1858 | latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS), | |
| 1859 | }); | |
| 1860 | const atStart = buildTickList([staleReady], BASE_MS, [], { | |
| 1861 | snoozeEnvironmentIds: new Set<EnvironmentId>(), | |
| 1862 | }); | |
| 1863 | vi.setSystemTime(BASE_MS + MINUTE_MS); | |
| 1864 | const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], { | |
| 1865 | snoozeEnvironmentIds: new Set<EnvironmentId>(), | |
| 1866 | }); | |
| 1867 | expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true); | |
| 1868 | } finally { | |
| 1869 | vi.useRealTimers(); | |
| 1870 | } | |
| 1871 | }); | |
| 1872 | ||
| 1873 | it("re-renders the snoozed countdown row when the wake label advances", () => { | |
| 1874 | vi.useFakeTimers(); | |
| 1875 | try { | |
| 1876 | const wakeAt = BASE_MS + 120 * MINUTE_MS; | |
| 1877 | const snoozed = makeThread({ | |
| 1878 | id: ThreadId.make("tick-wake"), | |
| 1879 | title: "tick wake", | |
| 1880 | snoozedAt: isoAt(BASE_MS - MINUTE_MS), | |
| 1881 | snoozedUntil: isoAt(wakeAt), | |
| 1882 | }); | |
| 1883 | const wakeKey = `v2-thread:${environmentId}:tick-wake`; | |
| 1884 | const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!; | |
| 1885 | vi.setSystemTime(BASE_MS); | |
| 1886 | const atStart = buildTickList([snoozed], BASE_MS, []); | |
| 1887 | vi.setSystemTime(BASE_MS + MINUTE_MS); | |
| 1888 | const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []); | |
| 1889 | expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true); | |
| 1890 | // Minutes round up, so the countdown holds "2h" until the remaining | |
| 1891 | // time drops to the hour boundary — and only that row flips when it | |
| 1892 | // finally moves to minute granularity. | |
| 1893 | vi.setSystemTime(BASE_MS + 61 * MINUTE_MS); | |
| 1894 | const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []); | |
| 1895 | const oneHourRow = wakeRow(oneHour); | |
| 1896 | expect(oneHourRow.type).toBe("v2-thread"); | |
| 1897 | expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m"); | |
| 1898 | expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false); | |
| 1899 | } finally { | |
| 1900 | vi.useRealTimers(); | |
| 1901 | } | |
| 1902 | }); | |
| 1903 | }); | |
| 1904 | ||
| 1905 | describe("buildThreadListV2ListItems trailing dividers", () => { | |
| 1906 | it("follows the final neighbour order, not the pre-splice blocks", () => { | |
| 1907 | const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" }); | |
| 1908 | const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" }); | |
| 1909 | const layout = buildThreadListV2Items({ | |
| 1910 | threads: [activeA, activeB], | |
| 1911 | environmentId: null, | |
| 1912 | searchQuery: "", | |
| 1913 | now: NOW, | |
| 1914 | }); | |
| 1915 | const items = buildThreadListV2ListItems({ | |
| 1916 | items: layout.items, | |
| 1917 | pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")], | |
| 1918 | snoozeLabelNow: NOW, | |
| 1919 | }); | |
| 1920 | const dividers = items.map((item) => | |
| 1921 | item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a", | |
| 1922 | ); | |
| 1923 | // thread A | thread B | queued 1 | queued 2: consecutive threads keep | |
| 1924 | // their hairlines, the row before the Unsent section rule loses its own, | |
| 1925 | // queued rows divide each other, and the last row has nothing under it. | |
| 1926 | expect(dividers).toEqual([true, false, true, false]); | |
| 1927 | }); | |
| 1928 | }); | |
| 1929 | ||
| 1930 | describe("buildThreadListV2ListItems row-state stamps", () => { | |
| 1931 | const readyThread = makeThread({ | |
| 1932 | id: ThreadId.make("stamp-ready"), | |
| 1933 | title: "stamp ready", | |
| 1934 | latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS), | |
| 1935 | }); | |
| 1936 | const settledThread = makeThread({ | |
| 1937 | id: ThreadId.make("stamp-settled"), | |
| 1938 | title: "stamp settled", | |
| 1939 | settledOverride: "settled", | |
| 1940 | settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS), | |
| 1941 | }); | |
| 1942 | const allEnvironments = new Set<EnvironmentId>([environmentId]); | |
| 1943 | ||
| 1944 | it("stamps queued outbox messages onto the matching row and notices removal", () => { | |
| 1945 | // An outbox write never touches the thread shell, so the queued icon has | |
| 1946 | // to ride on the item for the recycled cell to ever update it. | |
| 1947 | const queued = buildTickList([readyThread, settledThread], BASE_MS, [], { | |
| 1948 | queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]), | |
| 1949 | snoozeEnvironmentIds: allEnvironments, | |
| 1950 | }); | |
| 1951 | const plain = buildTickList([readyThread, settledThread], BASE_MS, [], { | |
| 1952 | queuedThreadKeys: new Set<string>(), | |
| 1953 | snoozeEnvironmentIds: allEnvironments, | |
| 1954 | }); | |
| 1955 | const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items); | |
| 1956 | const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!; | |
| 1957 | const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!; | |
| 1958 | const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!; | |
| 1959 | const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!; | |
| 1960 | expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true); | |
| 1961 | expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false); | |
| 1962 | expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false); | |
| 1963 | // The neighbour row is untouched by the outbox change. | |
| 1964 | expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true); | |
| 1965 | }); | |
| 1966 | ||
| 1967 | it("notices move-availability changes on card rows without a shell update", () => { | |
| 1968 | const permissive = new Map([ | |
| 1969 | [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }], | |
| 1970 | [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], | |
| 1971 | ]); | |
| 1972 | const blocked = new Map([ | |
| 1973 | [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }], | |
| 1974 | ]); | |
| 1975 | const open = buildTickList([readyThread, settledThread], BASE_MS, [], { | |
| 1976 | moveAvailability: permissive, | |
| 1977 | snoozeEnvironmentIds: allEnvironments, | |
| 1978 | }); | |
| 1979 | const closed = buildTickList([readyThread, settledThread], BASE_MS, [], { | |
| 1980 | moveAvailability: blocked, | |
| 1981 | snoozeEnvironmentIds: allEnvironments, | |
| 1982 | }); | |
| 1983 | const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!; | |
| 1984 | const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!; | |
| 1985 | expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true); | |
| 1986 | expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false); | |
| 1987 | expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false); | |
| 1988 | // Slim rows never carry the move actions, so availability is inert there. | |
| 1989 | const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!; | |
| 1990 | const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!; | |
| 1991 | expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false); | |
| 1992 | expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true); | |
| 1993 | }); | |
| 1994 | ||
| 1995 | it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => { | |
| 1996 | vi.useFakeTimers(); | |
| 1997 | try { | |
| 1998 | vi.setSystemTime(BASE_MS); | |
| 1999 | const atStart = buildTickList([settledThread], BASE_MS, [], { | |
| 2000 | snoozeEnvironmentIds: allEnvironments, | |
| 2001 | }); | |
| 2002 | vi.setSystemTime(BASE_MS + MINUTE_MS); | |
| 2003 | const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], { | |
| 2004 | snoozeEnvironmentIds: allEnvironments, | |
| 2005 | }); | |
| 2006 | const rowAtStart = atStart.find((item) => item.type === "v2-thread")!; | |
| 2007 | const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!; | |
| 2008 | // The swipe-revealed secondary action carries the snooze preset menu on | |
| 2009 | // slim rows; its minute clock must move, or the displayed wake times | |
| 2010 | // drift while the row is recycled-stable. | |
| 2011 | expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim"); | |
| 2012 | expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS)); | |
| 2013 | expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe( | |
| 2014 | isoAt(BASE_MS + MINUTE_MS), | |
| 2015 | ); | |
| 2016 | expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false); | |
| 2017 | } finally { | |
| 2018 | vi.useRealTimers(); | |
| 2019 | } | |
| 2020 | }); | |
| 2021 | ||
| 2022 | it("stamps the shelf loading-disabled state so recycled headers refresh", () => { | |
| 2023 | const loading = buildTickList([settledThread], BASE_MS, [], { | |
| 2024 | shelfPreferencesLoading: true, | |
| 2025 | snoozeEnvironmentIds: allEnvironments, | |
| 2026 | }); | |
| 2027 | const loaded = buildTickList([settledThread], BASE_MS, [], { | |
| 2028 | shelfPreferencesLoading: false, | |
| 2029 | snoozeEnvironmentIds: allEnvironments, | |
| 2030 | }); | |
| 2031 | const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!; | |
| 2032 | const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!; | |
| 2033 | expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true); | |
| 2034 | expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false); | |
| 2035 | expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false); | |
| 2036 | }); | |
| 2037 | }); |
apps/mobile/src/features/threads/threadListV2.ts+184-8| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests"; |
| 2 | 2 | import { |
| 3 | canSnooze, | |
| 3 | 4 | effectiveSnoozed, |
| 4 | 5 | hasQueuedTurnStart, |
| 5 | 6 | QUEUED_TURN_START_GRACE_MS, |
| ... | ... | @@ -16,6 +17,9 @@ import { |
| 16 | 17 | } from "@t3tools/client-runtime/state/thread-sort"; |
| 17 | 18 | import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; |
| 18 | 19 | |
| 20 | import type { ThreadMoveAvailability } from "./threadOrder"; | |
| 21 | ||
| 22 | import { relativeTime } from "../../lib/time"; | |
| 19 | 23 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 20 | 24 | |
| 21 | 25 | import { |
| ... | ... | @@ -244,6 +248,28 @@ export interface ThreadListV2ThreadListItem { |
| 244 | 248 | readonly item: ThreadListV2Item; |
| 245 | 249 | /** Precomputed so recycled-list equality can see a minute-tick change. */ |
| 246 | 250 | readonly snoozeWakeLabelText: string | undefined; |
| 251 | /** Row timestamp precomputed against the parent clock so the recycler's | |
| 252 | equality only sees a change on rows that actually draw a time. Blank | |
| 253 | while the row renders a status label or the wake countdown instead. */ | |
| 254 | readonly timeLabel: string; | |
| 255 | /** Minute clock feeding the row's native snooze menu, carried only on rows | |
| 256 | whose menu holds snooze presets (capability-gated cards the user may | |
| 257 | snooze). Keeping it out of the list's `extraData` means the minute tick | |
| 258 | re-renders just these rows instead of every visible one. */ | |
| 259 | readonly snoozePresetMinute: string | undefined; | |
| 260 | /** Inset hairline drawn under the row. Precomputed from the final order so | |
| 261 | a neighbour change (e.g. the queued block appearing) updates the row | |
| 262 | through recycled-list equality instead of leaving a stale divider. */ | |
| 263 | readonly showTrailingDivider: boolean; | |
| 264 | /** A message for this thread is waiting in the outbox. Carried on the item | |
| 265 | so an outbox write (which never touches the thread shell) reaches the | |
| 266 | row through recycled-list equality instead of leaving a stale icon. */ | |
| 267 | readonly hasQueuedMessages: boolean; | |
| 268 | /** Move up/down availability in the row's card section. Carried on the item | |
| 269 | for the same reason: a reorder in flight (or its commit) changes menu | |
| 270 | availability without changing any shell. */ | |
| 271 | readonly canMoveUp: boolean; | |
| 272 | readonly canMoveDown: boolean; | |
| 247 | 273 | } |
| 248 | 274 | |
| 249 | 275 | export interface ThreadListV2PendingListItem { |
| ... | ... | @@ -252,6 +278,9 @@ export interface ThreadListV2PendingListItem { |
| 252 | 278 | readonly pendingTask: PendingNewTask; |
| 253 | 279 | /** First queued row after the active block draws the PENDING divider. */ |
| 254 | 280 | readonly showPendingDivider: boolean; |
| 281 | /** Same rule as the thread rows: a hairline unless the next row carries its | |
| 282 | own section rule or none follows. */ | |
| 283 | readonly showTrailingDivider: boolean; | |
| 255 | 284 | } |
| 256 | 285 | |
| 257 | 286 | export interface ThreadListV2SnoozedShelfListItem { |
| ... | ... | @@ -259,6 +288,11 @@ export interface ThreadListV2SnoozedShelfListItem { |
| 259 | 288 | readonly key: "v2-snoozed-shelf"; |
| 260 | 289 | readonly count: number; |
| 261 | 290 | readonly expanded: boolean; |
| 291 | /** Shelf preferences still loading: the toggle is disabled until they | |
| 292 | arrive. Carried on the item because a recycled cell ignores the render | |
| 293 | closure — without it the header would stay visibly disabled (or enabled | |
| 294 | too early) after the preference load lands. */ | |
| 295 | readonly disabled: boolean; | |
| 262 | 296 | } |
| 263 | 297 | |
| 264 | 298 | export interface ThreadListV2SettledShelfListItem { |
| ... | ... | @@ -266,6 +300,8 @@ export interface ThreadListV2SettledShelfListItem { |
| 266 | 300 | readonly key: "v2-settled-shelf"; |
| 267 | 301 | readonly count: number; |
| 268 | 302 | readonly expanded: boolean; |
| 303 | /** See the snoozed shelf header's field. */ | |
| 304 | readonly disabled: boolean; | |
| 269 | 305 | } |
| 270 | 306 | |
| 271 | 307 | export type ThreadListV2ListItem = |
| ... | ... | @@ -274,6 +310,90 @@ export type ThreadListV2ListItem = |
| 274 | 310 | | ThreadListV2SnoozedShelfListItem |
| 275 | 311 | | ThreadListV2SettledShelfListItem; |
| 276 | 312 | |
| 313 | /** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to | |
| 314 | the v2 item kinds the shared equality understands. */ | |
| 315 | export function isThreadListV2ListItem(value: { | |
| 316 | readonly type: string; | |
| 317 | }): value is ThreadListV2ListItem { | |
| 318 | return ( | |
| 319 | value.type === "v2-thread" || | |
| 320 | value.type === "v2-pending" || | |
| 321 | value.type === "v2-snoozed-shelf" || | |
| 322 | value.type === "v2-settled-shelf" | |
| 323 | ); | |
| 324 | } | |
| 325 | ||
| 326 | /** Recycled-list equality for the flat v2 list (Home + iPad sidebar). | |
| 327 | Item objects are rebuilt on every minute tick and every partition run; | |
| 328 | without this the lists would consider every mounted row changed and | |
| 329 | re-render all of them (each carrying a swipeable + a PR subscription). | |
| 330 | The clock-derived fields are per-row precomputed text, so a minute tick | |
| 331 | only flips equality on rows whose visible text (or snooze menu) actually | |
| 332 | moved. Thread references are stable across rebuilds. */ | |
| 333 | export function threadListV2ListItemsAreEqual( | |
| 334 | previous: ThreadListV2ListItem, | |
| 335 | item: ThreadListV2ListItem, | |
| 336 | ): boolean { | |
| 337 | switch (item.type) { | |
| 338 | case "v2-thread": | |
| 339 | return ( | |
| 340 | previous.type === "v2-thread" && | |
| 341 | previous.key === item.key && | |
| 342 | previous.item.thread === item.item.thread && | |
| 343 | previous.item.variant === item.item.variant && | |
| 344 | previous.item.snoozed === item.item.snoozed && | |
| 345 | previous.item.pinned === item.item.pinned && | |
| 346 | previous.snoozeWakeLabelText === item.snoozeWakeLabelText && | |
| 347 | previous.timeLabel === item.timeLabel && | |
| 348 | previous.snoozePresetMinute === item.snoozePresetMinute && | |
| 349 | previous.showTrailingDivider === item.showTrailingDivider && | |
| 350 | previous.hasQueuedMessages === item.hasQueuedMessages && | |
| 351 | previous.canMoveUp === item.canMoveUp && | |
| 352 | previous.canMoveDown === item.canMoveDown | |
| 353 | ); | |
| 354 | case "v2-pending": | |
| 355 | return ( | |
| 356 | previous.type === "v2-pending" && | |
| 357 | previous.key === item.key && | |
| 358 | previous.pendingTask === item.pendingTask && | |
| 359 | previous.showPendingDivider === item.showPendingDivider && | |
| 360 | previous.showTrailingDivider === item.showTrailingDivider | |
| 361 | ); | |
| 362 | case "v2-snoozed-shelf": | |
| 363 | return ( | |
| 364 | previous.type === "v2-snoozed-shelf" && | |
| 365 | previous.count === item.count && | |
| 366 | previous.expanded === item.expanded && | |
| 367 | previous.disabled === item.disabled | |
| 368 | ); | |
| 369 | case "v2-settled-shelf": | |
| 370 | return ( | |
| 371 | previous.type === "v2-settled-shelf" && | |
| 372 | previous.count === item.count && | |
| 373 | previous.expanded === item.expanded && | |
| 374 | previous.disabled === item.disabled | |
| 375 | ); | |
| 376 | } | |
| 377 | } | |
| 378 | ||
| 379 | /** The timestamp a row renders when it shows no status label: the settle | |
| 380 | stamp on settled slim rows, otherwise the latest activity. Blank for | |
| 381 | status-labelled cards and snoozed rows with a wake countdown — those | |
| 382 | never draw a time, so their minute tick must not invalidate the cell. */ | |
| 383 | function resolveThreadListV2ItemTimeLabel( | |
| 384 | item: ThreadListV2Item, | |
| 385 | showSnoozeWakeLabel: boolean, | |
| 386 | ): string { | |
| 387 | const { thread, variant, snoozed } = item; | |
| 388 | if (showSnoozeWakeLabel) return ""; | |
| 389 | if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return ""; | |
| 390 | const settledTimestamp = | |
| 391 | variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null; | |
| 392 | return relativeTime( | |
| 393 | settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, | |
| 394 | ); | |
| 395 | } | |
| 396 | ||
| 277 | 397 | /** |
| 278 | 398 | * Builds the shared mobile order: active → pending → snoozed shelf → settled. |
| 279 | 399 | * Pending tasks are waiting rather than asking, and parked work remains |
| ... | ... | @@ -289,21 +409,64 @@ export function buildThreadListV2ListItems(input: { |
| 289 | 409 | readonly settledShelfExpanded?: boolean; |
| 290 | 410 | readonly settledShelfHeaderIndex?: number | null; |
| 291 | 411 | readonly snoozeLabelNow?: string; |
| 412 | /** Environments whose server supports thread.snooze. Rows on other | |
| 413 | environments never carry the minute clock that feeds the snooze menu. | |
| 414 | Absent = no gating (tests). */ | |
| 415 | readonly snoozeEnvironmentIds?: ReadonlySet<EnvironmentId>; | |
| 416 | /** Thread keys (`environmentId:threadId`) with a message waiting in the | |
| 417 | outbox; stamped onto the matching rows as `hasQueuedMessages`. */ | |
| 418 | readonly queuedThreadKeys?: ReadonlySet<string>; | |
| 419 | /** Menu availability for Move up/down, keyed by `environmentId:threadId` | |
| 420 | (only consulted for card rows — slim menus omit the moves). Produced by | |
| 421 | `computeThreadMoveAvailability` in one pass per section; a recycled cell | |
| 422 | ignores the render closure, so the stamps ride on the item. Absent = | |
| 423 | never available (tests). */ | |
| 424 | readonly moveAvailability?: ReadonlyMap<string, ThreadMoveAvailability>; | |
| 425 | /** True while the shelf expansion preferences are still loading; stamped | |
| 426 | onto both shelf headers so the disabled state reaches recycled cells. */ | |
| 427 | readonly shelfPreferencesLoading?: boolean; | |
| 292 | 428 | }): ThreadListV2ListItem[] { |
| 293 | const threadItems = input.items.map((item): ThreadListV2ListItem => ({ | |
| 294 | type: "v2-thread", | |
| 295 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, | |
| 296 | item, | |
| 297 | snoozeWakeLabelText: | |
| 429 | const threadItems = input.items.map((item): ThreadListV2ListItem => { | |
| 430 | const snoozeWakeLabelText = | |
| 298 | 431 | item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined |
| 299 | 432 | ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow }) |
| 300 | : undefined, | |
| 301 | })); | |
| 433 | : undefined; | |
| 434 | // The minute clock belongs on the item, not the list's extraData, so the | |
| 435 | // recycler's equality can confine the per-minute re-render to rows whose | |
| 436 | // snooze menu actually shows preset times. The swipe-revealed snooze menu | |
| 437 | // exists on slim rows too (the variant only swaps the primary action), | |
| 438 | // so the gate follows actual snooze availability, not the variant. | |
| 439 | const snoozePresetMinute = | |
| 440 | !item.snoozed && | |
| 441 | input.snoozeLabelNow !== undefined && | |
| 442 | (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) && | |
| 443 | canSnooze(item.thread, { now: input.snoozeLabelNow }) | |
| 444 | ? input.snoozeLabelNow | |
| 445 | : undefined; | |
| 446 | const move = | |
| 447 | item.variant === "card" | |
| 448 | ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`) | |
| 449 | : undefined; | |
| 450 | return { | |
| 451 | type: "v2-thread", | |
| 452 | key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`, | |
| 453 | item, | |
| 454 | snoozeWakeLabelText, | |
| 455 | timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined), | |
| 456 | snoozePresetMinute, | |
| 457 | showTrailingDivider: false, | |
| 458 | hasQueuedMessages: | |
| 459 | input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true, | |
| 460 | canMoveUp: move?.canMoveUp === true, | |
| 461 | canMoveDown: move?.canMoveDown === true, | |
| 462 | }; | |
| 463 | }); | |
| 302 | 464 | const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({ |
| 303 | 465 | type: "v2-pending", |
| 304 | 466 | key: `v2-${pendingTask.key}`, |
| 305 | 467 | pendingTask, |
| 306 | 468 | showPendingDivider: index === 0, |
| 469 | showTrailingDivider: false, | |
| 307 | 470 | })); |
| 308 | 471 | const snoozedCount = input.snoozedCount ?? 0; |
| 309 | 472 | const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null; |
| ... | ... | @@ -312,12 +475,14 @@ export function buildThreadListV2ListItems(input: { |
| 312 | 475 | const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length; |
| 313 | 476 | const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length; |
| 314 | 477 | const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems]; |
| 478 | const shelfDisabled = input.shelfPreferencesLoading === true; | |
| 315 | 479 | if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) { |
| 316 | 480 | result.push({ |
| 317 | 481 | type: "v2-snoozed-shelf", |
| 318 | 482 | key: "v2-snoozed-shelf", |
| 319 | 483 | count: snoozedCount, |
| 320 | 484 | expanded: input.snoozedShelfExpanded === true, |
| 485 | disabled: shelfDisabled, | |
| 321 | 486 | }); |
| 322 | 487 | result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd)); |
| 323 | 488 | } |
| ... | ... | @@ -327,10 +492,21 @@ export function buildThreadListV2ListItems(input: { |
| 327 | 492 | key: "v2-settled-shelf", |
| 328 | 493 | count: settledCount, |
| 329 | 494 | expanded: input.settledShelfExpanded !== false, |
| 495 | disabled: shelfDisabled, | |
| 330 | 496 | }); |
| 331 | 497 | result.push(...threadItems.slice(settledShelfHeaderIndex)); |
| 332 | 498 | } |
| 333 | return result; | |
| 499 | // Hairlines depend on the final neighbour, so they are stamped after the | |
| 500 | // splice: a recycled cell only re-renders when its divider actually flips. | |
| 501 | return result.map((entry, index) => { | |
| 502 | if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry; | |
| 503 | const next = result[index + 1]; | |
| 504 | const showTrailingDivider = | |
| 505 | next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider); | |
| 506 | return showTrailingDivider === entry.showTrailingDivider | |
| 507 | ? entry | |
| 508 | : { ...entry, showTrailingDivider }; | |
| 509 | }); | |
| 334 | 510 | } |
| 335 | 511 | |
| 336 | 512 | /** |
apps/mobile/src/features/threads/threadOrder.ts+143-2| ... | ... | @@ -1,5 +1,9 @@ |
| 1 | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; | |
| 2 | import { | |
| 3 | generateSpreadPinOrderKeys, | |
| 4 | pinOrderKeyBetween, | |
| 5 | planPinnedReorder, | |
| 6 | } from "@t3tools/client-runtime/state/thread-sort"; | |
| 3 | 7 | import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; |
| 4 | 8 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 5 | 9 | |
| ... | ... | @@ -42,7 +46,7 @@ export function threadOrderAfterMove( |
| 42 | 46 | return result; |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | type OrderRow = Pick< | |
| 49 | export type OrderRow = Pick< | |
| 46 | 50 | EnvironmentThreadShell, |
| 47 | 51 | | "id" |
| 48 | 52 | | "environmentId" |
| ... | ... | @@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: { |
| 106 | 110 | }; |
| 107 | 111 | } |
| 108 | 112 | |
| 113 | export interface ThreadMoveAvailability { | |
| 114 | readonly canMoveUp: boolean; | |
| 115 | readonly canMoveDown: boolean; | |
| 116 | } | |
| 117 | ||
| 118 | /** | |
| 119 | * Batch form of "call `createThreadMovePlanner` once per card": one pass over | |
| 120 | * the section answers up/down availability for every ordered row, so list | |
| 121 | * construction stays linear instead of one full planner probe (array copies, | |
| 122 | * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of | |
| 123 | * the planner's plan rules lives in the body below; the reference-parity test | |
| 124 | * pins them row-by-row, including adversarial keys and hidden reservations. | |
| 125 | */ | |
| 126 | export function computeThreadMoveAvailability(input: { | |
| 127 | readonly ordered: readonly OrderRow[]; | |
| 128 | readonly allThreads?: readonly OrderRow[]; | |
| 129 | readonly section: PendingThreadOrder["section"]; | |
| 130 | readonly reorderableEnvironmentIds: ReadonlySet<EnvironmentId>; | |
| 131 | readonly pendingOrder?: PendingThreadOrder | null; | |
| 132 | }): Map<string, ThreadMoveAvailability> { | |
| 133 | const result = new Map<string, ThreadMoveAvailability>(); | |
| 134 | // A reorder in flight locks the whole list until its receipt lands. | |
| 135 | if (input.pendingOrder != null) return result; | |
| 136 | const rows = input.ordered; | |
| 137 | const orderedIds = rows.map(rowId); | |
| 138 | const indexById = new Map(orderedIds.map((id, index) => [id, index] as const)); | |
| 139 | const keysById = new Map( | |
| 140 | (input.allThreads ?? input.ordered).map( | |
| 141 | (row) => [rowId(row), rowOrder(row, input.section).key] as const, | |
| 142 | ), | |
| 143 | ); | |
| 144 | const writableIds = new Set( | |
| 145 | (input.allThreads ?? input.ordered) | |
| 146 | .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) | |
| 147 | .map(rowId), | |
| 148 | ); | |
| 149 | const visibleIds = new Set(orderedIds); | |
| 150 | const reservedKeys = new Set( | |
| 151 | [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), | |
| 152 | ); | |
| 153 | // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is | |
| 154 | // answered in O(1) amortized instead of one planner probe per row: | |
| 155 | // | |
| 156 | // Fast path (both neighbors keyed): the fresh key between the landing | |
| 157 | // neighbors, walking forward while hidden reserved keys block it. The walk | |
| 158 | // depends only on the neighbor key pair - each adjacency is probed by at | |
| 159 | // most two rows (down of the left member, up of the right member) - so the | |
| 160 | // memo keeps even a fully adversarial reserved-key layout linear per build. | |
| 161 | // | |
| 162 | // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh | |
| 163 | // spread keys for every row; only positions whose current key differs get | |
| 164 | // written. A swap permutes two rows without changing the multiset, so the | |
| 165 | // assignment set differs from the unswapped baseline at at most those two | |
| 166 | // positions, and mismatch/writability tallies computed once per section | |
| 167 | // answer each row with a constant-size delta. | |
| 168 | const midpoints = new Map<string, string | null>(); | |
| 169 | const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => { | |
| 170 | const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`; | |
| 171 | const cached = midpoints.get(memoKey); | |
| 172 | if (cached !== undefined) return cached; | |
| 173 | let key = pinOrderKeyBetween(beforeKey, afterKey); | |
| 174 | while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); | |
| 175 | midpoints.set(memoKey, key); | |
| 176 | return key; | |
| 177 | }; | |
| 178 | const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) | |
| 179 | .filter((key) => !reservedKeys.has(key)) | |
| 180 | .slice(0, orderedIds.length); | |
| 181 | const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null); | |
| 182 | const writableRow = orderedIds.map((id) => writableIds.has(id)); | |
| 183 | let baselineWrites = 0; | |
| 184 | let baselineUnwritableWrites = 0; | |
| 185 | for (let position = 0; position < orderedIds.length; position += 1) { | |
| 186 | if (currentKeys[position] === spreadKeys[position]) continue; | |
| 187 | baselineWrites += 1; | |
| 188 | if (!writableRow[position]) baselineUnwritableWrites += 1; | |
| 189 | } | |
| 190 | // `movedId` swaps with its neighbor; ids at the two swapped positions change, | |
| 191 | // so only their tally contributions are recomputed. | |
| 192 | const rewriteViable = (index: number): boolean => { | |
| 193 | let writes = baselineWrites; | |
| 194 | let unwritableWrites = baselineUnwritableWrites; | |
| 195 | for (const position of [index, index + 1]) { | |
| 196 | const other = position === index ? index + 1 : index; | |
| 197 | if (currentKeys[position] !== spreadKeys[position]) { | |
| 198 | writes -= 1; | |
| 199 | if (!writableRow[position]) unwritableWrites -= 1; | |
| 200 | } | |
| 201 | // After the swap this position holds the row that was at `other`. | |
| 202 | if (currentKeys[other] !== spreadKeys[position]) { | |
| 203 | writes += 1; | |
| 204 | if (!writableRow[other]) unwritableWrites += 1; | |
| 205 | } | |
| 206 | } | |
| 207 | return writes > 0 && unwritableWrites === 0; | |
| 208 | }; | |
| 209 | for (const row of rows) { | |
| 210 | const movedId = rowId(row); | |
| 211 | const denied = { canMoveUp: false, canMoveDown: false }; | |
| 212 | if (!writableIds.has(movedId)) { | |
| 213 | result.set(movedId, denied); | |
| 214 | continue; | |
| 215 | } | |
| 216 | const index = indexById.get(movedId); | |
| 217 | if (index === undefined) { | |
| 218 | result.set(movedId, denied); | |
| 219 | continue; | |
| 220 | } | |
| 221 | const adjacentAvailable = (towardUp: boolean): boolean => { | |
| 222 | const shifted = index + (towardUp ? -1 : 1); | |
| 223 | if (shifted < 0 || shifted >= orderedIds.length) return false; | |
| 224 | // The swap exchanges the row with its neighbor; afterwards the moved row | |
| 225 | // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD | |
| 226 | // order: moving up it lands between old(index-2) and old(index-1), | |
| 227 | // moving down between old(index+1) and old(index+2). | |
| 228 | const beforeIndex = towardUp ? index - 2 : index + 1; | |
| 229 | const afterIndex = towardUp ? index - 1 : index + 2; | |
| 230 | const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null); | |
| 231 | const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null); | |
| 232 | const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null); | |
| 233 | const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null); | |
| 234 | if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) { | |
| 235 | const key = fastPathKey(beforeKey, afterKey); | |
| 236 | // A fresh key is a single-write plan for the (writable) moved row. | |
| 237 | if (key !== null) return true; | |
| 238 | } | |
| 239 | // Keyless neighbor or exhausted key space: the section rewrite runs. | |
| 240 | return rewriteViable(Math.min(index, shifted)); | |
| 241 | }; | |
| 242 | result.set(movedId, { | |
| 243 | canMoveUp: adjacentAvailable(true), | |
| 244 | canMoveDown: adjacentAvailable(false), | |
| 245 | }); | |
| 246 | } | |
| 247 | return result; | |
| 248 | } | |
| 249 | ||
| 109 | 250 | export function createPendingThreadOrder(input: { |
| 110 | 251 | readonly section: PendingThreadOrder["section"]; |
| 111 | 252 | readonly ordered: readonly OrderRow[]; |
apps/mobile/src/features/threads/threadOrderAvailability.test.ts created+265| ... | ... | @@ -0,0 +1,265 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { | |
| 3 | generateSpreadPinOrderKeys, | |
| 4 | pinOrderKeyBetween, | |
| 5 | } from "@t3tools/client-runtime/state/thread-sort"; | |
| 6 | import type { EnvironmentId } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { | |
| 9 | computeThreadMoveAvailability, | |
| 10 | createThreadMovePlanner, | |
| 11 | type OrderRow, | |
| 12 | } from "./threadOrder"; | |
| 13 | ||
| 14 | // The batch availability computation must answer exactly what the reference | |
| 15 | // per-move planner answers; these tests randomize sections that stress every | |
| 16 | // branch (keyless rows, hidden keys, non-writable rows, adversarial keys). | |
| 17 | ||
| 18 | function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow { | |
| 19 | return { | |
| 20 | id: id as OrderRow["id"], | |
| 21 | environmentId: environmentId as OrderRow["environmentId"], | |
| 22 | pinOrderKey: pinned ? key : null, | |
| 23 | activeOrderKey: pinned ? null : key, | |
| 24 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 25 | unsettledAt: "2026-01-01T00:00:00.000Z", | |
| 26 | pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null, | |
| 27 | }; | |
| 28 | } | |
| 29 | ||
| 30 | function makeRng(seed: number) { | |
| 31 | let state = seed >>> 0; | |
| 32 | return () => { | |
| 33 | state = (state * 1_664_525 + 1_013_904_223) >>> 0; | |
| 34 | return state / 0x1_0000_0000; | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | const KEY_POOL = [ | |
| 39 | "a", | |
| 40 | "b", | |
| 41 | "ba", | |
| 42 | "bb", | |
| 43 | "c", | |
| 44 | "d", | |
| 45 | "ca", | |
| 46 | "cb", | |
| 47 | "da", | |
| 48 | "db", | |
| 49 | "e", | |
| 50 | "f", | |
| 51 | "g", | |
| 52 | "h", | |
| 53 | "i", | |
| 54 | "j", | |
| 55 | "k", | |
| 56 | "l", | |
| 57 | "m", | |
| 58 | "n", | |
| 59 | "o", | |
| 60 | "p", | |
| 61 | "q", | |
| 62 | "r", | |
| 63 | "s", | |
| 64 | "t", | |
| 65 | "u", | |
| 66 | "v", | |
| 67 | "w", | |
| 68 | "x", | |
| 69 | "y", | |
| 70 | "z", | |
| 71 | null, | |
| 72 | ]; | |
| 73 | ||
| 74 | function referenceAvailability( | |
| 75 | ordered: readonly OrderRow[], | |
| 76 | allThreads: readonly OrderRow[], | |
| 77 | writable: ReadonlySet<EnvironmentId>, | |
| 78 | ) { | |
| 79 | const planner = createThreadMovePlanner({ | |
| 80 | ordered, | |
| 81 | allThreads, | |
| 82 | section: "pinned", | |
| 83 | reorderableEnvironmentIds: writable, | |
| 84 | }); | |
| 85 | const answers = new Map<string, { canMoveUp: boolean; canMoveDown: boolean }>(); | |
| 86 | for (const row of ordered) { | |
| 87 | const movedId = `${row.environmentId}:${row.id}`; | |
| 88 | answers.set(movedId, { | |
| 89 | canMoveUp: planner(movedId, "up") !== null, | |
| 90 | canMoveDown: planner(movedId, "down") !== null, | |
| 91 | }); | |
| 92 | } | |
| 93 | return answers; | |
| 94 | } | |
| 95 | ||
| 96 | function randomCase(rng: () => number) { | |
| 97 | const rowCount = 1 + Math.floor(rng() * 9); | |
| 98 | // Two environments; "writable" env vs. one lacking the reorder capability. | |
| 99 | const rows: OrderRow[] = []; | |
| 100 | for (let index = 0; index < rowCount; index += 1) { | |
| 101 | const environment = rng() < 0.75 ? "env-w" : "env-x"; | |
| 102 | const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; | |
| 103 | // Ids with colons: a `${environmentId}:${id}` string is not splittable | |
| 104 | // back into its parts, so batch and planner must agree even here. | |
| 105 | const id = rng() < 0.4 ? `t:${index}` : `t${index}`; | |
| 106 | rows.push(makeRow(id, environment, key, true)); | |
| 107 | } | |
| 108 | // Hidden rows (in allThreads, not in the visible ordered section) may hold | |
| 109 | // keys that collide with fast-path midpoints. | |
| 110 | const hidden: OrderRow[] = []; | |
| 111 | const hiddenCount = Math.floor(rng() * 4); | |
| 112 | for (let index = 0; index < hiddenCount; index += 1) { | |
| 113 | const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null; | |
| 114 | const id = rng() < 0.4 ? `h:${index}` : `h${index}`; | |
| 115 | hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true)); | |
| 116 | } | |
| 117 | return { ordered: rows, allThreads: [...rows, ...hidden] }; | |
| 118 | } | |
| 119 | ||
| 120 | const WRITABLE = new Set<EnvironmentId>(["env-w" as EnvironmentId]); | |
| 121 | ||
| 122 | describe("computeThreadMoveAvailability matches the reference planner", () => { | |
| 123 | it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => { | |
| 124 | for (let seed = 1; seed <= 4_000; seed += 1) { | |
| 125 | const rng = makeRng(seed); | |
| 126 | const { ordered, allThreads } = randomCase(rng); | |
| 127 | const batch = computeThreadMoveAvailability({ | |
| 128 | ordered, | |
| 129 | allThreads, | |
| 130 | section: "pinned", | |
| 131 | reorderableEnvironmentIds: WRITABLE, | |
| 132 | }); | |
| 133 | const reference = referenceAvailability(ordered, allThreads, WRITABLE); | |
| 134 | for (const [id, answer] of reference) { | |
| 135 | expect( | |
| 136 | batch.get(id) ?? { canMoveUp: false, canMoveDown: false }, | |
| 137 | `seed ${seed} row ${id} ordered=${ordered | |
| 138 | .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`) | |
| 139 | .join(",")}`, | |
| 140 | ).toEqual(answer); | |
| 141 | } | |
| 142 | } | |
| 143 | }); | |
| 144 | ||
| 145 | it("locks every row while a pending reorder is in flight", () => { | |
| 146 | const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)]; | |
| 147 | const pending = { | |
| 148 | section: "pinned" as const, | |
| 149 | orderedIds: ["env-w:t1", "env-w:t0"], | |
| 150 | before: new Map(), | |
| 151 | assignments: new Map(), | |
| 152 | confirmed: new Set<string>(), | |
| 153 | commandsComplete: false, | |
| 154 | }; | |
| 155 | const batch = computeThreadMoveAvailability({ | |
| 156 | ordered: rows, | |
| 157 | section: "pinned", | |
| 158 | reorderableEnvironmentIds: WRITABLE, | |
| 159 | pendingOrder: pending, | |
| 160 | }); | |
| 161 | expect(batch.size).toBe(0); | |
| 162 | }); | |
| 163 | ||
| 164 | it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => { | |
| 165 | // Reported parity case: keys ["f","gn",null], middle row NOT writable. | |
| 166 | // The rewrite assigns the middle row the key it already holds, so the | |
| 167 | // diff never writes it and the first row's down-move stays available. | |
| 168 | const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)]; | |
| 169 | const withThird = [...rows, makeRow("t2", "env-w", null, true)]; | |
| 170 | const batch = computeThreadMoveAvailability({ | |
| 171 | ordered: withThird, | |
| 172 | allThreads: withThird, | |
| 173 | section: "pinned", | |
| 174 | reorderableEnvironmentIds: WRITABLE, | |
| 175 | }); | |
| 176 | const reference = referenceAvailability(withThird, withThird, WRITABLE); | |
| 177 | expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); | |
| 178 | expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true }); | |
| 179 | }); | |
| 180 | ||
| 181 | it("keeps moves available for ids containing colons (composite-id parsing)", () => { | |
| 182 | // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting | |
| 183 | // the composite id at the last colon yields `env:thread` and falsely locks | |
| 184 | // both rows; writability must come from the row's own environmentId. | |
| 185 | const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)]; | |
| 186 | const batch = computeThreadMoveAvailability({ | |
| 187 | ordered: rows, | |
| 188 | section: "pinned", | |
| 189 | reorderableEnvironmentIds: new Set<EnvironmentId>(["env" as EnvironmentId]), | |
| 190 | }); | |
| 191 | const reference = referenceAvailability( | |
| 192 | rows, | |
| 193 | rows, | |
| 194 | new Set<EnvironmentId>(["env" as EnvironmentId]), | |
| 195 | ); | |
| 196 | expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); | |
| 197 | expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true }); | |
| 198 | expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false }); | |
| 199 | }); | |
| 200 | ||
| 201 | it("denies single-row sections on both directions", () => { | |
| 202 | const rows = [makeRow("t0", "env-w", "a", true)]; | |
| 203 | const batch = computeThreadMoveAvailability({ | |
| 204 | ordered: rows, | |
| 205 | section: "pinned", | |
| 206 | reorderableEnvironmentIds: WRITABLE, | |
| 207 | }); | |
| 208 | expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false }); | |
| 209 | }); | |
| 210 | ||
| 211 | it("denies rows whose section has non-writable neighbours when the fast path fails", () => { | |
| 212 | // Keyless neighbors force the section-rewrite fallback; a non-writable | |
| 213 | // neighbor makes the rewrite illegal for every row in the section. | |
| 214 | const rows = [ | |
| 215 | makeRow("t0", "env-w", null, true), | |
| 216 | makeRow("t1", "env-x", "c", true), | |
| 217 | makeRow("t2", "env-w", "d", true), | |
| 218 | ]; | |
| 219 | const batch = computeThreadMoveAvailability({ | |
| 220 | ordered: rows, | |
| 221 | allThreads: rows, | |
| 222 | section: "pinned", | |
| 223 | reorderableEnvironmentIds: WRITABLE, | |
| 224 | }); | |
| 225 | const reference = referenceAvailability(rows, rows, WRITABLE); | |
| 226 | expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); | |
| 227 | // Sanity: the middle (non-writable) row is denied on both sides. | |
| 228 | expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false }); | |
| 229 | }); | |
| 230 | ||
| 231 | it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => { | |
| 232 | // The worst case for reserved-key collisions: one hidden row holds the | |
| 233 | // exact midpoint key of every adjacent visible pair, so every probe walks. | |
| 234 | const visibleKeys = generateSpreadPinOrderKeys(24); | |
| 235 | const hiddenKeys = visibleKeys | |
| 236 | .slice(0, -1) | |
| 237 | .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!)) | |
| 238 | .filter((key): key is string => key != null); | |
| 239 | const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true)); | |
| 240 | const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true)); | |
| 241 | const allThreads = [...ordered, ...hidden]; | |
| 242 | const batch = computeThreadMoveAvailability({ | |
| 243 | ordered, | |
| 244 | allThreads, | |
| 245 | section: "pinned", | |
| 246 | reorderableEnvironmentIds: WRITABLE, | |
| 247 | }); | |
| 248 | const reference = referenceAvailability(ordered, allThreads, WRITABLE); | |
| 249 | expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); | |
| 250 | }); | |
| 251 | ||
| 252 | it("agrees on an adversarial section of consecutive single-char keys", () => { | |
| 253 | // Every midpoint between consecutive one-char keys is unrepresentable, so | |
| 254 | // no row may claim a fast-path plan. | |
| 255 | const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true)); | |
| 256 | const batch = computeThreadMoveAvailability({ | |
| 257 | ordered: rows, | |
| 258 | allThreads: rows, | |
| 259 | section: "pinned", | |
| 260 | reorderableEnvironmentIds: WRITABLE, | |
| 261 | }); | |
| 262 | const reference = referenceAvailability(rows, rows, WRITABLE); | |
| 263 | expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference)); | |
| 264 | }); | |
| 265 | }); |