diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx
index 946672ca711746c919b923a053841b998e206a4b..80e9f5cac6fe9f5458946c3b2fa26d0c930f4cc3 100644
--- a/apps/mobile/src/features/home/HomeScreen.tsx
+++ b/apps/mobile/src/features/home/HomeScreen.tsx
@@ -1,5 +1,5 @@
import type { ThreadMoveDestination } from "../threads/threadOrder";
-import { createThreadMovePlanner } from "../threads/threadOrder";
+import { computeThreadMoveAvailability } from "../threads/threadOrder";
import {
LegendList,
type LegendListRef,
@@ -23,7 +23,7 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useFocusEffect } from "@react-navigation/native";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityIndicator, FlatList, Platform, View } from "react-native";
+import { ActivityIndicator, Platform, View } from "react-native";
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -55,11 +55,12 @@ import {
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
} from "../threads/thread-list-v2-items";
-import { resolveThreadProviderInstance } from "../threads/thread-provider-instance";
+import { useThreadRowProviderInstanceResolver } from "../threads/thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
+ threadListV2ListItemsAreEqual,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
@@ -138,6 +139,21 @@ interface HomeScreenProps {
/* ─── Layout constants ───────────────────────────────────────────────── */
const ESTIMATED_THREAD_ROW_HEIGHT = 72;
+// v2 rows are mixed-height: settled slim rows run ~60dp, single-line cards
+// measured ~74dp on device (252px on the Pixel 10 Pro screenshot), two-line
+// cards ~94dp. The estimate seeds the recycler's initial container count,
+// `ceil((scrollLength + 2 * INITIAL_DRAW_DISTANCE) / estimate)` with the
+// initial draw distance capped at 50, so an estimate at or below the average
+// row height starts the pool at or above the item count for the short lists
+// that LegendList otherwise keeps pooling to exactly its item count — that is
+// what stopped the dev-mode "no unused container available" warning on the
+// seeded short-list device passes. It is a mitigation, not an elimination:
+// after first layout the full drawDistance applies, and a sudden expansion
+// past the pooled headroom (~25+ items appearing at once) still creates a
+// container on demand with the dev-only warning one pass ahead of the
+// measured-height pool expansion. The old tallest-card estimate (~92) fired
+// that warning on every ordinary shelf expand, so the average wins.
+const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72;
const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44;
/**
* Top spacing between the list and the Android custom header. The Android
@@ -655,12 +671,19 @@ export function HomeScreen(props: HomeScreenProps) {
),
[serverConfigs],
);
+ // Reference-stable provider glyphs: a fresh object per render would break
+ // the memoized rows' props comparison on every parent render.
+ const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
- const threadMovePlanners = useMemo(() => {
- const sectionPlanner = (section: "pinned" | "active") =>
- createThreadMovePlanner({
+ // Up/down menu availability for every card, computed once per section per
+ // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
+ // list construction quadratic, and this list rebuilds on every minute tick.
+ const threadMoveAvailability = useMemo(() => {
+ const sectionAvailability = (section: "pinned" | "active") =>
+ computeThreadMoveAvailability({
allThreads: props.threads,
section,
+ pendingOrder,
reorderableEnvironmentIds: new Set(
[...serverConfigs].flatMap(([id, config]) =>
(section === "pinned"
@@ -680,7 +703,7 @@ export function HomeScreen(props: HomeScreenProps) {
queuedThreadKeys,
}),
});
- return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+ return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
}, [
serverConfigs,
props.threads,
@@ -783,8 +806,22 @@ export function HomeScreen(props: HomeScreenProps) {
settledShelfExpanded,
settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
snoozeLabelNow: `${nowMinute}:00.000Z`,
+ snoozeEnvironmentIds,
+ queuedThreadKeys,
+ moveAvailability: threadMoveAvailability,
+ shelfPreferencesLoading: !shelfPreferencesLoaded,
}),
- [settledShelfExpanded, snoozedShelfExpanded, threadListV2Layout, v2PendingTasks],
+ [
+ nowMinute,
+ queuedThreadKeys,
+ threadMoveAvailability,
+ settledShelfExpanded,
+ shelfPreferencesLoaded,
+ snoozedShelfExpanded,
+ snoozeEnvironmentIds,
+ threadListV2Layout,
+ v2PendingTasks,
+ ],
);
useThreadJumpShortcuts(
@@ -793,11 +830,7 @@ export function HomeScreen(props: HomeScreenProps) {
);
const renderV2Item = useCallback(
- ({ item, index }: { readonly item: ThreadListV2ListItem; readonly index: number }) => {
- const nextItem = threadListV2Items[index + 1];
- const showTrailingDivider =
- nextItem?.type === "v2-thread" ||
- (nextItem?.type === "v2-pending" && !nextItem.showPendingDivider);
+ ({ item }: { readonly item: ThreadListV2ListItem }) => {
if (item.type === "v2-pending") {
const pendingScopeKey = scopedProjectKey(
item.pendingTask.environmentId,
@@ -816,7 +849,7 @@ export function HomeScreen(props: HomeScreenProps) {
}
environmentMachine={machineByEnvironmentId.get(item.pendingTask.environmentId)}
showPendingDivider={item.showPendingDivider}
- showTrailingDivider={showTrailingDivider}
+ showTrailingDivider={item.showTrailingDivider}
onSelectPendingTask={props.onSelectPendingTask}
onDeletePendingTask={props.onDeletePendingTask}
/>
@@ -826,7 +859,7 @@ export function HomeScreen(props: HomeScreenProps) {
return (
@@ -836,33 +869,32 @@ export function HomeScreen(props: HomeScreenProps) {
return (
);
}
const thread = item.item.thread;
- const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active;
- const movedId = `${thread.environmentId}:${thread.id}`;
return (
1
? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
@@ -891,8 +923,8 @@ export function HomeScreen(props: HomeScreenProps) {
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
- canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
- canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
+ canMoveUp={item.canMoveUp}
+ canMoveDown={item.canMoveDown}
onSnoozeThread={handleSnoozeThread}
onUnsnoozeThread={handleUnsnoozeThread}
onUnsettleThread={handleUnsettleThread}
@@ -907,9 +939,6 @@ export function HomeScreen(props: HomeScreenProps) {
[
handleDeleteThread,
activeReorderEnvironmentIds,
- threadMovePlanners,
- pendingOrder,
- queuedThreadKeys,
handleMoveThread,
handlePinThread,
handleRegenerateThreadTitle,
@@ -931,25 +960,23 @@ export function HomeScreen(props: HomeScreenProps) {
props.onSelectThread,
props.onNewThreadOnBranch,
props.savedConnectionsById,
- serverConfigs,
- shelfPreferencesLoaded,
+ resolveProviderInstance,
settlementEnvironmentIds,
snoozeEnvironmentIds,
- threadListV2Items,
threadSearchMatchByKey,
titleRegenerationEnvironmentIds,
toggleSettledShelf,
toggleSnoozedShelf,
v2ProjectTitleByProjectKey,
props.searchQuery,
- nowMinute,
],
);
const v2KeyExtractor = useCallback((item: ThreadListV2ListItem) => item.key, []);
- // FlatList treats a changed extraData identity as "re-render every visible
- // row", so an inline object literal would invalidate all rows on every
- // HomeScreen render.
+ // FlatList/LegendList treat a changed extraData identity as "re-render every
+ // visible row", so an inline object literal would invalidate all rows on
+ // every HomeScreen render — and the minute clock must stay out of it for
+ // the same reason: the clock text is precomputed per item instead.
const v2ExtraData = useMemo(
() => ({
projectByKey,
@@ -957,7 +984,6 @@ export function HomeScreen(props: HomeScreenProps) {
serverConfigs,
savedConnectionsById: props.savedConnectionsById,
searchQuery: props.searchQuery,
- snoozePresetMinute: nowMinute,
threadSearchMatchByKey,
}),
[
@@ -965,7 +991,6 @@ export function HomeScreen(props: HomeScreenProps) {
props.searchQuery,
props.savedConnectionsById,
serverConfigs,
- nowMinute,
threadSearchMatchByKey,
v2ProjectTitleByProjectKey,
],
@@ -1225,11 +1250,19 @@ export function HomeScreen(props: HomeScreenProps) {
: "flex-1 bg-screen"
}
>
+ {/* Same recycler the iPad sidebar and the legacy list use: cells are
+ reused across data rebuilds and `itemsAreEqual` keeps a minute tick
+ (or an unrelated shell update) from re-rendering untouched rows. */}
- item.type}
+ itemsAreEqual={threadListV2ListItemsAreEqual}
+ estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT}
+ drawDistance={500}
+ recycleItems
extraData={v2ExtraData}
ListHeaderComponent={v2ListHeader}
ListFooterComponent={
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index bf957ad54195001f0356e4025a66f36087338707..23cf2b22d70ed45531e147fedbc7b2d93eeab13b 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -1,5 +1,5 @@
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
-import { createThreadMovePlanner } from "./threadOrder";
+import { computeThreadMoveAvailability } from "./threadOrder";
import type {
EnvironmentProject,
EnvironmentThreadShell,
@@ -84,11 +84,13 @@ import {
ThreadListV2ShowMoreRow,
ThreadListV2SnoozedShelfHeader,
} from "./thread-list-v2-items";
-import { resolveThreadProviderInstance } from "./thread-provider-instance";
+import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
import {
buildThreadListV2Items,
getThreadListV2OrderedSection,
buildThreadListV2ListItems,
+ isThreadListV2ListItem,
+ threadListV2ListItemsAreEqual,
THREAD_LIST_V2_SETTLED_INITIAL_COUNT,
THREAD_LIST_V2_SETTLED_PAGE_COUNT,
type ThreadListV2ListItem,
@@ -483,12 +485,19 @@ function ThreadNavigationSidebarPane(
),
[serverConfigs],
);
+ // Reference-stable provider glyphs: a fresh object per render would break
+ // the memoized rows' props comparison on every parent render.
+ const resolveProviderInstance = useThreadRowProviderInstanceResolver(serverConfigs);
const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick);
- const threadMovePlanners = useMemo(() => {
- const sectionPlanner = (section: "pinned" | "active") =>
- createThreadMovePlanner({
+ // Up/down menu availability for every card, computed once per section per
+ // rebuild (see computeThreadMoveAvailability): per-thread planner calls made
+ // list construction quadratic, and this list rebuilds on every minute tick.
+ const threadMoveAvailability = useMemo(() => {
+ const sectionAvailability = (section: "pinned" | "active") =>
+ computeThreadMoveAvailability({
allThreads: threads,
section,
+ pendingOrder,
reorderableEnvironmentIds: new Set(
[...serverConfigs].flatMap(([id, config]) =>
(section === "pinned"
@@ -508,7 +517,7 @@ function ThreadNavigationSidebarPane(
queuedThreadKeys,
}),
});
- return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") };
+ return new Map([...sectionAvailability("pinned"), ...sectionAvailability("active")]);
}, [
serverConfigs,
threads,
@@ -607,6 +616,10 @@ function ThreadNavigationSidebarPane(
settledShelfExpanded,
settledShelfHeaderIndex: threadListV2Layout.settledShelfHeaderIndex,
snoozeLabelNow: `${nowMinute}:00.000Z`,
+ snoozeEnvironmentIds,
+ queuedThreadKeys,
+ moveAvailability: threadMoveAvailability,
+ shelfPreferencesLoading: !shelfPreferencesLoaded,
});
if (settledShelfExpanded && threadListV2Layout.hiddenSettledCount > 0) {
items.push({
@@ -622,9 +635,13 @@ function ThreadNavigationSidebarPane(
options.selectedEnvironmentId,
pendingTasks,
props.searchQuery,
+ queuedThreadKeys,
+ threadMoveAvailability,
selectedProjectRefs,
settledShelfExpanded,
+ shelfPreferencesLoaded,
snoozedShelfExpanded,
+ snoozeEnvironmentIds,
threadListV2Enabled,
threadListV2Layout,
]);
@@ -789,9 +806,11 @@ function ThreadNavigationSidebarPane(
onScroll: onMaterialFabScroll,
onScrollBeginDrag: handleScrollBeginDrag,
});
- // Project shells load after the first rows draw, so the maps they feed have
- // to bust the recycler's memoization — otherwise a row keeps the blank
- // favicon and fallback title it was first rendered with.
+ // The sticky header's project shells and search maps feed row props, so
+ // they have to bust the recycler's memoization — otherwise a row keeps the
+ // blank favicon and fallback title it was first rendered with. The minute
+ // clock deliberately stays out: its per-row text lives on the items, so a
+ // tick only re-renders rows whose displayed text actually moved.
const listExtraData = useMemo(
() => ({
selectedThreadKey: props.selectedThreadKey ?? "",
@@ -799,7 +818,6 @@ function ThreadNavigationSidebarPane(
projectTitleByProjectKey,
savedConnectionsById,
serverConfigs,
- snoozePresetMinute: nowMinute,
threadSearchMatchByKey,
}),
[
@@ -808,49 +826,23 @@ function ThreadNavigationSidebarPane(
projectTitleByProjectKey,
savedConnectionsById,
serverConfigs,
- nowMinute,
threadSearchMatchByKey,
],
);
useThreadJumpShortcuts(listItems, handleSelectThread);
const sidebarItemsAreEqual = useCallback(
(previous: SidebarListItem, item: SidebarListItem): boolean => {
- if (previous.type === "v2-thread" && item.type === "v2-thread") {
- return (
- previous.key === item.key &&
- previous.item.thread === item.item.thread &&
- previous.item.variant === item.item.variant &&
- previous.item.snoozed === item.item.snoozed &&
- previous.item.pinned === item.item.pinned &&
- previous.snoozeWakeLabelText === item.snoozeWakeLabelText
- );
+ if (isThreadListV2ListItem(previous) && isThreadListV2ListItem(item)) {
+ return threadListV2ListItemsAreEqual(previous, item);
}
if (previous.type === "v2-show-more" && item.type === "v2-show-more") {
return previous.hiddenCount === item.hiddenCount;
}
- if (previous.type === "v2-pending" && item.type === "v2-pending") {
- return (
- previous.pendingTask === item.pendingTask &&
- previous.showPendingDivider === item.showPendingDivider
- );
- }
- if (previous.type === "v2-snoozed-shelf" && item.type === "v2-snoozed-shelf") {
- return previous.count === item.count && previous.expanded === item.expanded;
- }
- if (previous.type === "v2-settled-shelf" && item.type === "v2-settled-shelf") {
- return previous.count === item.count && previous.expanded === item.expanded;
- }
if (
- previous.type === "v2-thread" ||
+ isThreadListV2ListItem(previous) ||
previous.type === "v2-show-more" ||
- previous.type === "v2-pending" ||
- previous.type === "v2-snoozed-shelf" ||
- previous.type === "v2-settled-shelf" ||
- item.type === "v2-thread" ||
- item.type === "v2-show-more" ||
- item.type === "v2-pending" ||
- item.type === "v2-snoozed-shelf" ||
- item.type === "v2-settled-shelf"
+ isThreadListV2ListItem(item) ||
+ item.type === "v2-show-more"
) {
return false;
}
@@ -904,24 +896,29 @@ function ThreadNavigationSidebarPane(
}
case "v2-thread": {
const thread = item.item.thread;
- const movePlanner = item.item.pinned
- ? threadMovePlanners.pinned
- : threadMovePlanners.active;
- const movedId = `${thread.environmentId}:${thread.id}`;
const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId);
+ // Intentional difference from Home: the sidebar never passes
+ // `showTrailingDivider` because its rows render no Home-style row
+ // hairline at all — card rows carry tonal containers in this pane
+ // (the hairline branch is !sidebarPane-only) and slim rows have no
+ // hairline branch. The stamp still rides the shared list items
+ // because Home's boundary suppression consumes it; the sidebar's
+ // only cost is the occasional divider-only equality invalidation,
+ // which re-renders identically.
return (
1
? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null)
@@ -955,8 +952,8 @@ function ThreadNavigationSidebarPane(
? pinReorderEnvironmentIds.has(thread.environmentId)
: activeReorderEnvironmentIds.has(thread.environmentId)
}
- canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null}
- canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null}
+ canMoveUp={item.canMoveUp}
+ canMoveDown={item.canMoveDown}
onSnoozeThread={snoozeThread}
onUnsnoozeThread={unsnoozeThread}
onUnsettleThread={unsettleThread}
@@ -973,7 +970,7 @@ function ThreadNavigationSidebarPane(
return (
onDeleteThread(thread), [onDeleteThread, thread]);
const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]);
@@ -565,6 +562,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);
const handleSettle = useCallback(() => onSettleThread(thread), [onSettleThread, thread]);
const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
+ // A recycled cell reassigns this mounted row to a different thread without
+ // remounting it, and the render closure stops running while list equality
+ // says the item is unchanged — so any row-local UI state must be dismissed
+ // when the identity under it changes. Without this, a custom snooze sheet
+ // opened for one thread survives the thread's removal/reorder and its
+ // submit snoozes whichever thread the cell was reassigned to. (ThreadSwipeable
+ // enforces the same contract on the swipe layer with its resetKey.)
+ const rowIdentity = `${thread.environmentId}:${thread.id}`;
+ const [boundIdentity, setBoundIdentity] = useState(rowIdentity);
+ if (boundIdentity !== rowIdentity) {
+ setBoundIdentity(rowIdentity);
+ setCustomSnoozeOpen(false);
+ }
const handleSnooze = useCallback(
(snoozedUntil: string) => onSnoozeThread(thread, snoozedUntil),
[onSnoozeThread, thread],
@@ -758,6 +768,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handleUnsettle,
handleUnsnooze,
snoozePresets,
+ setCustomSnoozeOpen,
],
);
const primaryAction = useMemo(() => {
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.test.ts b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
index 2afef975907087e270e7e7e7364fcec53540850f..4912bf2b61bae6577c447a324d641f9d8debf0f7 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.test.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.test.ts
@@ -8,7 +8,10 @@ import {
} from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
-import { resolveThreadProviderInstance } from "./thread-provider-instance";
+import {
+ createThreadRowProviderInstanceResolver,
+ resolveThreadProviderInstance,
+} from "./thread-provider-instance";
function makeConfig(
providers: ReadonlyArray<{
@@ -96,3 +99,53 @@ describe("resolveThreadProviderInstance", () => {
expect(resolveThreadProviderInstance(serverConfigs, thread)?.showBadge).toBe(false);
});
});
+
+describe("createThreadRowProviderInstanceResolver", () => {
+ const environmentId = EnvironmentId.make("environment-a");
+ const serverConfigs = new Map([
+ [
+ environmentId,
+ makeConfig([
+ { instanceId: "codex", driver: "codex", displayName: "Codex" },
+ { instanceId: "codex_work", driver: "codex", displayName: "Codex" },
+ ]),
+ ],
+ ]);
+
+ it("hands out the same reference for repeated lookups of one instance", () => {
+ const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ const first = resolve(makeThread(environmentId, "codex"));
+ const second = resolve(makeThread(environmentId, "codex"));
+ // Memoized rows compare props by reference: a fresh object per call would
+ // re-render every row on every parent render (minute tick included).
+ expect(first).not.toBeNull();
+ expect(second).toBe(first);
+ });
+
+ it("distinguishes instances of the same driver", () => {
+ const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ const personal = resolve(makeThread(environmentId, "codex"));
+ const work = resolve(makeThread(environmentId, "codex_work"));
+ expect(personal).not.toBeNull();
+ expect(work).not.toBeNull();
+ expect(work).not.toBe(personal);
+ expect(work?.displayName).toBe("Codex Work");
+ });
+
+ it("hands out a new identity when the server-config generation changes", () => {
+ const before = createThreadRowProviderInstanceResolver(serverConfigs);
+ const nextConfigs = new Map([
+ [environmentId, makeConfig([{ instanceId: "codex", driver: "codex" }])],
+ ]);
+ const after = createThreadRowProviderInstanceResolver(nextConfigs);
+ expect(after(makeThread(environmentId, "codex"))).not.toBe(
+ before(makeThread(environmentId, "codex")),
+ );
+ });
+
+ it("resolves unknown instances to null without throwing", () => {
+ const resolve = createThreadRowProviderInstanceResolver(serverConfigs);
+ expect(resolve(makeThread(environmentId, "ghost"))).toBeNull();
+ expect(resolve(makeThread(environmentId, "ghost"))).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts
index 29dbe8828b667d47ac27e9c7183d0d6c2d4ef004..cd07f636820ccca5d31f18e984106a1ebd62e711 100644
--- a/apps/mobile/src/features/threads/thread-provider-instance.ts
+++ b/apps/mobile/src/features/threads/thread-provider-instance.ts
@@ -1,3 +1,5 @@
+import { useMemo } from "react";
+
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
normalizeProviderAccentColor,
@@ -40,3 +42,33 @@ export function resolveThreadProviderInstance(
),
};
}
+
+/**
+ * Builds a resolver handing out reference-stable `ThreadRowProviderInstance`
+ * objects. `resolveThreadProviderInstance` builds a fresh object per call,
+ * which breaks the memoized row's props comparison on every parent render —
+ * the result only depends on (environment, instance id), so one cache per
+ * server-config generation keeps each row's `providerInstance` prop stable
+ * until the instance behind the row actually changes.
+ */
+export function createThreadRowProviderInstanceResolver(
+ serverConfigs: ReadonlyMap,
+): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null {
+ const cache = new Map();
+ return (thread) => {
+ const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ const cacheKey = `${thread.environmentId}|${instanceId ?? ""}`;
+ const cached = cache.get(cacheKey);
+ if (cached !== undefined) return cached;
+ const resolved = resolveThreadProviderInstance(serverConfigs, thread);
+ cache.set(cacheKey, resolved);
+ return resolved;
+ };
+}
+
+/** List-scoped wrapper: one cache per server-config generation. */
+export function useThreadRowProviderInstanceResolver(
+ serverConfigs: ReadonlyMap,
+): (thread: EnvironmentThreadShell) => ThreadRowProviderInstance | null {
+ return useMemo(() => createThreadRowProviderInstanceResolver(serverConfigs), [serverConfigs]);
+}
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index afc00ef9ab403fbacb62eebf8964b5c34db580ac..15f875d7225a9acef3364ab28113dc4312110b8f 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -5,7 +5,9 @@ import {
threadOrderAfterMove,
threadDropLifecycle,
reconcilePendingThreadOrder,
+ computeThreadMoveAvailability,
type PendingThreadOrder,
+ type ThreadMoveAvailability,
} from "./threadOrder";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
@@ -19,7 +21,7 @@ import {
ThreadId,
TurnId,
} from "@t3tools/contracts";
-import { describe, expect, it } from "vite-plus/test";
+import { describe, expect, it, vi } from "vite-plus/test";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import { threadJumpTarget } from "../keyboard/threadKeyboardShortcuts";
@@ -27,12 +29,15 @@ import {
buildThreadListV2Items,
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
+ isThreadListV2ListItem,
resolveThreadListV2Enabled,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SnoozeGateExpiryMs,
resolveThreadListV2Status,
resolveThreadListV2SwipeActions,
sortThreadsForListV2,
+ threadListV2ListItemsAreEqual,
+ type ThreadListV2ListItem,
} from "./threadListV2";
const environmentId = EnvironmentId.make("environment-1");
@@ -1482,3 +1487,551 @@ describe("cross-section thread drops", () => {
).toEqual({ pin: false, unpin: false, unsettle: false, unsnooze: false });
});
});
+
+/* ─── Recycled-list equality + per-row clock scoping ─────────────────── */
+
+const BASE_MS = Date.parse(NOW);
+const isoAt = (ms: number) => new Date(ms).toISOString();
+const MINUTE_MS = 60_000;
+
+function runningSession(threadId: string) {
+ return {
+ threadId: ThreadId.make(threadId),
+ status: "running" as const,
+ providerName: "Codex",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ runtimeMode: "full-access" as const,
+ activeTurnId: null,
+ lastError: null,
+ updatedAt: NOW,
+ };
+}
+
+function buildTickThreads() {
+ return {
+ ready: makeThread({
+ id: ThreadId.make("tick-ready"),
+ title: "tick ready",
+ latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ }),
+ approval: makeThread({
+ id: ThreadId.make("tick-approval"),
+ title: "tick approval",
+ hasPendingApprovals: true,
+ latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ }),
+ settled: makeThread({
+ id: ThreadId.make("tick-settled"),
+ title: "tick settled",
+ settledOverride: "settled",
+ settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS),
+ }),
+ snoozed: makeThread({
+ id: ThreadId.make("tick-snoozed"),
+ title: "tick snoozed",
+ snoozedAt: isoAt(BASE_MS - MINUTE_MS),
+ snoozedUntil: isoAt(BASE_MS + 2 * 60 * MINUTE_MS),
+ }),
+ };
+}
+
+function buildTickList(
+ threads: ReadonlyArray,
+ clockMs: number,
+ pendingTasks: ReadonlyArray,
+ options?: {
+ readonly snoozeEnvironmentIds?: ReadonlySet;
+ readonly queuedThreadKeys?: ReadonlySet;
+ readonly moveAvailability?: ReadonlyMap;
+ readonly shelfPreferencesLoading?: boolean;
+ },
+): ThreadListV2ListItem[] {
+ const now = isoAt(clockMs);
+ const layout = buildThreadListV2Items({
+ threads,
+ environmentId: null,
+ searchQuery: "",
+ now,
+ snoozedShelfExpanded: true,
+ });
+ return buildThreadListV2ListItems({
+ items: layout.items,
+ pendingTasks,
+ snoozedCount: layout.snoozedCount,
+ snoozedShelfExpanded: true,
+ snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex,
+ settledCount: layout.settledCount,
+ settledShelfHeaderIndex: layout.settledShelfHeaderIndex,
+ snoozeLabelNow: now,
+ ...(options?.snoozeEnvironmentIds
+ ? { snoozeEnvironmentIds: options.snoozeEnvironmentIds }
+ : {}),
+ ...(options?.queuedThreadKeys ? { queuedThreadKeys: options.queuedThreadKeys } : {}),
+ ...(options?.moveAvailability ? { moveAvailability: options.moveAvailability } : {}),
+ ...(options?.shelfPreferencesLoading !== undefined
+ ? { shelfPreferencesLoading: options.shelfPreferencesLoading }
+ : {}),
+ });
+}
+
+function itemsByThreadKey(items: ReadonlyArray) {
+ const byKey = new Map();
+ for (const item of items) byKey.set(item.key, item);
+ return byKey;
+}
+
+describe("threadListV2ListItemsAreEqual", () => {
+ const thread = makeThread({
+ id: ThreadId.make("eq"),
+ title: "eq",
+ latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ });
+ const layout = buildThreadListV2Items({
+ threads: [thread],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+ // One queued task object shared across builds: identity, not content, is
+ // what the row equality compares (mirrors the store's stable references).
+ const queued = makePendingTask("eq-queued");
+ const build = () =>
+ buildThreadListV2ListItems({
+ items: layout.items,
+ pendingTasks: [queued],
+ snoozeLabelNow: NOW,
+ });
+
+ it("treats rebuilt wrappers over identical rows as equal", () => {
+ const first = build();
+ const second = build();
+ expect(first.length).toBe(second.length);
+ for (let index = 0; index < first.length; index += 1) {
+ expect(first[index]).not.toBe(second[index]);
+ expect(threadListV2ListItemsAreEqual(first[index]!, second[index]!)).toBe(true);
+ }
+ });
+
+ it("notices a replaced thread shell", () => {
+ const replacement = makeThread({ id: ThreadId.make("eq"), title: "renamed" });
+ const rebuilt = buildThreadListV2Items({
+ threads: [replacement],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+ const next = buildThreadListV2ListItems({
+ items: rebuilt.items,
+ pendingTasks: [],
+ snoozeLabelNow: NOW,
+ });
+ const previousThread = build().find((item) => item.type === "v2-thread")!;
+ const nextThread = next.find((item) => item.type === "v2-thread")!;
+ expect(threadListV2ListItemsAreEqual(previousThread, nextThread)).toBe(false);
+ });
+
+ it("notices a changed wake countdown label", () => {
+ const snoozedLayout = buildThreadListV2Items({
+ threads: [
+ makeThread({
+ id: ThreadId.make("wake"),
+ title: "wake",
+ snoozedAt: NOW,
+ snoozedUntil: isoAt(BASE_MS + 61 * MINUTE_MS),
+ }),
+ ],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ snoozedShelfExpanded: true,
+ });
+ const earlier = buildThreadListV2ListItems({
+ items: snoozedLayout.items,
+ pendingTasks: [],
+ snoozedShelfExpanded: true,
+ snoozeLabelNow: NOW,
+ });
+ const later = buildThreadListV2ListItems({
+ items: snoozedLayout.items,
+ pendingTasks: [],
+ snoozedShelfExpanded: true,
+ snoozeLabelNow: isoAt(BASE_MS + MINUTE_MS),
+ });
+ const rows = (items: ThreadListV2ListItem[]) => {
+ const row = items.find((item) => item.key === `v2-thread:${environmentId}:wake`);
+ expect(row?.type).toBe("v2-thread");
+ if (row?.type !== "v2-thread") throw new Error("missing snoozed row");
+ return row;
+ };
+ expect(rows(earlier).snoozeWakeLabelText).toBe("2h");
+ expect(rows(later).snoozeWakeLabelText).toBe("1h");
+ expect(threadListV2ListItemsAreEqual(rows(earlier), rows(later))).toBe(false);
+ });
+
+ it("notices shelf count, expansion, and loading-disabled changes", () => {
+ const shelf = {
+ type: "v2-settled-shelf",
+ key: "v2-settled-shelf",
+ count: 2,
+ expanded: true,
+ disabled: false,
+ } as const;
+ expect(threadListV2ListItemsAreEqual(shelf, { ...shelf })).toBe(true);
+ expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, count: 3 })).toBe(false);
+ expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, expanded: false })).toBe(false);
+ // A recycled cell ignores the render closure, so the shelf header's
+ // preference-loading disabled state has to ride on the item too.
+ expect(threadListV2ListItemsAreEqual(shelf, { ...shelf, disabled: true })).toBe(false);
+ });
+
+ it("treats different item kinds as unequal", () => {
+ const built = build();
+ const threadItem = built.find((item) => item.type === "v2-thread")!;
+ const pendingItem = built.find((item) => item.type === "v2-pending")!;
+ expect(threadListV2ListItemsAreEqual(threadItem, pendingItem)).toBe(false);
+ });
+
+ it("notices a trailing-divider flip caused by a neighbour change", () => {
+ const threadA = makeThread({ id: ThreadId.make("flip-a"), title: "flip a" });
+ const threadB = makeThread({ id: ThreadId.make("flip-b"), title: "flip b" });
+ const bare = buildThreadListV2ListItems({
+ items: buildThreadListV2Items({
+ threads: [threadA, threadB],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ }).items,
+ pendingTasks: [],
+ snoozeLabelNow: NOW,
+ });
+ // The same shells with B settled: A now sits above the Settled section
+ // rule instead of another row, so A's hairline must flip through the
+ // recycled equality — its own shell reference never changed.
+ const settledB = makeThread({
+ id: ThreadId.make("flip-b"),
+ title: "flip b",
+ settledOverride: "settled",
+ settledAt: NOW,
+ });
+ const withSettled = buildThreadListV2ListItems({
+ items: buildThreadListV2Items({
+ threads: [threadA, settledB],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ }).items,
+ pendingTasks: [],
+ settledCount: 1,
+ settledShelfHeaderIndex: 1,
+ snoozeLabelNow: NOW,
+ });
+ const firstA = bare[0]!;
+ const secondA = withSettled[0]!;
+ expect(firstA.type === "v2-thread" && firstA.showTrailingDivider).toBe(true);
+ expect(secondA.type === "v2-thread" && secondA.showTrailingDivider).toBe(false);
+ expect(threadListV2ListItemsAreEqual(firstA, secondA)).toBe(false);
+ });
+});
+
+describe("isThreadListV2ListItem", () => {
+ it("narrows the v2 kinds and rejects the legacy discriminators", () => {
+ expect(isThreadListV2ListItem({ type: "v2-thread" })).toBe(true);
+ expect(isThreadListV2ListItem({ type: "v2-pending" })).toBe(true);
+ expect(isThreadListV2ListItem({ type: "v2-snoozed-shelf" })).toBe(true);
+ expect(isThreadListV2ListItem({ type: "v2-settled-shelf" })).toBe(true);
+ expect(isThreadListV2ListItem({ type: "thread" })).toBe(false);
+ expect(isThreadListV2ListItem({ type: "v2-show-more" })).toBe(false);
+ });
+});
+
+describe("buildThreadListV2ListItems clock scoping", () => {
+ const allEnvironments = new Set([environmentId]);
+ const tickQueued = () => [makePendingTask("tick-queued")];
+
+ it("carries the snooze menu clock on every row whose swipe menu offers presets", () => {
+ const threads = buildTickThreads();
+ const items = buildTickList(Object.values(threads), BASE_MS, tickQueued(), {
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const byKey = itemsByThreadKey(items);
+ const ready = byKey.get(`v2-thread:${environmentId}:tick-ready`)!;
+ const approval = byKey.get(`v2-thread:${environmentId}:tick-approval`)!;
+ const settled = byKey.get(`v2-thread:${environmentId}:tick-settled`)!;
+ const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!;
+ expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBe(NOW);
+ // The swipe-revealed snooze action exists on slim rows too (the variant
+ // only swaps the primary action), so settled rows need the fresh clock.
+ expect(settled.type === "v2-thread" && settled.snoozePresetMinute).toBe(NOW);
+ // Approval rows are never snoozable; snoozed rows only offer Wake.
+ expect(approval.type === "v2-thread" && approval.snoozePresetMinute).toBeUndefined();
+ expect(snoozed.type === "v2-thread" && snoozed.snoozePresetMinute).toBeUndefined();
+ });
+
+ it("keeps the snooze menu clock off rows on servers without the capability", () => {
+ const threads = buildTickThreads();
+ const items = buildTickList([threads.ready], BASE_MS, [], {
+ snoozeEnvironmentIds: new Set(),
+ });
+ const ready = items.find((item) => item.type === "v2-thread")!;
+ expect(ready.type === "v2-thread" && ready.snoozePresetMinute).toBeUndefined();
+ });
+
+ it("blanks the precomputed time for rows that render a label instead", () => {
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(BASE_MS);
+ const working = makeThread({
+ id: ThreadId.make("tick-working"),
+ title: "tick working",
+ latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ session: runningSession("tick-working"),
+ });
+ const threads = buildTickThreads();
+ const items = buildTickList(
+ [threads.ready, working, threads.settled, threads.snoozed],
+ BASE_MS,
+ tickQueued(),
+ );
+ const byKey = itemsByThreadKey(items);
+ const label = (key: string) => {
+ const item = byKey.get(key)!;
+ return item.type === "v2-thread" ? item.timeLabel : "";
+ };
+ // Ready cards and settled/snoozed slim rows draw a time; the wake
+ // countdown outranks the time on snoozed rows; cards with a status
+ // label never draw one.
+ expect(label(`v2-thread:${environmentId}:tick-ready`)).toBe("5m");
+ expect(label(`v2-thread:${environmentId}:tick-working`)).toBe("");
+ expect(label(`v2-thread:${environmentId}:tick-settled`)).toBe("3d");
+ expect(label(`v2-thread:${environmentId}:tick-snoozed`)).toBe("");
+ const snoozed = byKey.get(`v2-thread:${environmentId}:tick-snoozed`)!;
+ expect(snoozed.type === "v2-thread" && snoozed.snoozeWakeLabelText).toBe("2h");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe("thread list v2 minute tick invalidation", () => {
+ it("only invalidates rows whose clock-driven content moved", () => {
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(BASE_MS);
+ const threads = buildTickThreads();
+ const shellOrder = [threads.ready, threads.approval, threads.settled, threads.snoozed];
+ // One queued-task reference shared by both builds: the store hands the
+ // list the same pending-task objects between rebuilds.
+ const pendingTasks = [makePendingTask("tick-queued")];
+ const atStart = buildTickList(shellOrder, BASE_MS, pendingTasks);
+ vi.setSystemTime(BASE_MS + MINUTE_MS);
+ const atNextMinute = buildTickList(shellOrder, BASE_MS + MINUTE_MS, pendingTasks);
+
+ expect(atStart.length).toBe(atNextMinute.length);
+ const invalidated: string[] = [];
+ for (let index = 0; index < atStart.length; index += 1) {
+ if (!threadListV2ListItemsAreEqual(atStart[index]!, atNextMinute[index]!)) {
+ invalidated.push(atStart[index]!.key);
+ }
+ }
+ // The ready row draws a minute-granular time and carries the snooze
+ // menu, and the settled slim row's swipe-revealed snooze menu shows
+ // preset times too, so both rows' menu content moved. Every other row
+ // — the approval card (status label, never snoozable), the snoozed
+ // shelf row ("2h" unchanged, Wake only), the shelf headers, and the
+ // queued row — survives the tick untouched.
+ expect(invalidated).toEqual([
+ `v2-thread:${environmentId}:tick-ready`,
+ `v2-thread:${environmentId}:tick-settled`,
+ ]);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("keeps hour-granularity rows stable across a minute tick when they carry no snooze menu", () => {
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(BASE_MS);
+ const staleReady = makeThread({
+ id: ThreadId.make("tick-stale"),
+ title: "tick stale",
+ latestUserMessageAt: isoAt(BASE_MS - 3 * 60 * MINUTE_MS),
+ });
+ const atStart = buildTickList([staleReady], BASE_MS, [], {
+ snoozeEnvironmentIds: new Set(),
+ });
+ vi.setSystemTime(BASE_MS + MINUTE_MS);
+ const atNextMinute = buildTickList([staleReady], BASE_MS + MINUTE_MS, [], {
+ snoozeEnvironmentIds: new Set(),
+ });
+ expect(threadListV2ListItemsAreEqual(atStart[0]!, atNextMinute[0]!)).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("re-renders the snoozed countdown row when the wake label advances", () => {
+ vi.useFakeTimers();
+ try {
+ const wakeAt = BASE_MS + 120 * MINUTE_MS;
+ const snoozed = makeThread({
+ id: ThreadId.make("tick-wake"),
+ title: "tick wake",
+ snoozedAt: isoAt(BASE_MS - MINUTE_MS),
+ snoozedUntil: isoAt(wakeAt),
+ });
+ const wakeKey = `v2-thread:${environmentId}:tick-wake`;
+ const wakeRow = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items).get(wakeKey)!;
+ vi.setSystemTime(BASE_MS);
+ const atStart = buildTickList([snoozed], BASE_MS, []);
+ vi.setSystemTime(BASE_MS + MINUTE_MS);
+ const stillTwoHours = buildTickList([snoozed], BASE_MS + MINUTE_MS, []);
+ expect(threadListV2ListItemsAreEqual(wakeRow(atStart), wakeRow(stillTwoHours))).toBe(true);
+ // Minutes round up, so the countdown holds "2h" until the remaining
+ // time drops to the hour boundary — and only that row flips when it
+ // finally moves to minute granularity.
+ vi.setSystemTime(BASE_MS + 61 * MINUTE_MS);
+ const oneHour = buildTickList([snoozed], BASE_MS + 61 * MINUTE_MS, []);
+ const oneHourRow = wakeRow(oneHour);
+ expect(oneHourRow.type).toBe("v2-thread");
+ expect(oneHourRow.type === "v2-thread" && oneHourRow.snoozeWakeLabelText).toBe("59m");
+ expect(threadListV2ListItemsAreEqual(wakeRow(stillTwoHours), wakeRow(oneHour))).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
+
+describe("buildThreadListV2ListItems trailing dividers", () => {
+ it("follows the final neighbour order, not the pre-splice blocks", () => {
+ const activeA = makeThread({ id: ThreadId.make("div-a"), title: "a" });
+ const activeB = makeThread({ id: ThreadId.make("div-b"), title: "b" });
+ const layout = buildThreadListV2Items({
+ threads: [activeA, activeB],
+ environmentId: null,
+ searchQuery: "",
+ now: NOW,
+ });
+ const items = buildThreadListV2ListItems({
+ items: layout.items,
+ pendingTasks: [makePendingTask("div-q1"), makePendingTask("div-q2")],
+ snoozeLabelNow: NOW,
+ });
+ const dividers = items.map((item) =>
+ item.type === "v2-thread" || item.type === "v2-pending" ? item.showTrailingDivider : "n/a",
+ );
+ // thread A | thread B | queued 1 | queued 2: consecutive threads keep
+ // their hairlines, the row before the Unsent section rule loses its own,
+ // queued rows divide each other, and the last row has nothing under it.
+ expect(dividers).toEqual([true, false, true, false]);
+ });
+});
+
+describe("buildThreadListV2ListItems row-state stamps", () => {
+ const readyThread = makeThread({
+ id: ThreadId.make("stamp-ready"),
+ title: "stamp ready",
+ latestUserMessageAt: isoAt(BASE_MS - 5 * MINUTE_MS),
+ });
+ const settledThread = makeThread({
+ id: ThreadId.make("stamp-settled"),
+ title: "stamp settled",
+ settledOverride: "settled",
+ settledAt: isoAt(BASE_MS - 3 * 24 * 60 * MINUTE_MS),
+ });
+ const allEnvironments = new Set([environmentId]);
+
+ it("stamps queued outbox messages onto the matching row and notices removal", () => {
+ // An outbox write never touches the thread shell, so the queued icon has
+ // to ride on the item for the recycled cell to ever update it.
+ const queued = buildTickList([readyThread, settledThread], BASE_MS, [], {
+ queuedThreadKeys: new Set([`${environmentId}:stamp-ready`]),
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const plain = buildTickList([readyThread, settledThread], BASE_MS, [], {
+ queuedThreadKeys: new Set(),
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const byKey = (items: ThreadListV2ListItem[]) => itemsByThreadKey(items);
+ const readyQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-ready`)!;
+ const readyPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-ready`)!;
+ const settledQueued = byKey(queued).get(`v2-thread:${environmentId}:stamp-settled`)!;
+ const settledPlain = byKey(plain).get(`v2-thread:${environmentId}:stamp-settled`)!;
+ expect(readyQueued.type === "v2-thread" && readyQueued.hasQueuedMessages).toBe(true);
+ expect(readyPlain.type === "v2-thread" && readyPlain.hasQueuedMessages).toBe(false);
+ expect(threadListV2ListItemsAreEqual(readyQueued, readyPlain)).toBe(false);
+ // The neighbour row is untouched by the outbox change.
+ expect(threadListV2ListItemsAreEqual(settledQueued, settledPlain)).toBe(true);
+ });
+
+ it("notices move-availability changes on card rows without a shell update", () => {
+ const permissive = new Map([
+ [`${environmentId}:stamp-ready`, { canMoveUp: true, canMoveDown: true }],
+ [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }],
+ ]);
+ const blocked = new Map([
+ [`${environmentId}:stamp-settled`, { canMoveUp: true, canMoveDown: true }],
+ ]);
+ const open = buildTickList([readyThread, settledThread], BASE_MS, [], {
+ moveAvailability: permissive,
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const closed = buildTickList([readyThread, settledThread], BASE_MS, [], {
+ moveAvailability: blocked,
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const readyOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-ready`)!;
+ const readyClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-ready`)!;
+ expect(readyOpen.type === "v2-thread" && readyOpen.canMoveUp).toBe(true);
+ expect(readyClosed.type === "v2-thread" && readyClosed.canMoveUp).toBe(false);
+ expect(threadListV2ListItemsAreEqual(readyOpen, readyClosed)).toBe(false);
+ // Slim rows never carry the move actions, so availability is inert there.
+ const settledOpen = itemsByThreadKey(open).get(`v2-thread:${environmentId}:stamp-settled`)!;
+ const settledClosed = itemsByThreadKey(closed).get(`v2-thread:${environmentId}:stamp-settled`)!;
+ expect(settledOpen.type === "v2-thread" && settledOpen.canMoveUp).toBe(false);
+ expect(threadListV2ListItemsAreEqual(settledOpen, settledClosed)).toBe(true);
+ });
+
+ it("keeps the settled slim row's swipe snooze menu fresh across a tick", () => {
+ vi.useFakeTimers();
+ try {
+ vi.setSystemTime(BASE_MS);
+ const atStart = buildTickList([settledThread], BASE_MS, [], {
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ vi.setSystemTime(BASE_MS + MINUTE_MS);
+ const atNextMinute = buildTickList([settledThread], BASE_MS + MINUTE_MS, [], {
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const rowAtStart = atStart.find((item) => item.type === "v2-thread")!;
+ const rowAtNext = atNextMinute.find((item) => item.type === "v2-thread")!;
+ // The swipe-revealed secondary action carries the snooze preset menu on
+ // slim rows; its minute clock must move, or the displayed wake times
+ // drift while the row is recycled-stable.
+ expect(rowAtStart.type === "v2-thread" && rowAtStart.item.variant).toBe("slim");
+ expect(rowAtStart.type === "v2-thread" && rowAtStart.snoozePresetMinute).toBe(isoAt(BASE_MS));
+ expect(rowAtNext.type === "v2-thread" && rowAtNext.snoozePresetMinute).toBe(
+ isoAt(BASE_MS + MINUTE_MS),
+ );
+ expect(threadListV2ListItemsAreEqual(rowAtStart, rowAtNext)).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stamps the shelf loading-disabled state so recycled headers refresh", () => {
+ const loading = buildTickList([settledThread], BASE_MS, [], {
+ shelfPreferencesLoading: true,
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const loaded = buildTickList([settledThread], BASE_MS, [], {
+ shelfPreferencesLoading: false,
+ snoozeEnvironmentIds: allEnvironments,
+ });
+ const shelfLoading = loading.find((item) => item.type === "v2-settled-shelf")!;
+ const shelfLoaded = loaded.find((item) => item.type === "v2-settled-shelf")!;
+ expect(shelfLoading.type === "v2-settled-shelf" && shelfLoading.disabled).toBe(true);
+ expect(shelfLoaded.type === "v2-settled-shelf" && shelfLoaded.disabled).toBe(false);
+ expect(threadListV2ListItemsAreEqual(shelfLoading, shelfLoaded)).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index e1cb8e9ece7f0c5c5d1aa16695407623dd1702aa..d2f2d4539059be943e75ae1ed48f9430e0f91cee 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -1,5 +1,6 @@
import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
import {
+ canSnooze,
effectiveSnoozed,
hasQueuedTurnStart,
QUEUED_TURN_START_GRACE_MS,
@@ -16,6 +17,9 @@ import {
} from "@t3tools/client-runtime/state/thread-sort";
import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+import type { ThreadMoveAvailability } from "./threadOrder";
+
+import { relativeTime } from "../../lib/time";
import type { PendingNewTask } from "../../state/use-pending-new-tasks";
import {
@@ -244,6 +248,28 @@ export interface ThreadListV2ThreadListItem {
readonly item: ThreadListV2Item;
/** Precomputed so recycled-list equality can see a minute-tick change. */
readonly snoozeWakeLabelText: string | undefined;
+ /** Row timestamp precomputed against the parent clock so the recycler's
+ equality only sees a change on rows that actually draw a time. Blank
+ while the row renders a status label or the wake countdown instead. */
+ readonly timeLabel: string;
+ /** Minute clock feeding the row's native snooze menu, carried only on rows
+ whose menu holds snooze presets (capability-gated cards the user may
+ snooze). Keeping it out of the list's `extraData` means the minute tick
+ re-renders just these rows instead of every visible one. */
+ readonly snoozePresetMinute: string | undefined;
+ /** Inset hairline drawn under the row. Precomputed from the final order so
+ a neighbour change (e.g. the queued block appearing) updates the row
+ through recycled-list equality instead of leaving a stale divider. */
+ readonly showTrailingDivider: boolean;
+ /** A message for this thread is waiting in the outbox. Carried on the item
+ so an outbox write (which never touches the thread shell) reaches the
+ row through recycled-list equality instead of leaving a stale icon. */
+ readonly hasQueuedMessages: boolean;
+ /** Move up/down availability in the row's card section. Carried on the item
+ for the same reason: a reorder in flight (or its commit) changes menu
+ availability without changing any shell. */
+ readonly canMoveUp: boolean;
+ readonly canMoveDown: boolean;
}
export interface ThreadListV2PendingListItem {
@@ -252,6 +278,9 @@ export interface ThreadListV2PendingListItem {
readonly pendingTask: PendingNewTask;
/** First queued row after the active block draws the PENDING divider. */
readonly showPendingDivider: boolean;
+ /** Same rule as the thread rows: a hairline unless the next row carries its
+ own section rule or none follows. */
+ readonly showTrailingDivider: boolean;
}
export interface ThreadListV2SnoozedShelfListItem {
@@ -259,6 +288,11 @@ export interface ThreadListV2SnoozedShelfListItem {
readonly key: "v2-snoozed-shelf";
readonly count: number;
readonly expanded: boolean;
+ /** Shelf preferences still loading: the toggle is disabled until they
+ arrive. Carried on the item because a recycled cell ignores the render
+ closure — without it the header would stay visibly disabled (or enabled
+ too early) after the preference load lands. */
+ readonly disabled: boolean;
}
export interface ThreadListV2SettledShelfListItem {
@@ -266,6 +300,8 @@ export interface ThreadListV2SettledShelfListItem {
readonly key: "v2-settled-shelf";
readonly count: number;
readonly expanded: boolean;
+ /** See the snoozed shelf header's field. */
+ readonly disabled: boolean;
}
export type ThreadListV2ListItem =
@@ -274,6 +310,90 @@ export type ThreadListV2ListItem =
| ThreadListV2SnoozedShelfListItem
| ThreadListV2SettledShelfListItem;
+/** Narrows a wider list-item union (e.g. the sidebar's legacy + v2 mix) to
+ the v2 item kinds the shared equality understands. */
+export function isThreadListV2ListItem(value: {
+ readonly type: string;
+}): value is ThreadListV2ListItem {
+ return (
+ value.type === "v2-thread" ||
+ value.type === "v2-pending" ||
+ value.type === "v2-snoozed-shelf" ||
+ value.type === "v2-settled-shelf"
+ );
+}
+
+/** Recycled-list equality for the flat v2 list (Home + iPad sidebar).
+ Item objects are rebuilt on every minute tick and every partition run;
+ without this the lists would consider every mounted row changed and
+ re-render all of them (each carrying a swipeable + a PR subscription).
+ The clock-derived fields are per-row precomputed text, so a minute tick
+ only flips equality on rows whose visible text (or snooze menu) actually
+ moved. Thread references are stable across rebuilds. */
+export function threadListV2ListItemsAreEqual(
+ previous: ThreadListV2ListItem,
+ item: ThreadListV2ListItem,
+): boolean {
+ switch (item.type) {
+ case "v2-thread":
+ return (
+ previous.type === "v2-thread" &&
+ previous.key === item.key &&
+ previous.item.thread === item.item.thread &&
+ previous.item.variant === item.item.variant &&
+ previous.item.snoozed === item.item.snoozed &&
+ previous.item.pinned === item.item.pinned &&
+ previous.snoozeWakeLabelText === item.snoozeWakeLabelText &&
+ previous.timeLabel === item.timeLabel &&
+ previous.snoozePresetMinute === item.snoozePresetMinute &&
+ previous.showTrailingDivider === item.showTrailingDivider &&
+ previous.hasQueuedMessages === item.hasQueuedMessages &&
+ previous.canMoveUp === item.canMoveUp &&
+ previous.canMoveDown === item.canMoveDown
+ );
+ case "v2-pending":
+ return (
+ previous.type === "v2-pending" &&
+ previous.key === item.key &&
+ previous.pendingTask === item.pendingTask &&
+ previous.showPendingDivider === item.showPendingDivider &&
+ previous.showTrailingDivider === item.showTrailingDivider
+ );
+ case "v2-snoozed-shelf":
+ return (
+ previous.type === "v2-snoozed-shelf" &&
+ previous.count === item.count &&
+ previous.expanded === item.expanded &&
+ previous.disabled === item.disabled
+ );
+ case "v2-settled-shelf":
+ return (
+ previous.type === "v2-settled-shelf" &&
+ previous.count === item.count &&
+ previous.expanded === item.expanded &&
+ previous.disabled === item.disabled
+ );
+ }
+}
+
+/** The timestamp a row renders when it shows no status label: the settle
+ stamp on settled slim rows, otherwise the latest activity. Blank for
+ status-labelled cards and snoozed rows with a wake countdown — those
+ never draw a time, so their minute tick must not invalidate the cell. */
+function resolveThreadListV2ItemTimeLabel(
+ item: ThreadListV2Item,
+ showSnoozeWakeLabel: boolean,
+): string {
+ const { thread, variant, snoozed } = item;
+ if (showSnoozeWakeLabel) return "";
+ if (variant === "card" && resolveThreadListV2Status(thread) !== "ready") return "";
+ const settledTimestamp =
+ variant === "slim" && !snoozed ? resolveSettledThreadTimestamp(thread) : null;
+ return relativeTime(
+ settledTimestamp ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt,
+ );
+}
+
/**
* Builds the shared mobile order: active → pending → snoozed shelf → settled.
* Pending tasks are waiting rather than asking, and parked work remains
@@ -289,21 +409,64 @@ export function buildThreadListV2ListItems(input: {
readonly settledShelfExpanded?: boolean;
readonly settledShelfHeaderIndex?: number | null;
readonly snoozeLabelNow?: string;
+ /** Environments whose server supports thread.snooze. Rows on other
+ environments never carry the minute clock that feeds the snooze menu.
+ Absent = no gating (tests). */
+ readonly snoozeEnvironmentIds?: ReadonlySet;
+ /** Thread keys (`environmentId:threadId`) with a message waiting in the
+ outbox; stamped onto the matching rows as `hasQueuedMessages`. */
+ readonly queuedThreadKeys?: ReadonlySet;
+ /** Menu availability for Move up/down, keyed by `environmentId:threadId`
+ (only consulted for card rows — slim menus omit the moves). Produced by
+ `computeThreadMoveAvailability` in one pass per section; a recycled cell
+ ignores the render closure, so the stamps ride on the item. Absent =
+ never available (tests). */
+ readonly moveAvailability?: ReadonlyMap;
+ /** True while the shelf expansion preferences are still loading; stamped
+ onto both shelf headers so the disabled state reaches recycled cells. */
+ readonly shelfPreferencesLoading?: boolean;
}): ThreadListV2ListItem[] {
- const threadItems = input.items.map((item): ThreadListV2ListItem => ({
- type: "v2-thread",
- key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
- item,
- snoozeWakeLabelText:
+ const threadItems = input.items.map((item): ThreadListV2ListItem => {
+ const snoozeWakeLabelText =
item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
- : undefined,
- }));
+ : undefined;
+ // The minute clock belongs on the item, not the list's extraData, so the
+ // recycler's equality can confine the per-minute re-render to rows whose
+ // snooze menu actually shows preset times. The swipe-revealed snooze menu
+ // exists on slim rows too (the variant only swaps the primary action),
+ // so the gate follows actual snooze availability, not the variant.
+ const snoozePresetMinute =
+ !item.snoozed &&
+ input.snoozeLabelNow !== undefined &&
+ (input.snoozeEnvironmentIds?.has(item.thread.environmentId) ?? true) &&
+ canSnooze(item.thread, { now: input.snoozeLabelNow })
+ ? input.snoozeLabelNow
+ : undefined;
+ const move =
+ item.variant === "card"
+ ? input.moveAvailability?.get(`${item.thread.environmentId}:${item.thread.id}`)
+ : undefined;
+ return {
+ type: "v2-thread",
+ key: `v2-thread:${item.thread.environmentId}:${item.thread.id}`,
+ item,
+ snoozeWakeLabelText,
+ timeLabel: resolveThreadListV2ItemTimeLabel(item, snoozeWakeLabelText !== undefined),
+ snoozePresetMinute,
+ showTrailingDivider: false,
+ hasQueuedMessages:
+ input.queuedThreadKeys?.has(`${item.thread.environmentId}:${item.thread.id}`) === true,
+ canMoveUp: move?.canMoveUp === true,
+ canMoveDown: move?.canMoveDown === true,
+ };
+ });
const pendingItems = input.pendingTasks.map((pendingTask, index): ThreadListV2ListItem => ({
type: "v2-pending",
key: `v2-${pendingTask.key}`,
pendingTask,
showPendingDivider: index === 0,
+ showTrailingDivider: false,
}));
const snoozedCount = input.snoozedCount ?? 0;
const snoozedShelfHeaderIndex = input.snoozedShelfHeaderIndex ?? null;
@@ -312,12 +475,14 @@ export function buildThreadListV2ListItems(input: {
const activeEnd = snoozedShelfHeaderIndex ?? settledShelfHeaderIndex ?? threadItems.length;
const snoozedEnd = settledShelfHeaderIndex ?? threadItems.length;
const result: ThreadListV2ListItem[] = [...threadItems.slice(0, activeEnd), ...pendingItems];
+ const shelfDisabled = input.shelfPreferencesLoading === true;
if (snoozedShelfHeaderIndex !== null && snoozedCount > 0) {
result.push({
type: "v2-snoozed-shelf",
key: "v2-snoozed-shelf",
count: snoozedCount,
expanded: input.snoozedShelfExpanded === true,
+ disabled: shelfDisabled,
});
result.push(...threadItems.slice(snoozedShelfHeaderIndex, snoozedEnd));
}
@@ -327,10 +492,21 @@ export function buildThreadListV2ListItems(input: {
key: "v2-settled-shelf",
count: settledCount,
expanded: input.settledShelfExpanded !== false,
+ disabled: shelfDisabled,
});
result.push(...threadItems.slice(settledShelfHeaderIndex));
}
- return result;
+ // Hairlines depend on the final neighbour, so they are stamped after the
+ // splice: a recycled cell only re-renders when its divider actually flips.
+ return result.map((entry, index) => {
+ if (entry.type !== "v2-thread" && entry.type !== "v2-pending") return entry;
+ const next = result[index + 1];
+ const showTrailingDivider =
+ next?.type === "v2-thread" || (next?.type === "v2-pending" && !next.showPendingDivider);
+ return showTrailingDivider === entry.showTrailingDivider
+ ? entry
+ : { ...entry, showTrailingDivider };
+ });
}
/**
diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts
index c71c1040bf405851c4926b52bf4759eab21dd9b1..570251f4a985d733f33fa9b7c8dd95001c92606b 100644
--- a/apps/mobile/src/features/threads/threadOrder.ts
+++ b/apps/mobile/src/features/threads/threadOrder.ts
@@ -1,5 +1,9 @@
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
-import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort";
+import {
+ generateSpreadPinOrderKeys,
+ pinOrderKeyBetween,
+ planPinnedReorder,
+} from "@t3tools/client-runtime/state/thread-sort";
import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
import type { EnvironmentId } from "@t3tools/contracts";
@@ -42,7 +46,7 @@ export function threadOrderAfterMove(
return result;
}
-type OrderRow = Pick<
+export type OrderRow = Pick<
EnvironmentThreadShell,
| "id"
| "environmentId"
@@ -106,6 +110,143 @@ export function createThreadMovePlanner(input: {
};
}
+export interface ThreadMoveAvailability {
+ readonly canMoveUp: boolean;
+ readonly canMoveDown: boolean;
+}
+
+/**
+ * Batch form of "call `createThreadMovePlanner` once per card": one pass over
+ * the section answers up/down availability for every ordered row, so list
+ * construction stays linear instead of one full planner probe (array copies,
+ * hidden-key rescans) per row on the minute-tick rebuild path. The mirror of
+ * the planner's plan rules lives in the body below; the reference-parity test
+ * pins them row-by-row, including adversarial keys and hidden reservations.
+ */
+export function computeThreadMoveAvailability(input: {
+ readonly ordered: readonly OrderRow[];
+ readonly allThreads?: readonly OrderRow[];
+ readonly section: PendingThreadOrder["section"];
+ readonly reorderableEnvironmentIds: ReadonlySet;
+ readonly pendingOrder?: PendingThreadOrder | null;
+}): Map {
+ const result = new Map();
+ // A reorder in flight locks the whole list until its receipt lands.
+ if (input.pendingOrder != null) return result;
+ const rows = input.ordered;
+ const orderedIds = rows.map(rowId);
+ const indexById = new Map(orderedIds.map((id, index) => [id, index] as const));
+ const keysById = new Map(
+ (input.allThreads ?? input.ordered).map(
+ (row) => [rowId(row), rowOrder(row, input.section).key] as const,
+ ),
+ );
+ const writableIds = new Set(
+ (input.allThreads ?? input.ordered)
+ .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId))
+ .map(rowId),
+ );
+ const visibleIds = new Set(orderedIds);
+ const reservedKeys = new Set(
+ [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])),
+ );
+ // Mirror of `planPinnedReorder` for adjacent swaps, hoisted so every row is
+ // answered in O(1) amortized instead of one planner probe per row:
+ //
+ // Fast path (both neighbors keyed): the fresh key between the landing
+ // neighbors, walking forward while hidden reserved keys block it. The walk
+ // depends only on the neighbor key pair - each adjacency is probed by at
+ // most two rows (down of the left member, up of the right member) - so the
+ // memo keeps even a fully adversarial reserved-key layout linear per build.
+ //
+ // Rewrite path (keyless/unusable neighbor, or key space exhausted): fresh
+ // spread keys for every row; only positions whose current key differs get
+ // written. A swap permutes two rows without changing the multiset, so the
+ // assignment set differs from the unswapped baseline at at most those two
+ // positions, and mismatch/writability tallies computed once per section
+ // answer each row with a constant-size delta.
+ const midpoints = new Map();
+ const fastPathKey = (beforeKey: string | null, afterKey: string | null): string | null => {
+ const memoKey = `${beforeKey ?? ""}\u0000${afterKey ?? ""}`;
+ const cached = midpoints.get(memoKey);
+ if (cached !== undefined) return cached;
+ let key = pinOrderKeyBetween(beforeKey, afterKey);
+ while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey);
+ midpoints.set(memoKey, key);
+ return key;
+ };
+ const spreadKeys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size)
+ .filter((key) => !reservedKeys.has(key))
+ .slice(0, orderedIds.length);
+ const currentKeys = orderedIds.map((id) => keysById.get(id) ?? null);
+ const writableRow = orderedIds.map((id) => writableIds.has(id));
+ let baselineWrites = 0;
+ let baselineUnwritableWrites = 0;
+ for (let position = 0; position < orderedIds.length; position += 1) {
+ if (currentKeys[position] === spreadKeys[position]) continue;
+ baselineWrites += 1;
+ if (!writableRow[position]) baselineUnwritableWrites += 1;
+ }
+ // `movedId` swaps with its neighbor; ids at the two swapped positions change,
+ // so only their tally contributions are recomputed.
+ const rewriteViable = (index: number): boolean => {
+ let writes = baselineWrites;
+ let unwritableWrites = baselineUnwritableWrites;
+ for (const position of [index, index + 1]) {
+ const other = position === index ? index + 1 : index;
+ if (currentKeys[position] !== spreadKeys[position]) {
+ writes -= 1;
+ if (!writableRow[position]) unwritableWrites -= 1;
+ }
+ // After the swap this position holds the row that was at `other`.
+ if (currentKeys[other] !== spreadKeys[position]) {
+ writes += 1;
+ if (!writableRow[other]) unwritableWrites += 1;
+ }
+ }
+ return writes > 0 && unwritableWrites === 0;
+ };
+ for (const row of rows) {
+ const movedId = rowId(row);
+ const denied = { canMoveUp: false, canMoveDown: false };
+ if (!writableIds.has(movedId)) {
+ result.set(movedId, denied);
+ continue;
+ }
+ const index = indexById.get(movedId);
+ if (index === undefined) {
+ result.set(movedId, denied);
+ continue;
+ }
+ const adjacentAvailable = (towardUp: boolean): boolean => {
+ const shifted = index + (towardUp ? -1 : 1);
+ if (shifted < 0 || shifted >= orderedIds.length) return false;
+ // The swap exchanges the row with its neighbor; afterwards the moved row
+ // sits at `shifted` between `beforeIndex` and `afterIndex` of the OLD
+ // order: moving up it lands between old(index-2) and old(index-1),
+ // moving down between old(index+1) and old(index+2).
+ const beforeIndex = towardUp ? index - 2 : index + 1;
+ const afterIndex = towardUp ? index - 1 : index + 2;
+ const beforeId = beforeIndex < 0 ? null : (orderedIds[beforeIndex] ?? null);
+ const afterId = afterIndex >= orderedIds.length ? null : (orderedIds[afterIndex] ?? null);
+ const beforeKey = beforeId === null ? null : (keysById.get(beforeId) ?? null);
+ const afterKey = afterId === null ? null : (keysById.get(afterId) ?? null);
+ if ((beforeId === null || beforeKey != null) && (afterId === null || afterKey != null)) {
+ const key = fastPathKey(beforeKey, afterKey);
+ // A fresh key is a single-write plan for the (writable) moved row.
+ if (key !== null) return true;
+ }
+ // Keyless neighbor or exhausted key space: the section rewrite runs.
+ return rewriteViable(Math.min(index, shifted));
+ };
+ result.set(movedId, {
+ canMoveUp: adjacentAvailable(true),
+ canMoveDown: adjacentAvailable(false),
+ });
+ }
+ return result;
+}
+
export function createPendingThreadOrder(input: {
readonly section: PendingThreadOrder["section"];
readonly ordered: readonly OrderRow[];
diff --git a/apps/mobile/src/features/threads/threadOrderAvailability.test.ts b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..765eff4cf61ed5122ad4ab1f67ecbd5ffcf5de5c
--- /dev/null
+++ b/apps/mobile/src/features/threads/threadOrderAvailability.test.ts
@@ -0,0 +1,265 @@
+import { describe, expect, it } from "vite-plus/test";
+import {
+ generateSpreadPinOrderKeys,
+ pinOrderKeyBetween,
+} from "@t3tools/client-runtime/state/thread-sort";
+import type { EnvironmentId } from "@t3tools/contracts";
+
+import {
+ computeThreadMoveAvailability,
+ createThreadMovePlanner,
+ type OrderRow,
+} from "./threadOrder";
+
+// The batch availability computation must answer exactly what the reference
+// per-move planner answers; these tests randomize sections that stress every
+// branch (keyless rows, hidden keys, non-writable rows, adversarial keys).
+
+function makeRow(id: string, environmentId: string, key: string | null, pinned: boolean): OrderRow {
+ return {
+ id: id as OrderRow["id"],
+ environmentId: environmentId as OrderRow["environmentId"],
+ pinOrderKey: pinned ? key : null,
+ activeOrderKey: pinned ? null : key,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ unsettledAt: "2026-01-01T00:00:00.000Z",
+ pinnedAt: pinned ? "2026-01-01T00:00:00.000Z" : null,
+ };
+}
+
+function makeRng(seed: number) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1_664_525 + 1_013_904_223) >>> 0;
+ return state / 0x1_0000_0000;
+ };
+}
+
+const KEY_POOL = [
+ "a",
+ "b",
+ "ba",
+ "bb",
+ "c",
+ "d",
+ "ca",
+ "cb",
+ "da",
+ "db",
+ "e",
+ "f",
+ "g",
+ "h",
+ "i",
+ "j",
+ "k",
+ "l",
+ "m",
+ "n",
+ "o",
+ "p",
+ "q",
+ "r",
+ "s",
+ "t",
+ "u",
+ "v",
+ "w",
+ "x",
+ "y",
+ "z",
+ null,
+];
+
+function referenceAvailability(
+ ordered: readonly OrderRow[],
+ allThreads: readonly OrderRow[],
+ writable: ReadonlySet,
+) {
+ const planner = createThreadMovePlanner({
+ ordered,
+ allThreads,
+ section: "pinned",
+ reorderableEnvironmentIds: writable,
+ });
+ const answers = new Map();
+ for (const row of ordered) {
+ const movedId = `${row.environmentId}:${row.id}`;
+ answers.set(movedId, {
+ canMoveUp: planner(movedId, "up") !== null,
+ canMoveDown: planner(movedId, "down") !== null,
+ });
+ }
+ return answers;
+}
+
+function randomCase(rng: () => number) {
+ const rowCount = 1 + Math.floor(rng() * 9);
+ // Two environments; "writable" env vs. one lacking the reorder capability.
+ const rows: OrderRow[] = [];
+ for (let index = 0; index < rowCount; index += 1) {
+ const environment = rng() < 0.75 ? "env-w" : "env-x";
+ const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
+ // Ids with colons: a `${environmentId}:${id}` string is not splittable
+ // back into its parts, so batch and planner must agree even here.
+ const id = rng() < 0.4 ? `t:${index}` : `t${index}`;
+ rows.push(makeRow(id, environment, key, true));
+ }
+ // Hidden rows (in allThreads, not in the visible ordered section) may hold
+ // keys that collide with fast-path midpoints.
+ const hidden: OrderRow[] = [];
+ const hiddenCount = Math.floor(rng() * 4);
+ for (let index = 0; index < hiddenCount; index += 1) {
+ const key = KEY_POOL[Math.floor(rng() * KEY_POOL.length)] ?? null;
+ const id = rng() < 0.4 ? `h:${index}` : `h${index}`;
+ hidden.push(makeRow(id, rng() < 0.75 ? "env-w" : "env-x", key, true));
+ }
+ return { ordered: rows, allThreads: [...rows, ...hidden] };
+}
+
+const WRITABLE = new Set(["env-w" as EnvironmentId]);
+
+describe("computeThreadMoveAvailability matches the reference planner", () => {
+ it("agrees across randomized sections (keys, holes, non-writable rows, hidden keys)", () => {
+ for (let seed = 1; seed <= 4_000; seed += 1) {
+ const rng = makeRng(seed);
+ const { ordered, allThreads } = randomCase(rng);
+ const batch = computeThreadMoveAvailability({
+ ordered,
+ allThreads,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ const reference = referenceAvailability(ordered, allThreads, WRITABLE);
+ for (const [id, answer] of reference) {
+ expect(
+ batch.get(id) ?? { canMoveUp: false, canMoveDown: false },
+ `seed ${seed} row ${id} ordered=${ordered
+ .map((row) => `${row.id}:${row.pinOrderKey ?? "-"}:${row.environmentId}`)
+ .join(",")}`,
+ ).toEqual(answer);
+ }
+ }
+ });
+
+ it("locks every row while a pending reorder is in flight", () => {
+ const rows = [makeRow("t0", "env-w", "a", true), makeRow("t1", "env-w", "c", true)];
+ const pending = {
+ section: "pinned" as const,
+ orderedIds: ["env-w:t1", "env-w:t0"],
+ before: new Map(),
+ assignments: new Map(),
+ confirmed: new Set(),
+ commandsComplete: false,
+ };
+ const batch = computeThreadMoveAvailability({
+ ordered: rows,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ pendingOrder: pending,
+ });
+ expect(batch.size).toBe(0);
+ });
+
+ it("allows the fast-path walk past reserved keys and rewrites that skip matching keys (auditor cases)", () => {
+ // Reported parity case: keys ["f","gn",null], middle row NOT writable.
+ // The rewrite assigns the middle row the key it already holds, so the
+ // diff never writes it and the first row's down-move stays available.
+ const rows = [makeRow("t0", "env-w", "f", true), makeRow("t1", "env-x", "gn", true)];
+ const withThird = [...rows, makeRow("t2", "env-w", null, true)];
+ const batch = computeThreadMoveAvailability({
+ ordered: withThird,
+ allThreads: withThird,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ const reference = referenceAvailability(withThird, withThird, WRITABLE);
+ expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
+ expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: true });
+ });
+
+ it("keeps moves available for ids containing colons (composite-id parsing)", () => {
+ // The reported case: environment `env`, ids `thread:1`/`thread:2`. Splitting
+ // the composite id at the last colon yields `env:thread` and falsely locks
+ // both rows; writability must come from the row's own environmentId.
+ const rows = [makeRow("thread:1", "env", "a", true), makeRow("thread:2", "env", "c", true)];
+ const batch = computeThreadMoveAvailability({
+ ordered: rows,
+ section: "pinned",
+ reorderableEnvironmentIds: new Set(["env" as EnvironmentId]),
+ });
+ const reference = referenceAvailability(
+ rows,
+ rows,
+ new Set(["env" as EnvironmentId]),
+ );
+ expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
+ expect(batch.get("env:thread:1")).toEqual({ canMoveUp: false, canMoveDown: true });
+ expect(batch.get("env:thread:2")).toEqual({ canMoveUp: true, canMoveDown: false });
+ });
+
+ it("denies single-row sections on both directions", () => {
+ const rows = [makeRow("t0", "env-w", "a", true)];
+ const batch = computeThreadMoveAvailability({
+ ordered: rows,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ expect(batch.get("env-w:t0")).toEqual({ canMoveUp: false, canMoveDown: false });
+ });
+
+ it("denies rows whose section has non-writable neighbours when the fast path fails", () => {
+ // Keyless neighbors force the section-rewrite fallback; a non-writable
+ // neighbor makes the rewrite illegal for every row in the section.
+ const rows = [
+ makeRow("t0", "env-w", null, true),
+ makeRow("t1", "env-x", "c", true),
+ makeRow("t2", "env-w", "d", true),
+ ];
+ const batch = computeThreadMoveAvailability({
+ ordered: rows,
+ allThreads: rows,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ const reference = referenceAvailability(rows, rows, WRITABLE);
+ expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
+ // Sanity: the middle (non-writable) row is denied on both sides.
+ expect(batch.get("env-x:t1")).toEqual({ canMoveUp: false, canMoveDown: false });
+ });
+
+ it("agrees on a section where every adjacency midpoint is a hidden reserved key", () => {
+ // The worst case for reserved-key collisions: one hidden row holds the
+ // exact midpoint key of every adjacent visible pair, so every probe walks.
+ const visibleKeys = generateSpreadPinOrderKeys(24);
+ const hiddenKeys = visibleKeys
+ .slice(0, -1)
+ .map((key, index) => pinOrderKeyBetween(key, visibleKeys[index + 1]!))
+ .filter((key): key is string => key != null);
+ const ordered = visibleKeys.map((key, index) => makeRow(`v${index}`, "env-w", key, true));
+ const hidden = hiddenKeys.map((key, index) => makeRow(`h${index}`, "env-w", key, true));
+ const allThreads = [...ordered, ...hidden];
+ const batch = computeThreadMoveAvailability({
+ ordered,
+ allThreads,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ const reference = referenceAvailability(ordered, allThreads, WRITABLE);
+ expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
+ });
+
+ it("agrees on an adversarial section of consecutive single-char keys", () => {
+ // Every midpoint between consecutive one-char keys is unrepresentable, so
+ // no row may claim a fast-path plan.
+ const rows = ["a", "b", "c", "d"].map((key, index) => makeRow(`t${index}`, "env-w", key, true));
+ const batch = computeThreadMoveAvailability({
+ ordered: rows,
+ allThreads: rows,
+ section: "pinned",
+ reorderableEnvironmentIds: WRITABLE,
+ });
+ const reference = referenceAvailability(rows, rows, WRITABLE);
+ expect(Object.fromEntries(batch)).toEqual(Object.fromEntries(reference));
+ });
+});