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 @@
11import type { ThreadMoveDestination } from "../threads/threadOrder";
2import { createThreadMovePlanner } from "../threads/threadOrder";
2import { computeThreadMoveAvailability } from "../threads/threadOrder";
33import {
44 LegendList,
55 type LegendListRef,
......@@ -23,7 +23,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
2323import { AsyncResult } from "effect/unstable/reactivity";
2424import { useFocusEffect } from "@react-navigation/native";
2525import { useCallback, useEffect, useMemo, useRef, useState } from "react";
26import { ActivityIndicator, FlatList, Platform, View } from "react-native";
26import { ActivityIndicator, Platform, View } from "react-native";
2727import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2828import { useSafeAreaInsets } from "react-native-safe-area-context";
2929
......@@ -55,11 +55,12 @@ import {
5555 ThreadListV2ShowMoreRow,
5656 ThreadListV2SnoozedShelfHeader,
5757} from "../threads/thread-list-v2-items";
58import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
58import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
5959import {
6060 buildThreadListV2Items,
6161 getThreadListV2OrderedSection,
6262 buildThreadListV2ListItems,
63 threadListV2ListItemsAreEqual,
6364 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
6465 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
6566 type ThreadListV2ListItem,
......@@ -138,6 +139,21 @@ interface HomeScreenProps {
138139/* ─── Layout constants ───────────────────────────────────────────────── */
139140
140141const 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;
141157const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
142158/**
143159 * Top spacing between the list and the Android custom header. The Android
......@@ -655,12 +671,19 @@ export function HomeScreen(props: HomeScreenProps) {
655671 ),
656672 [serverConfigs],
657673 );
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);
658677 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
659 const threadMovePlanners = useMemo(() => {
660 const sectionPlanner = (section: "pinned" | "active") =>
661 createThreadMovePlanner({
678 // Up/down menu availability for every card, computed once per section per
679 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
680 // list construction quadratic, and this list rebuilds on every minute tick.
681 const threadMoveAvailability = useMemo(() => {
682 const sectionAvailability = (section: "pinned" | "active") =>
683 computeThreadMoveAvailability({
662684 allThreads: props.threads,
663685 section,
686 pendingOrder,
664687 reorderableEnvironmentIds: new Set(
665688 [...serverConfigs].flatMap(([id, config]) =>
666689 (section === "pinned"
......@@ -680,7 +703,7 @@ export function HomeScreen(props: HomeScreenProps) {
680703 queuedThreadKeys,
681704 }),
682705 });
683 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
706 return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
684707 }, [
685708 serverConfigs,
686709 props.threads,
......@@ -783,8 +806,22 @@ export function HomeScreen(props: HomeScreenProps) {
783806 settledShelfExpanded,
784807 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
785808 snoozeLabelNow: `${nowMinute}:00.000Z`,
809 snoozeEnvironmentIds,
810 queuedThreadKeys,
811 moveAvailability: threadMoveAvailability,
812 shelfPreferencesLoading: !shelfPreferencesLoaded,
786813 }),
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 ],
788825 );
789826
790827 useThreadJumpShortcuts(
......@@ -793,11 +830,7 @@ export function HomeScreen(props: HomeScreenProps) {
793830 );
794831
795832 const renderV2Item = useCallback(
796 ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {
797 const nextItem = threadListV2Items[index + 1];
798 const showTrailingDivider =
799 nextItem?.type === "v2-thread" ||
800 (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
833 ({ item }: { readonly item: ThreadListV2ListItem }) => {
801834 if (item.type === "v2-pending") {
802835 const pendingScopeKey = scopedProjectKey(
803836 item.pendingTask.environmentId,
......@@ -816,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) {
816849 }
817850 environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
818851 showPendingDivider={item.showPendingDivider}
819 showTrailingDivider={showTrailingDivider}
852 showTrailingDivider={item.showTrailingDivider}
820853 onSelectPendingTask={props.onSelectPendingTask}
821854 onDeletePendingTask={props.onDeletePendingTask}
822855 />
......@@ -826,7 +859,7 @@ export function HomeScreen(props: HomeScreenProps) {
826859 return (
827860 <ThreadListV2SnoozedShelfHeader
828861 count={item.count}
829 disabled={!shelfPreferencesLoaded}
862 disabled={item.disabled}
830863 expanded={item.expanded}
831864 onToggle={toggleSnoozedShelf}
832865 />
......@@ -836,33 +869,32 @@ export function HomeScreen(props: HomeScreenProps) {
836869 return (
837870 <ThreadListV2SettledShelfHeader
838871 count={item.count}
839 disabled={!shelfPreferencesLoaded}
872 disabled={item.disabled}
840873 expanded={item.expanded}
841874 onToggle={toggleSettledShelf}
842875 />
843876 );
844877 }
845878 const thread = item.item.thread;
846 const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
847 const movedId = `${thread.environmentId}:${thread.id}`;
848879 return (
849880 <ThreadListV2Row
850881 onNewThreadOnBranch={props.onNewThreadOnBranch}
851882 thread={thread}
852883 variant={item.item.variant}
853 hasQueuedMessages={queuedThreadKeys.has(movedId)}
884 hasQueuedMessages={item.hasQueuedMessages}
854885 snoozed={item.item.snoozed}
855886 pinned={item.item.pinned}
856 snoozePresetMinute={nowMinute}
887 snoozePresetMinute={item.snoozePresetMinute ?? ""}
857888 snoozeWakeLabelText={item.snoozeWakeLabelText}
858 showTrailingDivider={showTrailingDivider}
889 timeLabel={item.timeLabel}
890 showTrailingDivider={item.showTrailingDivider}
859891 project={
860892 projectByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null
861893 }
862894 projectTitle={v2ProjectTitleByProjectKey.get(
863895 scopedProjectKey(thread.environmentId, thread.projectId),
864896 )}
865 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
897 providerInstance={resolveProviderInstance(thread)}
866898 environmentLabel={
867899 Object.keys(props.savedConnectionsById).length > 1
868900 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
......@@ -891,8 +923,8 @@ export function HomeScreen(props: HomeScreenProps) {
891923 ? pinReorderEnvironmentIds.has(thread.environmentId)
892924 : activeReorderEnvironmentIds.has(thread.environmentId)
893925 }
894 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
895 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
926 canMoveUp={item.canMoveUp}
927 canMoveDown={item.canMoveDown}
896928 onSnoozeThread={handleSnoozeThread}
897929 onUnsnoozeThread={handleUnsnoozeThread}
898930 onUnsettleThread={handleUnsettleThread}
......@@ -907,9 +939,6 @@ export function HomeScreen(props: HomeScreenProps) {
907939 [
908940 handleDeleteThread,
909941 activeReorderEnvironmentIds,
910 threadMovePlanners,
911 pendingOrder,
912 queuedThreadKeys,
913942 handleMoveThread,
914943 handlePinThread,
915944 handleRegenerateThreadTitle,
......@@ -931,25 +960,23 @@ export function HomeScreen(props: HomeScreenProps) {
931960 props.onSelectThread,
932961 props.onNewThreadOnBranch,
933962 props.savedConnectionsById,
934 serverConfigs,
935 shelfPreferencesLoaded,
963 resolveProviderInstance,
936964 settlementEnvironmentIds,
937965 snoozeEnvironmentIds,
938 threadListV2Items,
939966 threadSearchMatchByKey,
940967 titleRegenerationEnvironmentIds,
941968 toggleSettledShelf,
942969 toggleSnoozedShelf,
943970 v2ProjectTitleByProjectKey,
944971 props.searchQuery,
945 nowMinute,
946972 ],
947973 );
948974 const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
949975
950 // FlatList treats a changed extraData identity as "re-render every visible
951 // row", so an inline object literal would invalidate all rows on every
952 // HomeScreen render.
976 // FlatList/LegendList treat a changed extraData identity as "re-render every
977 // visible row", so an inline object literal would invalidate all rows on
978 // every HomeScreen render — and the minute clock must stay out of it for
979 // the same reason: the clock text is precomputed per item instead.
953980 const v2ExtraData = useMemo(
954981 () => ({
955982 projectByKey,
......@@ -957,7 +984,6 @@ export function HomeScreen(props: HomeScreenProps) {
957984 serverConfigs,
958985 savedConnectionsById: props.savedConnectionsById,
959986 searchQuery: props.searchQuery,
960 snoozePresetMinute: nowMinute,
961987 threadSearchMatchByKey,
962988 }),
963989 [
......@@ -965,7 +991,6 @@ export function HomeScreen(props: HomeScreenProps) {
965991 props.searchQuery,
966992 props.savedConnectionsById,
967993 serverConfigs,
968 nowMinute,
969994 threadSearchMatchByKey,
970995 v2ProjectTitleByProjectKey,
971996 ],
......@@ -1225,11 +1250,19 @@ export function HomeScreen(props: HomeScreenProps) {
12251250 : "flex-1 bg-screen"
12261251 }
12271252 >
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. */}
12281256 <SwipeableScrollGateProvider enabled={swipeEnabled}>
1229 <FlatList
1257 <LegendList
12301258 data={threadListV2Items}
12311259 renderItem={renderV2Item}
12321260 keyExtractor={v2KeyExtractor}
1261 getItemType={(item) => item.type}
1262 itemsAreEqual={threadListV2ListItemsAreEqual}
1263 estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
1264 drawDistance={500}
1265 recycleItems
12331266 extraData={v2ExtraData}
12341267 ListHeaderComponent={v2ListHeader}
12351268 ListFooterComponent={
apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx+50-57
......@@ -1,5 +1,5 @@
11import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
2import { createThreadMovePlanner } from "./threadOrder";
2import { computeThreadMoveAvailability } from "./threadOrder";
33import type {
44 EnvironmentProject,
55 EnvironmentThreadShell,
......@@ -84,11 +84,13 @@ import {
8484 ThreadListV2ShowMoreRow,
8585 ThreadListV2SnoozedShelfHeader,
8686} from "./thread-list-v2-items";
87import { resolveThreadProviderInstance } from "./thread-provider-instance";
87import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
8888import {
8989 buildThreadListV2Items,
9090 getThreadListV2OrderedSection,
9191 buildThreadListV2ListItems,
92 isThreadListV2ListItem,
93 threadListV2ListItemsAreEqual,
9294 THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
9395 THREAD_LIST_V2_SETTLED_PAGE_COUNT,
9496 type ThreadListV2ListItem,
......@@ -483,12 +485,19 @@ function ThreadNavigationSidebarPane(
483485 ),
484486 [serverConfigs],
485487 );
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);
486491 const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
487 const threadMovePlanners = useMemo(() => {
488 const sectionPlanner = (section: "pinned" | "active") =>
489 createThreadMovePlanner({
492 // Up/down menu availability for every card, computed once per section per
493 // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
494 // list construction quadratic, and this list rebuilds on every minute tick.
495 const threadMoveAvailability = useMemo(() => {
496 const sectionAvailability = (section: "pinned" | "active") =>
497 computeThreadMoveAvailability({
490498 allThreads: threads,
491499 section,
500 pendingOrder,
492501 reorderableEnvironmentIds: new Set(
493502 [...serverConfigs].flatMap(([id, config]) =>
494503 (section === "pinned"
......@@ -508,7 +517,7 @@ function ThreadNavigationSidebarPane(
508517 queuedThreadKeys,
509518 }),
510519 });
511 return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
520 return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
512521 }, [
513522 serverConfigs,
514523 threads,
......@@ -607,6 +616,10 @@ function ThreadNavigationSidebarPane(
607616 settledShelfExpanded,
608617 settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
609618 snoozeLabelNow: `${nowMinute}:00.000Z`,
619 snoozeEnvironmentIds,
620 queuedThreadKeys,
621 moveAvailability: threadMoveAvailability,
622 shelfPreferencesLoading: !shelfPreferencesLoaded,
610623 });
611624 if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) {
612625 items.push({
......@@ -622,9 +635,13 @@ function ThreadNavigationSidebarPane(
622635 options.selectedEnvironmentId,
623636 pendingTasks,
624637 props.searchQuery,
638 queuedThreadKeys,
639 threadMoveAvailability,
625640 selectedProjectRefs,
626641 settledShelfExpanded,
642 shelfPreferencesLoaded,
627643 snoozedShelfExpanded,
644 snoozeEnvironmentIds,
628645 threadListV2Enabled,
629646 threadListV2Layout,
630647 ]);
......@@ -789,9 +806,11 @@ function ThreadNavigationSidebarPane(
789806 onScroll: onMaterialFabScroll,
790807 onScrollBeginDrag: handleScrollBeginDrag,
791808 });
792 // Project shells load after the first rows draw, so the maps they feed have
793 // to bust the recycler's memoization — otherwise a row keeps the blank
794 // favicon and fallback title it was first rendered with.
809 // The sticky header's project shells and search maps feed row props, so
810 // they have to bust the recycler's memoization — otherwise a row keeps the
811 // blank favicon and fallback title it was first rendered with. The minute
812 // clock deliberately stays out: its per-row text lives on the items, so a
813 // tick only re-renders rows whose displayed text actually moved.
795814 const listExtraData = useMemo(
796815 () => ({
797816 selectedThreadKey: props.selectedThreadKey ?? "",
......@@ -799,7 +818,6 @@ function ThreadNavigationSidebarPane(
799818 projectTitleByProjectKey,
800819 savedConnectionsById,
801820 serverConfigs,
802 snoozePresetMinute: nowMinute,
803821 threadSearchMatchByKey,
804822 }),
805823 [
......@@ -808,49 +826,23 @@ function ThreadNavigationSidebarPane(
808826 projectTitleByProjectKey,
809827 savedConnectionsById,
810828 serverConfigs,
811 nowMinute,
812829 threadSearchMatchByKey,
813830 ],
814831 );
815832 useThreadJumpShortcuts(listItems, handleSelectThread);
816833 const sidebarItemsAreEqual = useCallback(
817834 (previous: SidebarListItem, item: SidebarListItem): boolean => {
818 if (previous.type === "v2-thread" && item.type === "v2-thread") {
819 return (
820 previous.key === item.key &&
821 previous.item.thread === item.item.thread &&
822 previous.item.variant === item.item.variant &&
823 previous.item.snoozed === item.item.snoozed &&
824 previous.item.pinned === item.item.pinned &&
825 previous.snoozeWakeLabelText === item.snoozeWakeLabelText
826 );
835 if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) {
836 return threadListV2ListItemsAreEqual(previous, item);
827837 }
828838 if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
829839 return previous.hiddenCount === item.hiddenCount;
830840 }
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 }
843841 if (
844 previous.type === "v2-thread" ||
842 isThreadListV2ListItem(previous) ||
845843 previous.type === "v2-show-more" ||
846 previous.type === "v2-pending" ||
847 previous.type === "v2-snoozed-shelf" ||
848 previous.type === "v2-settled-shelf" ||
849 item.type === "v2-thread" ||
850 item.type === "v2-show-more" ||
851 item.type === "v2-pending" ||
852 item.type === "v2-snoozed-shelf" ||
853 item.type === "v2-settled-shelf"
844 isThreadListV2ListItem(item) ||
845 item.type === "v2-show-more"
854846 ) {
855847 return false;
856848 }
......@@ -904,24 +896,29 @@ function ThreadNavigationSidebarPane(
904896 }
905897 case "v2-thread": {
906898 const thread = item.item.thread;
907 const movePlanner = item.item.pinned
908 ? threadMovePlanners.pinned
909 : threadMovePlanners.active;
910 const movedId = `${thread.environmentId}:${thread.id}`;
911899 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.
912908 return (
913909 <ThreadListV2Row
914910 onNewThreadOnBranch={props.onNewThreadOnBranch}
915911 thread={thread}
916912 variant={item.item.variant}
917 hasQueuedMessages={queuedThreadKeys.has(`${thread.environmentId}:${thread.id}`)}
913 hasQueuedMessages={item.hasQueuedMessages}
918914 snoozed={item.item.snoozed}
919915 pinned={item.item.pinned}
920 snoozePresetMinute={nowMinute}
916 snoozePresetMinute={item.snoozePresetMinute ?? ""}
921917 snoozeWakeLabelText={item.snoozeWakeLabelText}
918 timeLabel={item.timeLabel}
922919 project={projectByKey.get(scopeKey) ?? null}
923920 projectTitle={projectTitleByProjectKey.get(scopeKey)}
924 providerInstance={resolveThreadProviderInstance(serverConfigs, thread)}
921 providerInstance={resolveProviderInstance(thread)}
925922 environmentLabel={
926923 Object.keys(savedConnectionsById).length > 1
927924 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
......@@ -955,8 +952,8 @@ function ThreadNavigationSidebarPane(
955952 ? pinReorderEnvironmentIds.has(thread.environmentId)
956953 : activeReorderEnvironmentIds.has(thread.environmentId)
957954 }
958 canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
959 canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
955 canMoveUp={item.canMoveUp}
956 canMoveDown={item.canMoveDown}
960957 onSnoozeThread={snoozeThread}
961958 onUnsnoozeThread={unsnoozeThread}
962959 onUnsettleThread={unsettleThread}
......@@ -973,7 +970,7 @@ function ThreadNavigationSidebarPane(
973970 return (
974971 <ThreadListV2SnoozedShelfHeader
975972 count={item.count}
976 disabled={!shelfPreferencesLoaded}
973 disabled={item.disabled}
977974 expanded={item.expanded}
978975 onToggle={toggleSnoozedShelf}
979976 pane="sidebar"
......@@ -983,7 +980,7 @@ function ThreadNavigationSidebarPane(
983980 return (
984981 <ThreadListV2SettledShelfHeader
985982 count={item.count}
986 disabled={!shelfPreferencesLoaded}
983 disabled={item.disabled}
987984 expanded={item.expanded}
988985 onToggle={toggleSettledShelf}
989986 pane="sidebar"
......@@ -1080,8 +1077,6 @@ function ThreadNavigationSidebarPane(
10801077 [
10811078 archiveThread,
10821079 activeReorderEnvironmentIds,
1083 threadMovePlanners,
1084 pendingOrder,
10851080 queuedThreadKeys,
10861081 confirmDeletePendingTask,
10871082 confirmDeleteThread,
......@@ -1104,8 +1099,6 @@ function ThreadNavigationSidebarPane(
11041099 props.selectedThreadKey,
11051100 props.width,
11061101 savedConnectionsById,
1107 serverConfigs,
1108 shelfPreferencesLoaded,
11091102 threadSearchMatchByKey,
11101103 titleRegenerationEnvironmentIds,
11111104 settleThread,
......@@ -1114,7 +1107,7 @@ function ThreadNavigationSidebarPane(
11141107 sidebarScrollGesture,
11151108 snoozeEnvironmentIds,
11161109 snoozeThread,
1117 nowMinute,
1110 resolveProviderInstance,
11181111 toggleSettledShelf,
11191112 toggleSnoozedShelf,
11201113 unpinThread,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+26-15
......@@ -18,7 +18,6 @@ import type {
1818import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search";
1919import type { EnvironmentMachineKind } from "@t3tools/contracts";
2020import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled";
21import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort";
2221import type { MenuAction } from "@react-native-menu/menu";
2322import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react";
2423import { Alert, Pressable, useWindowDimensions, View } from "react-native";
......@@ -33,7 +32,6 @@ import { ProviderInstanceIcon } from "../../components/ProviderIcon";
3332import type { ThreadRowProviderInstance } from "./thread-provider-instance";
3433import { cn } from "../../lib/cn";
3534import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
36import { relativeTime } from "../../lib/time";
3735import { useUniwindTheme } from "../../lib/useUniwindTheme";
3836import type { PendingNewTask } from "../../state/use-pending-new-tasks";
3937import { useThreadPr } from "../../state/use-thread-pr";
......@@ -69,10 +67,6 @@ const STATUS_LABEL_BY_STATUS: Partial<
6967 failed: { label: "Failed", className: "text-danger-foreground" },
7068};
7169
72function threadTimeLabel(thread: EnvironmentThreadShell): string {
73 return relativeTime(thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt);
74}
75
7670// Menus keep lifecycle and title regeneration together. Archive keeps its
7771// own surface (thread screen / settings) rather than crowding v2 rows.
7872const CARD_MENU_ACTIONS: MenuAction[] = [
......@@ -453,8 +447,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
453447 /** Preformatted against the parent minute tick so this memoized row's
454448 countdown keeps moving. */
455449 readonly snoozeWakeLabelText?: string;
456 /** Parent minute tick passed as a prop so this memoized row refreshes its
457 native snooze menu while mounted. */
450 /** Preformatted against the parent clock (row order timestamp: settle stamp
451 on settled rows, latest activity otherwise). Blank while a status label
452 or the wake countdown owns that slot. Precomputed per row — not via the
453 list's extraData — so the minute tick re-renders only rows whose
454 displayed text moved. */
455 readonly timeLabel: string;
456 /** Parent minute tick carried on the row's list item, present only when the
457 row's menu offers snooze presets, so those menus refresh while mounted
458 without invalidating every other row. */
458459 readonly snoozePresetMinute: string;
459460 readonly project: EnvironmentProject | null;
460461 readonly projectTitle?: string;
......@@ -549,13 +550,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
549550
550551 const status = resolveThreadListV2Status(thread);
551552 const statusLabel = STATUS_LABEL_BY_STATUS[status];
552 // Settled rows label by the same stamp they sort by, so order and label
553 // can't disagree. updatedAt is always present, so the resolver never
554 // returns null here.
555 const settledTimestamp =
556 variant === "slim" && !snoozedRow ? resolveSettledThreadTimestamp(thread) : null;
557 const timeLabel =
558 settledTimestamp !== null ? relativeTime(settledTimestamp) : threadTimeLabel(thread);
553 // The timestamp is precomputed on the list item (same stamps the settled
554 // tail sorts by) so a minute tick only re-renders rows that draw it.
555 const timeLabel = props.timeLabel;
559556
560557 const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]);
561558 const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
......@@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
565562 );
566563 const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
567564 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 }
568578 const handleSnooze = useCallback(
569579 (snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
570580 [onSnoozeThread, thread],
......@@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
758768 handleUnsettle,
759769 handleUnsnooze,
760770 snoozePresets,
771 setCustomSnoozeOpen,
761772 ],
762773 );
763774 const primaryAction = useMemo(() => {
apps/mobile/src/features/threads/thread-provider-instance.test.ts+54-1
......@@ -8,7 +8,10 @@ import {
88} from "@t3tools/contracts";
99import { 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
1316function makeConfig(
1417 providers: ReadonlyArray<{
......@@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => {
9699 expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
97100 });
98101});
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 @@
1import { useMemo } from "react";
2
13import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
24import {
35 normalizeProviderAccentColor,
......@@ -40,3 +42,33 @@ export function resolveThreadProviderInstance(
4042 ),
4143 };
4244}
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 {
55 threadOrderAfterMove,
66 threadDropLifecycle,
77 reconcilePendingThreadOrder,
8 computeThreadMoveAvailability,
89 type PendingThreadOrder,
10 type ThreadMoveAvailability,
911} from "./threadOrder";
1012import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
1113import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
......@@ -19,7 +21,7 @@ import {
1921 ThreadId,
2022 TurnId,
2123} from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
24import { describe, expect, it, vi } from "vite-plus/test";
2325
2426import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2527import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
......@@ -27,12 +29,15 @@ import {
2729 buildThreadListV2Items,
2830 buildThreadListV2ListItems,
2931 getThreadListV2OrderedSection,
32 isThreadListV2ListItem,
3033 resolveThreadListV2Enabled,
3134 resolveThreadListV2SnoozeMenuSelection,
3235 resolveThreadListV2SnoozeGateExpiryMs,
3336 resolveThreadListV2Status,
3437 resolveThreadListV2SwipeActions,
3538 sortThreadsForListV2,
39 threadListV2ListItemsAreEqual,
40 type ThreadListV2ListItem,
3641} from "./threadListV2";
3742
3843const environmentId = EnvironmentId.make("environment-1");
......@@ -1482,3 +1487,551 @@ describe("cross-section thread drops", () => {
14821487 ).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
14831488 });
14841489});
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 @@
11import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
22import {
3 canSnooze,
34 effectiveSnoozed,
45 hasQueuedTurnStart,
56 QUEUED_TURN_START_GRACE_MS,
......@@ -16,6 +17,9 @@ import {
1617} from "@t3tools/client-runtime/state/thread-sort";
1718import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
1819
20import type { ThreadMoveAvailability } from "./threadOrder";
21
22import { relativeTime } from "../../lib/time";
1923import type { PendingNewTask } from "../../state/use-pending-new-tasks";
2024
2125import {
......@@ -244,6 +248,28 @@ export interface ThreadListV2ThreadListItem {
244248 readonly item: ThreadListV2Item;
245249 /** Precomputed so recycled-list equality can see a minute-tick change. */
246250 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;
247273}
248274
249275export interface ThreadListV2PendingListItem {
......@@ -252,6 +278,9 @@ export interface ThreadListV2PendingListItem {
252278 readonly pendingTask: PendingNewTask;
253279 /** First queued row after the active block draws the PENDING divider. */
254280 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;
255284}
256285
257286export interface ThreadListV2SnoozedShelfListItem {
......@@ -259,6 +288,11 @@ export interface ThreadListV2SnoozedShelfListItem {
259288 readonly key: "v2-snoozed-shelf";
260289 readonly count: number;
261290 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;
262296}
263297
264298export interface ThreadListV2SettledShelfListItem {
......@@ -266,6 +300,8 @@ export interface ThreadListV2SettledShelfListItem {
266300 readonly key: "v2-settled-shelf";
267301 readonly count: number;
268302 readonly expanded: boolean;
303 /** See the snoozed shelf header's field. */
304 readonly disabled: boolean;
269305}
270306
271307export type ThreadListV2ListItem =
......@@ -274,6 +310,90 @@ export type ThreadListV2ListItem =
274310 | ThreadListV2SnoozedShelfListItem
275311 | 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
277397/**
278398 * Builds the shared mobile order: active → pending → snoozed shelf → settled.
279399 * Pending tasks are waiting rather than asking, and parked work remains
......@@ -289,21 +409,64 @@ export function buildThreadListV2ListItems(input: {
289409 readonly settledShelfExpanded?: boolean;
290410 readonly settledShelfHeaderIndex?: number | null;
291411 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;
292428}): ThreadListV2ListItem[] {
293 const threadItems = input.items.map((item): ThreadListV2ListItem => ({
294 type: "v2-thread",
295 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
296 item,
297 snoozeWakeLabelText:
429 const threadItems = input.items.map((item): ThreadListV2ListItem => {
430 const snoozeWakeLabelText =
298431 item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
299432 ? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
300 : undefined,
301 }));
433 : undefined;
434 // The minute clock belongs on the item, not the list's extraData, so the
435 // recycler's equality can confine the per-minute re-render to rows whose
436 // snooze menu actually shows preset times. The swipe-revealed snooze menu
437 // exists on slim rows too (the variant only swaps the primary action),
438 // so the gate follows actual snooze availability, not the variant.
439 const snoozePresetMinute =
440 !item.snoozed &&
441 input.snoozeLabelNow !== undefined &&
442 (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) &&
443 canSnooze(item.thread, { now: input.snoozeLabelNow })
444 ? input.snoozeLabelNow
445 : undefined;
446 const move =
447 item.variant === "card"
448 ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`)
449 : undefined;
450 return {
451 type: "v2-thread",
452 key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
453 item,
454 snoozeWakeLabelText,
455 timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined),
456 snoozePresetMinute,
457 showTrailingDivider: false,
458 hasQueuedMessages:
459 input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true,
460 canMoveUp: move?.canMoveUp === true,
461 canMoveDown: move?.canMoveDown === true,
462 };
463 });
302464 const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
303465 type: "v2-pending",
304466 key: `v2-${pendingTask.key}`,
305467 pendingTask,
306468 showPendingDivider: index === 0,
469 showTrailingDivider: false,
307470 }));
308471 const snoozedCount = input.snoozedCount ?? 0;
309472 const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
......@@ -312,12 +475,14 @@ export function buildThreadListV2ListItems(input: {
312475 const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
313476 const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
314477 const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
478 const shelfDisabled = input.shelfPreferencesLoading === true;
315479 if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
316480 result.push({
317481 type: "v2-snoozed-shelf",
318482 key: "v2-snoozed-shelf",
319483 count: snoozedCount,
320484 expanded: input.snoozedShelfExpanded === true,
485 disabled: shelfDisabled,
321486 });
322487 result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
323488 }
......@@ -327,10 +492,21 @@ export function buildThreadListV2ListItems(input: {
327492 key: "v2-settled-shelf",
328493 count: settledCount,
329494 expanded: input.settledShelfExpanded !== false,
495 disabled: shelfDisabled,
330496 });
331497 result.push(...threadItems.slice(settledShelfHeaderIndex));
332498 }
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 });
334510}
335511
336512/**
apps/mobile/src/features/threads/threadOrder.ts+143-2
......@@ -1,5 +1,9 @@
11import 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";
37import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
48import type { EnvironmentId } from "@t3tools/contracts";
59
......@@ -42,7 +46,7 @@ export function threadOrderAfterMove(
4246 return result;
4347}
4448
45type OrderRow = Pick<
49export type OrderRow = Pick<
4650 EnvironmentThreadShell,
4751 | "id"
4852 | "environmentId"
......@@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: {
106110 };
107111}
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
109250export function createPendingThreadOrder(input: {
110251 readonly section: PendingThreadOrder["section"];
111252 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});