authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 20:40:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 20:40:32-07:00
log151324b2c0bf0ace789820b8ea4ec8ceb55753d9
treeaba27e126283e9111980d76145b7e6dd3ed9a7df
parent219c1d265d917f0e7083b9033f4014c0c2940463
signature Signed by PGP key B5690EEEBB952194

perf(mobile): recycle the default v2 home list and scope the snooze minute tick (#13149)


9 files changed, 1378 insertions(+), 121 deletions(-)

apps/mobile/src/features/home/HomeScreen.tsx+70-37
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { ThreadMoveDestination } from "../threads/threadOrder";1import type { ThreadMoveDestination } from "../threads/threadOrder";
2import { createThreadMovePlanner } from "../threads/threadOrder";2import { computeThreadMoveAvailability } from "../threads/threadOrder";
3import {3import {
4 LegendList,4 LegendList,
5 type LegendListRef,5 type LegendListRef,
...@@ -23,7 +23,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";...@@ -23,7 +23,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
23import { AsyncResult } from "effect/unstable/reactivity";23import { AsyncResult } from "effect/unstable/reactivity";
24import { useFocusEffect } from "@react-navigation/native";24import { useFocusEffect } from "@react-navigation/native";
25import { useCallback, useEffect, useMemo, useRef, useState } from "react";25import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { ActivityIndicator, FlatList, Platform, View } from "react-native";26import { ActivityIndicator, Platform, View } from "react-native";
27import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";27import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
28import { useSafeAreaInsets } from "react-native-safe-area-context";28import { useSafeAreaInsets } from "react-native-safe-area-context";
2929
...@@ -55,11 +55,12 @@ import {...@@ -55,11 +55,12 @@ import {
55 ThreadListV2ShowMoreRow,55 ThreadListV2ShowMoreRow,
56 ThreadListV2SnoozedShelfHeader,56 ThreadListV2SnoozedShelfHeader,
57} from "../threads/thread-list-v2-items";57} from "../threads/thread-list-v2-items";
58import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";58import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
59import {59import {
60 buildThreadListV2Items,60 buildThreadListV2Items,
61 getThreadListV2OrderedSection,61 getThreadListV2OrderedSection,
62 buildThreadListV2ListItems,62 buildThreadListV2ListItems,
63 threadListV2ListItemsAreEqual,
63 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,64 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
64 THREAD_LIST_V2_SETTLED_PAGE_COUNT,65 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
65 type ThreadListV2ListItem,66 type ThreadListV2ListItem,
...@@ -138,6 +139,21 @@ interface HomeScreenProps {...@@ -138,6 +139,21 @@ interface HomeScreenProps {
138/* ─── Layout constants ───────────────────────────────────────────────── */139/* ─── Layout constants ───────────────────────────────────────────────── */
139140
140const ESTIMATED_THREAD_ROW_HEIGHT = 72;141const 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.
156const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72;
141const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;157const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
142/**158/**
143 * Top spacing between the list and the Android custom header. The Android159 * Top spacing between the list and the Android custom header. The Android
...@@ -655,12 +671,19 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -655,12 +671,19 @@ export function HomeScreen(props: HomeScreenProps) {
655 ),671 ),
656 [serverConfigs],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 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);677 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
659 const threadMovePlanners = useMemo(() => {678 // Up/down menu availability for every card, computed once per section per
660 const sectionPlanner = (section: "pinned" | "active") =>679 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
661 createThreadMovePlanner({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 allThreads: props.threads,684 allThreads: props.threads,
663 section,685 section,
686 pendingOrder,
664 reorderableEnvironmentIds: new Set(687 reorderableEnvironmentIds: new Set(
665 [...serverConfigs].flatMap(([id, config]) =>688 [...serverConfigs].flatMap(([id, config]) =>
666 (section === "pinned"689 (section === "pinned"
...@@ -680,7 +703,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -680,7 +703,7 @@ export function HomeScreen(props: HomeScreenProps) {
680 queuedThreadKeys,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 serverConfigs,708 serverConfigs,
686 props.threads,709 props.threads,
...@@ -783,8 +806,22 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -783,8 +806,22 @@ export function HomeScreen(props: HomeScreenProps) {
783 settledShelfExpanded,806 settledShelfExpanded,
784 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,807 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
785 snoozeLabelNow: `${nowMinute}:00.000Z`,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 );
789826
790 useThreadJumpShortcuts(827 useThreadJumpShortcuts(
...@@ -793,11 +830,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -793,11 +830,7 @@ export function HomeScreen(props: HomeScreenProps) {
793 );830 );
794831
795 const renderV2Item = useCallback(832 const renderV2Item = useCallback(
796 ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {833 ({ item }: { readonly item: ThreadListV2ListItem }) => {
797 const nextItem = threadListV2Items[index + 1];
798 const showTrailingDivider =
799 nextItem?.type === "v2-thread" ||
800 (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
801 if (item.type === "v2-pending") {834 if (item.type === "v2-pending") {
802 const pendingScopeKey = scopedProjectKey(835 const pendingScopeKey = scopedProjectKey(
803 item.pendingTask.environmentId,836 item.pendingTask.environmentId,
...@@ -816,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -816,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) {
816 }849 }
817 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}850 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
818 showPendingDivider={item.showPendingDivider}851 showPendingDivider={item.showPendingDivider}
819 showTrailingDivider={showTrailingDivider}852 showTrailingDivider={item.showTrailingDivider}
820 onSelectPendingTask={props.onSelectPendingTask}853 onSelectPendingTask={props.onSelectPendingTask}
821 onDeletePendingTask={props.onDeletePendingTask}854 onDeletePendingTask={props.onDeletePendingTask}
822 />855 />
...@@ -826,7 +859,7 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -826,7 +859,7 @@ export function HomeScreen(props: HomeScreenProps) {
826 return (859 return (
827 <ThreadListV2SnoozedShelfHeader860 <ThreadListV2SnoozedShelfHeader
828 count={item.count}861 count={item.count}
829 disabled={!shelfPreferencesLoaded}862 disabled={item.disabled}
830 expanded={item.expanded}863 expanded={item.expanded}
831 onToggle={toggleSnoozedShelf}864 onToggle={toggleSnoozedShelf}
832 />865 />
...@@ -836,33 +869,32 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -836,33 +869,32 @@ export function HomeScreen(props: HomeScreenProps) {
836 return (869 return (
837 <ThreadListV2SettledShelfHeader870 <ThreadListV2SettledShelfHeader
838 count={item.count}871 count={item.count}
839 disabled={!shelfPreferencesLoaded}872 disabled={item.disabled}
840 expanded={item.expanded}873 expanded={item.expanded}
841 onToggle={toggleSettledShelf}874 onToggle={toggleSettledShelf}
842 />875 />
843 );876 );
844 }877 }
845 const thread = item.item.thread;878 const thread = item.item.thread;
846 const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
847 const movedId = `${thread.environmentId}:${thread.id}`;
848 return (879 return (
849 <ThreadListV2Row880 <ThreadListV2Row
850 onNewThreadOnBranch={props.onNewThreadOnBranch}881 onNewThreadOnBranch={props.onNewThreadOnBranch}
851 thread={thread}882 thread={thread}
852 variant={item.item.variant}883 variant={item.item.variant}
853 hasQueuedMessages={queuedThreadKeys.has(movedId)}884 hasQueuedMessages={item.hasQueuedMessages}
854 snoozed={item.item.snoozed}885 snoozed={item.item.snoozed}
855 pinned={item.item.pinned}886 pinned={item.item.pinned}
856 snoozePresetMinute={nowMinute}887 snoozePresetMinute={item.snoozePresetMinute ?? ""}
857 snoozeWakeLabelText={item.snoozeWakeLabelText}888 snoozeWakeLabelText={item.snoozeWakeLabelText}
858 showTrailingDivider={showTrailingDivider}889 timeLabel={item.timeLabel}
890 showTrailingDivider={item.showTrailingDivider}
859 project={891 project={
860 projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null892 projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
861 }893 }
862 projectTitle={v2ProjectTitleByProjectKey.get(894 projectTitle={v2ProjectTitleByProjectKey.get(
863 scopedProjectKey(thread.environmentId, thread.projectId),895 scopedProjectKey(thread.environmentId, thread.projectId),
864 )}896 )}
865 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}897 providerInstance={resolveProviderInstance(thread)}
866 environmentLabel={898 environmentLabel={
867 Object.keys(props.savedConnectionsById).length > 1899 Object.keys(props.savedConnectionsById).length > 1
868 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)900 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
...@@ -891,8 +923,8 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -891,8 +923,8 @@ export function HomeScreen(props: HomeScreenProps) {
891 ? pinReorderEnvironmentIds.has(thread.environmentId)923 ? pinReorderEnvironmentIds.has(thread.environmentId)
892 : activeReorderEnvironmentIds.has(thread.environmentId)924 : activeReorderEnvironmentIds.has(thread.environmentId)
893 }925 }
894 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}926 canMoveUp={item.canMoveUp}
895 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}927 canMoveDown={item.canMoveDown}
896 onSnoozeThread={handleSnoozeThread}928 onSnoozeThread={handleSnoozeThread}
897 onUnsnoozeThread={handleUnsnoozeThread}929 onUnsnoozeThread={handleUnsnoozeThread}
898 onUnsettleThread={handleUnsettleThread}930 onUnsettleThread={handleUnsettleThread}
...@@ -907,9 +939,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -907,9 +939,6 @@ export function HomeScreen(props: HomeScreenProps) {
907 [939 [
908 handleDeleteThread,940 handleDeleteThread,
909 activeReorderEnvironmentIds,941 activeReorderEnvironmentIds,
910 threadMovePlanners,
911 pendingOrder,
912 queuedThreadKeys,
913 handleMoveThread,942 handleMoveThread,
914 handlePinThread,943 handlePinThread,
915 handleRegenerateThreadTitle,944 handleRegenerateThreadTitle,
...@@ -931,25 +960,23 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -931,25 +960,23 @@ export function HomeScreen(props: HomeScreenProps) {
931 props.onSelectThread,960 props.onSelectThread,
932 props.onNewThreadOnBranch,961 props.onNewThreadOnBranch,
933 props.savedConnectionsById,962 props.savedConnectionsById,
934 serverConfigs,963 resolveProviderInstance,
935 shelfPreferencesLoaded,
936 settlementEnvironmentIds,964 settlementEnvironmentIds,
937 snoozeEnvironmentIds,965 snoozeEnvironmentIds,
938 threadListV2Items,
939 threadSearchMatchByKey,966 threadSearchMatchByKey,
940 titleRegenerationEnvironmentIds,967 titleRegenerationEnvironmentIds,
941 toggleSettledShelf,968 toggleSettledShelf,
942 toggleSnoozedShelf,969 toggleSnoozedShelf,
943 v2ProjectTitleByProjectKey,970 v2ProjectTitleByProjectKey,
944 props.searchQuery,971 props.searchQuery,
945 nowMinute,
946 ],972 ],
947 );973 );
948 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);974 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
949975
950 // FlatList treats a changed extraData identity as "re-render every visible976 // FlatList/LegendList treat a changed extraData identity as "re-render every
951 // row", so an inline object literal would invalidate all rows on every977 // visible row", so an inline object literal would invalidate all rows on
952 // HomeScreen render.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 const v2ExtraData = useMemo(980 const v2ExtraData = useMemo(
954 () => ({981 () => ({
955 projectByKey,982 projectByKey,
...@@ -957,7 +984,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -957,7 +984,6 @@ export function HomeScreen(props: HomeScreenProps) {
957 serverConfigs,984 serverConfigs,
958 savedConnectionsById: props.savedConnectionsById,985 savedConnectionsById: props.savedConnectionsById,
959 searchQuery: props.searchQuery,986 searchQuery: props.searchQuery,
960 snoozePresetMinute: nowMinute,
961 threadSearchMatchByKey,987 threadSearchMatchByKey,
962 }),988 }),
963 [989 [
...@@ -965,7 +991,6 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -965,7 +991,6 @@ export function HomeScreen(props: HomeScreenProps) {
965 props.searchQuery,991 props.searchQuery,
966 props.savedConnectionsById,992 props.savedConnectionsById,
967 serverConfigs,993 serverConfigs,
968 nowMinute,
969 threadSearchMatchByKey,994 threadSearchMatchByKey,
970 v2ProjectTitleByProjectKey,995 v2ProjectTitleByProjectKey,
971 ],996 ],
...@@ -1225,11 +1250,19 @@ export function HomeScreen(props: HomeScreenProps) {...@@ -1225,11 +1250,19 @@ export function HomeScreen(props: HomeScreenProps) {
1225 : "flex-1 bg-screen"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 <SwipeableScrollGateProvider enabled={swipeEnabled}>1256 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1229 <FlatList1257 <LegendList
1230 data={threadListV2Items}1258 data={threadListV2Items}
1231 renderItem={renderV2Item}1259 renderItem={renderV2Item}
1232 keyExtractor={v2KeyExtractor}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 extraData={v2ExtraData}1266 extraData={v2ExtraData}
1234 ListHeaderComponent={v2ListHeader}1267 ListHeaderComponent={v2ListHeader}
1235 ListFooterComponent={1268 ListFooterComponent={
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-57
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";1import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { createThreadMovePlanner } from "./threadOrder";2import { computeThreadMoveAvailability } from "./threadOrder";
3import type {3import type {
4 EnvironmentProject,4 EnvironmentProject,
5 EnvironmentThreadShell,5 EnvironmentThreadShell,
...@@ -84,11 +84,13 @@ import {...@@ -84,11 +84,13 @@ import {
84 ThreadListV2ShowMoreRow,84 ThreadListV2ShowMoreRow,
85 ThreadListV2SnoozedShelfHeader,85 ThreadListV2SnoozedShelfHeader,
86} from "./thread-list-v2-items";86} from "./thread-list-v2-items";
87import { resolveThreadProviderInstance } from "./thread-provider-instance";87import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
88import {88import {
89 buildThreadListV2Items,89 buildThreadListV2Items,
90 getThreadListV2OrderedSection,90 getThreadListV2OrderedSection,
91 buildThreadListV2ListItems,91 buildThreadListV2ListItems,
92 isThreadListV2ListItem,
93 threadListV2ListItemsAreEqual,
92 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,94 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
93 THREAD_LIST_V2_SETTLED_PAGE_COUNT,95 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
94 type ThreadListV2ListItem,96 type ThreadListV2ListItem,
...@@ -483,12 +485,19 @@ function ThreadNavigationSidebarPane(...@@ -483,12 +485,19 @@ function ThreadNavigationSidebarPane(
483 ),485 ),
484 [serverConfigs],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 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);491 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
487 const threadMovePlanners = useMemo(() => {492 // Up/down menu availability for every card, computed once per section per
488 const sectionPlanner = (section: "pinned" | "active") =>493 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
489 createThreadMovePlanner({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 allThreads: threads,498 allThreads: threads,
491 section,499 section,
500 pendingOrder,
492 reorderableEnvironmentIds: new Set(501 reorderableEnvironmentIds: new Set(
493 [...serverConfigs].flatMap(([id, config]) =>502 [...serverConfigs].flatMap(([id, config]) =>
494 (section === "pinned"503 (section === "pinned"
...@@ -508,7 +517,7 @@ function ThreadNavigationSidebarPane(...@@ -508,7 +517,7 @@ function ThreadNavigationSidebarPane(
508 queuedThreadKeys,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 serverConfigs,522 serverConfigs,
514 threads,523 threads,
...@@ -607,6 +616,10 @@ function ThreadNavigationSidebarPane(...@@ -607,6 +616,10 @@ function ThreadNavigationSidebarPane(
607 settledShelfExpanded,616 settledShelfExpanded,
608 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,617 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
609 snoozeLabelNow: `${nowMinute}:00.000Z`,618 snoozeLabelNow: `${nowMinute}:00.000Z`,
619 snoozeEnvironmentIds,
620 queuedThreadKeys,
621 moveAvailability: threadMoveAvailability,
622 shelfPreferencesLoading: !shelfPreferencesLoaded,
610 });623 });
611 if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) {624 if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) {
612 items.push({625 items.push({
...@@ -622,9 +635,13 @@ function ThreadNavigationSidebarPane(...@@ -622,9 +635,13 @@ function ThreadNavigationSidebarPane(
622 options.selectedEnvironmentId,635 options.selectedEnvironmentId,
623 pendingTasks,636 pendingTasks,
624 props.searchQuery,637 props.searchQuery,
638 queuedThreadKeys,
639 threadMoveAvailability,
625 selectedProjectRefs,640 selectedProjectRefs,
626 settledShelfExpanded,641 settledShelfExpanded,
642 shelfPreferencesLoaded,
627 snoozedShelfExpanded,643 snoozedShelfExpanded,
644 snoozeEnvironmentIds,
628 threadListV2Enabled,645 threadListV2Enabled,
629 threadListV2Layout,646 threadListV2Layout,
630 ]);647 ]);
...@@ -789,9 +806,11 @@ function ThreadNavigationSidebarPane(...@@ -789,9 +806,11 @@ function ThreadNavigationSidebarPane(
789 onScroll: onMaterialFabScroll,806 onScroll: onMaterialFabScroll,
790 onScrollBeginDrag: handleScrollBeginDrag,807 onScrollBeginDrag: handleScrollBeginDrag,
791 });808 });
792 // Project shells load after the first rows draw, so the maps they feed have809 // The sticky header's project shells and search maps feed row props, so
793 // to bust the recycler's memoization — otherwise a row keeps the blank810 // they have to bust the recycler's memoization — otherwise a row keeps the
794 // favicon and fallback title it was first rendered with.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 const listExtraData = useMemo(814 const listExtraData = useMemo(
796 () => ({815 () => ({
797 selectedThreadKey: props.selectedThreadKey ?? "",816 selectedThreadKey: props.selectedThreadKey ?? "",
...@@ -799,7 +818,6 @@ function ThreadNavigationSidebarPane(...@@ -799,7 +818,6 @@ function ThreadNavigationSidebarPane(
799 projectTitleByProjectKey,818 projectTitleByProjectKey,
800 savedConnectionsById,819 savedConnectionsById,
801 serverConfigs,820 serverConfigs,
802 snoozePresetMinute: nowMinute,
803 threadSearchMatchByKey,821 threadSearchMatchByKey,
804 }),822 }),
805 [823 [
...@@ -808,49 +826,23 @@ function ThreadNavigationSidebarPane(...@@ -808,49 +826,23 @@ function ThreadNavigationSidebarPane(
808 projectTitleByProjectKey,826 projectTitleByProjectKey,
809 savedConnectionsById,827 savedConnectionsById,
810 serverConfigs,828 serverConfigs,
811 nowMinute,
812 threadSearchMatchByKey,829 threadSearchMatchByKey,
813 ],830 ],
814 );831 );
815 useThreadJumpShortcuts(listItems, handleSelectThread);832 useThreadJumpShortcuts(listItems, handleSelectThread);
816 const sidebarItemsAreEqual = useCallback(833 const sidebarItemsAreEqual = useCallback(
817 (previous: SidebarListItem, item: SidebarListItem): boolean => {834 (previous: SidebarListItem, item: SidebarListItem): boolean => {
818 if (previous.type === "v2-thread" && item.type === "v2-thread") {835 if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) {
819 return (836 return threadListV2ListItemsAreEqual(previous, item);
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 );
827 }837 }
828 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {838 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
829 return previous.hiddenCount === item.hiddenCount;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 if (841 if (
844 previous.type === "v2-thread" ||842 isThreadListV2ListItem(previous) ||
845 previous.type === "v2-show-more" ||843 previous.type === "v2-show-more" ||
846 previous.type === "v2-pending" ||844 isThreadListV2ListItem(item) ||
847 previous.type === "v2-snoozed-shelf" ||845 item.type === "v2-show-more"
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"
854 ) {846 ) {
855 return false;847 return false;
856 }848 }
...@@ -904,24 +896,29 @@ function ThreadNavigationSidebarPane(...@@ -904,24 +896,29 @@ function ThreadNavigationSidebarPane(
904 }896 }
905 case "v2-thread": {897 case "v2-thread": {
906 const thread = item.item.thread;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 const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);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 return (908 return (
913 <ThreadListV2Row909 <ThreadListV2Row
914 onNewThreadOnBranch={props.onNewThreadOnBranch}910 onNewThreadOnBranch={props.onNewThreadOnBranch}
915 thread={thread}911 thread={thread}
916 variant={item.item.variant}912 variant={item.item.variant}
917 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}913 hasQueuedMessages={item.hasQueuedMessages}
918 snoozed={item.item.snoozed}914 snoozed={item.item.snoozed}
919 pinned={item.item.pinned}915 pinned={item.item.pinned}
920 snoozePresetMinute={nowMinute}916 snoozePresetMinute={item.snoozePresetMinute ?? ""}
921 snoozeWakeLabelText={item.snoozeWakeLabelText}917 snoozeWakeLabelText={item.snoozeWakeLabelText}
918 timeLabel={item.timeLabel}
922 project={projectByKey.get(scopeKey) ?? null}919 project={projectByKey.get(scopeKey) ?? null}
923 projectTitle={projectTitleByProjectKey.get(scopeKey)}920 projectTitle={projectTitleByProjectKey.get(scopeKey)}
924 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}921 providerInstance={resolveProviderInstance(thread)}
925 environmentLabel={922 environmentLabel={
926 Object.keys(savedConnectionsById).length > 1923 Object.keys(savedConnectionsById).length > 1
927 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)924 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
...@@ -955,8 +952,8 @@ function ThreadNavigationSidebarPane(...@@ -955,8 +952,8 @@ function ThreadNavigationSidebarPane(
955 ? pinReorderEnvironmentIds.has(thread.environmentId)952 ? pinReorderEnvironmentIds.has(thread.environmentId)
956 : activeReorderEnvironmentIds.has(thread.environmentId)953 : activeReorderEnvironmentIds.has(thread.environmentId)
957 }954 }
958 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}955 canMoveUp={item.canMoveUp}
959 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}956 canMoveDown={item.canMoveDown}
960 onSnoozeThread={snoozeThread}957 onSnoozeThread={snoozeThread}
961 onUnsnoozeThread={unsnoozeThread}958 onUnsnoozeThread={unsnoozeThread}
962 onUnsettleThread={unsettleThread}959 onUnsettleThread={unsettleThread}
...@@ -973,7 +970,7 @@ function ThreadNavigationSidebarPane(...@@ -973,7 +970,7 @@ function ThreadNavigationSidebarPane(
973 return (970 return (
974 <ThreadListV2SnoozedShelfHeader971 <ThreadListV2SnoozedShelfHeader
975 count={item.count}972 count={item.count}
976 disabled={!shelfPreferencesLoaded}973 disabled={item.disabled}
977 expanded={item.expanded}974 expanded={item.expanded}
978 onToggle={toggleSnoozedShelf}975 onToggle={toggleSnoozedShelf}
979 pane="sidebar"976 pane="sidebar"
...@@ -983,7 +980,7 @@ function ThreadNavigationSidebarPane(...@@ -983,7 +980,7 @@ function ThreadNavigationSidebarPane(
983 return (980 return (
984 <ThreadListV2SettledShelfHeader981 <ThreadListV2SettledShelfHeader
985 count={item.count}982 count={item.count}
986 disabled={!shelfPreferencesLoaded}983 disabled={item.disabled}
987 expanded={item.expanded}984 expanded={item.expanded}
988 onToggle={toggleSettledShelf}985 onToggle={toggleSettledShelf}
989 pane="sidebar"986 pane="sidebar"
...@@ -1080,8 +1077,6 @@ function ThreadNavigationSidebarPane(...@@ -1080,8 +1077,6 @@ function ThreadNavigationSidebarPane(
1080 [1077 [
1081 archiveThread,1078 archiveThread,
1082 activeReorderEnvironmentIds,1079 activeReorderEnvironmentIds,
1083 threadMovePlanners,
1084 pendingOrder,
1085 queuedThreadKeys,1080 queuedThreadKeys,
1086 confirmDeletePendingTask,1081 confirmDeletePendingTask,
1087 confirmDeleteThread,1082 confirmDeleteThread,
...@@ -1104,8 +1099,6 @@ function ThreadNavigationSidebarPane(...@@ -1104,8 +1099,6 @@ function ThreadNavigationSidebarPane(
1104 props.selectedThreadKey,1099 props.selectedThreadKey,
1105 props.width,1100 props.width,
1106 savedConnectionsById,1101 savedConnectionsById,
1107 serverConfigs,
1108 shelfPreferencesLoaded,
1109 threadSearchMatchByKey,1102 threadSearchMatchByKey,
1110 titleRegenerationEnvironmentIds,1103 titleRegenerationEnvironmentIds,
1111 settleThread,1104 settleThread,
...@@ -1114,7 +1107,7 @@ function ThreadNavigationSidebarPane(...@@ -1114,7 +1107,7 @@ function ThreadNavigationSidebarPane(
1114 sidebarScrollGesture,1107 sidebarScrollGesture,
1115 snoozeEnvironmentIds,1108 snoozeEnvironmentIds,
1116 snoozeThread,1109 snoozeThread,
1117 nowMinute,1110 resolveProviderInstance,
1118 toggleSettledShelf,1111 toggleSettledShelf,
1119 toggleSnoozedShelf,1112 toggleSnoozedShelf,
1120 unpinThread,1113 unpinThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+26-15
...@@ -18,7 +18,6 @@ import type {...@@ -18,7 +18,6 @@ import type {
18import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";18import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
19import type { EnvironmentMachineKind } from "@t3tools/contracts";19import type { EnvironmentMachineKind } from "@t3tools/contracts";
20import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";20import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
21import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
22import type { MenuAction } from "@react-native-menu/menu";21import type { MenuAction } from "@react-native-menu/menu";
23import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";22import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
24import { Alert, Pressable, useWindowDimensions, View } from "react-native";23import { Alert, Pressable, useWindowDimensions, View } from "react-native";
...@@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon";...@@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon";
33import type { ThreadRowProviderInstance } from "./thread-provider-instance";32import type { ThreadRowProviderInstance } from "./thread-provider-instance";
34import { cn } from "../../lib/cn";33import { cn } from "../../lib/cn";
35import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";34import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
36import { relativeTime } from "../../lib/time";
37import { useUniwindTheme } from "../../lib/useUniwindTheme";35import { useUniwindTheme } from "../../lib/useUniwindTheme";
38import type { PendingNewTask } from "../../state/use-pending-new-tasks";36import type { PendingNewTask } from "../../state/use-pending-new-tasks";
39import { useThreadPr } from "../../state/use-thread-pr";37import { useThreadPr } from "../../state/use-thread-pr";
...@@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial<...@@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial<
69 failed: { label: "Failed", className: "text-danger-foreground" },67 failed: { label: "Failed", className: "text-danger-foreground" },
70};68};
7169
72function threadTimeLabel(thread: EnvironmentThreadShell): string {
73 return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
74}
75
76// Menus keep lifecycle and title regeneration together. Archive keeps its70// Menus keep lifecycle and title regeneration together. Archive keeps its
77// own surface (thread screen / settings) rather than crowding v2 rows.71// own surface (thread screen / settings) rather than crowding v2 rows.
78const CARD_MENU_ACTIONS: MenuAction[] = [72const CARD_MENU_ACTIONS: MenuAction[] = [
...@@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
453 /** Preformatted against the parent minute tick so this memoized row's447 /** Preformatted against the parent minute tick so this memoized row's
454 countdown keeps moving. */448 countdown keeps moving. */
455 readonly snoozeWakeLabelText?: string;449 readonly snoozeWakeLabelText?: string;
456 /** Parent minute tick passed as a prop so this memoized row refreshes its450 /** Preformatted against the parent clock (row order timestamp: settle stamp
457 native snooze menu while mounted. */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 readonly snoozePresetMinute: string;459 readonly snoozePresetMinute: string;
459 readonly project: EnvironmentProject | null;460 readonly project: EnvironmentProject | null;
460 readonly projectTitle?: string;461 readonly projectTitle?: string;
...@@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
549550
550 const status = resolveThreadListV2Status(thread);551 const status = resolveThreadListV2Status(thread);
551 const statusLabel = STATUS_LABEL_BY_STATUS[status];552 const statusLabel = STATUS_LABEL_BY_STATUS[status];
552 // Settled rows label by the same stamp they sort by, so order and label553 // The timestamp is precomputed on the list item (same stamps the settled
553 // can't disagree. updatedAt is always present, so the resolver never554 // tail sorts by) so a minute tick only re-renders rows that draw it.
554 // returns null here.555 const timeLabel = props.timeLabel;
555 const settledTimestamp =
556 variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null;
557 const timeLabel =
558 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);
559556
560 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);557 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
561 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);558 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
...@@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
565 );562 );
566 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);563 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
567 const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);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 const handleSnooze = useCallback(578 const handleSnooze = useCallback(
569 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),579 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
570 [onSnoozeThread, thread],580 [onSnoozeThread, thread],
...@@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
758 handleUnsettle,768 handleUnsettle,
759 handleUnsnooze,769 handleUnsnooze,
760 snoozePresets,770 snoozePresets,
771 setCustomSnoozeOpen,
761 ],772 ],
762 );773 );
763 const primaryAction = useMemo(() => {774 const primaryAction = useMemo(() => {
apps/mobile/src/features/threads/thread-provider-instance.test.ts+54-1
...@@ -8,7 +8,10 @@ import {...@@ -8,7 +8,10 @@ import {
8} from "@t3tools/contracts";8} from "@t3tools/contracts";
9import { describe, expect, it } from "vite-plus/test";9import { describe, expect, it } from "vite-plus/test";
1010
11import { resolveThreadProviderInstance } from "./thread-provider-instance";11import {
12 createThreadRowProviderInstanceResolver,
13 resolveThreadProviderInstance,
14} from "./thread-provider-instance";
1215
13function makeConfig(16function makeConfig(
14 providers: ReadonlyArray<{17 providers: ReadonlyArray<{
...@@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => {...@@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => {
96 expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);99 expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
97 });100 });
98});101});
102
103describe("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,3 +1,5 @@
1import { useMemo } from "react";
2
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";3import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {4import {
3 normalizeProviderAccentColor,5 normalizeProviderAccentColor,
...@@ -40,3 +42,33 @@ export function resolveThreadProviderInstance(...@@ -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 */
54export 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. */
70export 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,7 +5,9 @@ import {
5 threadOrderAfterMove,5 threadOrderAfterMove,
6 threadDropLifecycle,6 threadDropLifecycle,
7 reconcilePendingThreadOrder,7 reconcilePendingThreadOrder,
8 computeThreadMoveAvailability,
8 type PendingThreadOrder,9 type PendingThreadOrder,
10 type ThreadMoveAvailability,
9} from "./threadOrder";11} from "./threadOrder";
10import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";12import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
11import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";13import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
...@@ -19,7 +21,7 @@ import {...@@ -19,7 +21,7 @@ import {
19 ThreadId,21 ThreadId,
20 TurnId,22 TurnId,
21} from "@t3tools/contracts";23} from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";24import { describe, expect, it, vi } from "vite-plus/test";
2325
24import type { PendingNewTask } from "../../state/use-pending-new-tasks";26import type { PendingNewTask } from "../../state/use-pending-new-tasks";
25import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";27import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
...@@ -27,12 +29,15 @@ import {...@@ -27,12 +29,15 @@ import {
27 buildThreadListV2Items,29 buildThreadListV2Items,
28 buildThreadListV2ListItems,30 buildThreadListV2ListItems,
29 getThreadListV2OrderedSection,31 getThreadListV2OrderedSection,
32 isThreadListV2ListItem,
30 resolveThreadListV2Enabled,33 resolveThreadListV2Enabled,
31 resolveThreadListV2SnoozeMenuSelection,34 resolveThreadListV2SnoozeMenuSelection,
32 resolveThreadListV2SnoozeGateExpiryMs,35 resolveThreadListV2SnoozeGateExpiryMs,
33 resolveThreadListV2Status,36 resolveThreadListV2Status,
34 resolveThreadListV2SwipeActions,37 resolveThreadListV2SwipeActions,
35 sortThreadsForListV2,38 sortThreadsForListV2,
39 threadListV2ListItemsAreEqual,
40 type ThreadListV2ListItem,
36} from "./threadListV2";41} from "./threadListV2";
3742
38const environmentId = EnvironmentId.make("environment-1");43const environmentId = EnvironmentId.make("environment-1");
...@@ -1482,3 +1487,551 @@ describe("cross-section thread drops", () => {...@@ -1482,3 +1487,551 @@ describe("cross-section thread drops", () => {
1482 ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });1487 ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
1483 });1488 });
1484});1489});
1490
1491/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */
1492
1493const BASE_MS = Date.parse(NOW);
1494const isoAt = (ms: number) => new Date(ms).toISOString();
1495const MINUTE_MS = 60_000;
1496
1497function 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
1510function 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
1538function 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
1577function 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
1583describe("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
1736describe("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
1747describe("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
1815describe("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
1905describe("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
1930describe("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,5 +1,6 @@
1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";1import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
2import {2import {
3 canSnooze,
3 effectiveSnoozed,4 effectiveSnoozed,
4 hasQueuedTurnStart,5 hasQueuedTurnStart,
5 QUEUED_TURN_START_GRACE_MS,6 QUEUED_TURN_START_GRACE_MS,
...@@ -16,6 +17,9 @@ import {...@@ -16,6 +17,9 @@ import {
16} from "@t3tools/client-runtime/state/thread-sort";17} from "@t3tools/client-runtime/state/thread-sort";
17import type { EnvironmentId, ProjectId } from "@t3tools/contracts";18import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1819
20import type { ThreadMoveAvailability } from "./threadOrder";
21
22import { relativeTime } from "../../lib/time";
19import type { PendingNewTask } from "../../state/use-pending-new-tasks";23import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2024
21import {25import {
...@@ -244,6 +248,28 @@ export interface ThreadListV2ThreadListItem {...@@ -244,6 +248,28 @@ export interface ThreadListV2ThreadListItem {
244 readonly item: ThreadListV2Item;248 readonly item: ThreadListV2Item;
245 /** Precomputed so recycled-list equality can see a minute-tick change. */249 /** Precomputed so recycled-list equality can see a minute-tick change. */
246 readonly snoozeWakeLabelText: string | undefined;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}
248274
249export interface ThreadListV2PendingListItem {275export interface ThreadListV2PendingListItem {
...@@ -252,6 +278,9 @@ export interface ThreadListV2PendingListItem {...@@ -252,6 +278,9 @@ export interface ThreadListV2PendingListItem {
252 readonly pendingTask: PendingNewTask;278 readonly pendingTask: PendingNewTask;
253 /** First queued row after the active block draws the PENDING divider. */279 /** First queued row after the active block draws the PENDING divider. */
254 readonly showPendingDivider: boolean;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}
256285
257export interface ThreadListV2SnoozedShelfListItem {286export interface ThreadListV2SnoozedShelfListItem {
...@@ -259,6 +288,11 @@ export interface ThreadListV2SnoozedShelfListItem {...@@ -259,6 +288,11 @@ export interface ThreadListV2SnoozedShelfListItem {
259 readonly key: "v2-snoozed-shelf";288 readonly key: "v2-snoozed-shelf";
260 readonly count: number;289 readonly count: number;
261 readonly expanded: boolean;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}
263297
264export interface ThreadListV2SettledShelfListItem {298export interface ThreadListV2SettledShelfListItem {
...@@ -266,6 +300,8 @@ export interface ThreadListV2SettledShelfListItem {...@@ -266,6 +300,8 @@ export interface ThreadListV2SettledShelfListItem {
266 readonly key: "v2-settled-shelf";300 readonly key: "v2-settled-shelf";
267 readonly count: number;301 readonly count: number;
268 readonly expanded: boolean;302 readonly expanded: boolean;
303 /** See the snoozed shelf header's field. */
304 readonly disabled: boolean;
269}305}
270306
271export type ThreadListV2ListItem =307export type ThreadListV2ListItem =
...@@ -274,6 +310,90 @@ export type ThreadListV2ListItem =...@@ -274,6 +310,90 @@ export type ThreadListV2ListItem =
274 | ThreadListV2SnoozedShelfListItem310 | ThreadListV2SnoozedShelfListItem
275 | ThreadListV2SettledShelfListItem;311 | ThreadListV2SettledShelfListItem;
276312
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. */
315export 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. */
333export 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. */
383function 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 * Builds the shared mobile order: active → pending → snoozed shelf → settled.398 * Builds the shared mobile order: active → pending → snoozed shelf → settled.
279 * Pending tasks are waiting rather than asking, and parked work remains399 * Pending tasks are waiting rather than asking, and parked work remains
...@@ -289,21 +409,64 @@ export function buildThreadListV2ListItems(input: {...@@ -289,21 +409,64 @@ export function buildThreadListV2ListItems(input: {
289 readonly settledShelfExpanded?: boolean;409 readonly settledShelfExpanded?: boolean;
290 readonly settledShelfHeaderIndex?: number | null;410 readonly settledShelfHeaderIndex?: number | null;
291 readonly snoozeLabelNow?: string;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}): ThreadListV2ListItem[] {428}): ThreadListV2ListItem[] {
293 const threadItems = input.items.map((item): ThreadListV2ListItem => ({429 const threadItems = input.items.map((item): ThreadListV2ListItem => {
294 type: "v2-thread",430 const snoozeWakeLabelText =
295 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
296 item,
297 snoozeWakeLabelText:
298 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined431 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
299 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })432 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
300 : undefined,433 : undefined;
301 }));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 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({464 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
303 type: "v2-pending",465 type: "v2-pending",
304 key: `v2-${pendingTask.key}`,466 key: `v2-${pendingTask.key}`,
305 pendingTask,467 pendingTask,
306 showPendingDivider: index === 0,468 showPendingDivider: index === 0,
469 showTrailingDivider: false,
307 }));470 }));
308 const snoozedCount = input.snoozedCount ?? 0;471 const snoozedCount = input.snoozedCount ?? 0;
309 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;472 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
...@@ -312,12 +475,14 @@ export function buildThreadListV2ListItems(input: {...@@ -312,12 +475,14 @@ export function buildThreadListV2ListItems(input: {
312 const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;475 const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
313 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;476 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
314 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];477 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
478 const shelfDisabled = input.shelfPreferencesLoading === true;
315 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {479 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
316 result.push({480 result.push({
317 type: "v2-snoozed-shelf",481 type: "v2-snoozed-shelf",
318 key: "v2-snoozed-shelf",482 key: "v2-snoozed-shelf",
319 count: snoozedCount,483 count: snoozedCount,
320 expanded: input.snoozedShelfExpanded === true,484 expanded: input.snoozedShelfExpanded === true,
485 disabled: shelfDisabled,
321 });486 });
322 result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));487 result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
323 }488 }
...@@ -327,10 +492,21 @@ export function buildThreadListV2ListItems(input: {...@@ -327,10 +492,21 @@ export function buildThreadListV2ListItems(input: {
327 key: "v2-settled-shelf",492 key: "v2-settled-shelf",
328 count: settledCount,493 count: settledCount,
329 expanded: input.settledShelfExpanded !== false,494 expanded: input.settledShelfExpanded !== false,
495 disabled: shelfDisabled,
330 });496 });
331 result.push(...threadItems.slice(settledShelfHeaderIndex));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}
335511
336/**512/**
apps/mobile/src/features/threads/threadOrder.ts+143-2
...@@ -1,5 +1,9 @@...@@ -1,5 +1,9 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";2import {
3 generateSpreadPinOrderKeys,
4 pinOrderKeyBetween,
5 planPinnedReorder,
6} from "@t3tools/client-runtime/state/thread-sort";
3import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";7import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
4import type { EnvironmentId } from "@t3tools/contracts";8import type { EnvironmentId } from "@t3tools/contracts";
59
...@@ -42,7 +46,7 @@ export function threadOrderAfterMove(...@@ -42,7 +46,7 @@ export function threadOrderAfterMove(
42 return result;46 return result;
43}47}
4448
45type OrderRow = Pick<49export type OrderRow = Pick<
46 EnvironmentThreadShell,50 EnvironmentThreadShell,
47 | "id"51 | "id"
48 | "environmentId"52 | "environmentId"
...@@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: {...@@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: {
106 };110 };
107}111}
108112
113export 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 */
126export 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
109export function createPendingThreadOrder(input: {250export function createPendingThreadOrder(input: {
110 readonly section: PendingThreadOrder["section"];251 readonly section: PendingThreadOrder["section"];
111 readonly ordered: readonly OrderRow[];252 readonly ordered: readonly OrderRow[];
apps/mobile/src/features/threads/threadOrderAvailability.test.ts created+265
...@@ -0,0 +1,265 @@
1import { describe, expect, it } from "vite-plus/test";
2import {
3 generateSpreadPinOrderKeys,
4 pinOrderKeyBetween,
5} from "@t3tools/client-runtime/state/thread-sort";
6import type { EnvironmentId } from "@t3tools/contracts";
7
8import {
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
18function 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
30function 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
38const 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
74function 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
96function 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
120const WRITABLE = new Set<EnvironmentId>(["env-w" as EnvironmentId]);
121
122describe("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});