From cbe1a0800dd94a928d56c3d03269b3c394b6a193 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 09:24:25 -0700 Subject: [PATCH 1/6] perf(mobile): reuse chat lists across navigation renders --- .../features/threads/useThreadListV2.test.tsx | 120 +++++++++ .../src/features/threads/useThreadListV2.ts | 233 +++++++++++------- apps/mobile/vite.config.ts | 1 + 3 files changed, 270 insertions(+), 84 deletions(-) create mode 100644 apps/mobile/src/features/threads/useThreadListV2.test.tsx diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx new file mode 100644 index 0000000000000..fbc0c47b479a0 --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -0,0 +1,120 @@ +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { makeThreadShellFixture } from "../../test-fixtures"; +import { useThreadListV2 } from "./useThreadListV2"; + +vi.mock("../../state/olderSettledShelf", () => { + const isListed = () => true; + const unloadedCount = () => 0; + return { + useOlderSettledShelf: () => ({ isListed, unloadedCount }), + useOlderSettledShelfGrowth: () => undefined, + }; +}); + +it("reuses a large thread list across tab renders and updates when its inputs change", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; + const environmentId = EnvironmentId.make("environment-1"); + const otherEnvironmentId = EnvironmentId.make("environment-2"); + const threads = Array.from({ length: 1200 }, (_, index) => + makeThreadShellFixture({ + id: ThreadId.make(`thread-${index}`), + title: `Thread ${index}`, + environmentId: index % 2 === 0 ? environmentId : otherEnvironmentId, + settledOverride: index < 150 ? "active" : "settled", + settledAt: index < 150 ? null : "2026-01-01T00:00:00.000Z", + }), + ); + const environmentIds = new Set([environmentId, otherEnvironmentId]); + const input: Parameters[0] = { + threads, + pendingTasks: [], + environmentId: null, + projectRefs: null, + searchQuery: "", + searchMatches: new Map(), + settlementEnvironmentIds: environmentIds, + snoozeEnvironmentIds: environmentIds, + pinReorderEnvironmentIds: environmentIds, + activeReorderEnvironmentIds: environmentIds, + queuedThreadKeys: new Set(), + settledLimit: 10, + now: "2026-01-02T00:00:00.000Z", + pendingOrder: null, + shelfPreferencesLoaded: true, + workingShelfEnabled: true, + workingShelfExpanded: false, + snoozedShelfExpanded: false, + settledShelfExpanded: true, + selectedThreadKey: null, + }; + const seen: Array> = []; + function Probe(props: { input: typeof input; tab: string }) { + const result = useThreadListV2(props.input); + useLayoutEffect(() => { + seen.push(result); + }); + return
{props.tab}
; + } + + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render()); + const initial = seen.at(-1)!; + expect(initial.layout.items).toHaveLength(160); + expect(initial.layout.settledCount).toBe(1050); + + for (const tab of ["pullRequests", "chats", "settings", "chats"]) { + await act(async () => root.render()); + expect(seen.at(-1)!.layout).toBe(initial.layout); + expect(seen.at(-1)!.items).toBe(initial.items); + } + + await act(async () => root.render()); + expect(seen.at(-1)!.layout.items).toHaveLength(85); + expect(seen.at(-1)!.layout.settledCount).toBe(525); + expect( + seen.at(-1)!.layout.items.every((item) => item.thread.environmentId === environmentId), + ).toBe(true); + + const queuedThreadKeys = new Set([`${environmentId}:thread-150`]); + await act(async () => root.render()); + const queued = seen.at(-1)!; + expect(queued.layout.items).toHaveLength(161); + expect(queued.layout.settledCount).toBe(1049); + expect( + queued.items.find((item) => item.type === "v2-thread" && item.item.thread.id === "thread-150"), + ).toMatchObject({ hasQueuedMessages: true, item: { variant: "card" } }); + + await act(async () => root.render()); + expect(seen.at(-1)!.layout.items).toHaveLength(185); + + const updatedThreads = threads.map((thread) => + thread.id === "thread-0" ? { ...thread, title: "Updated title" } : thread, + ); + await act(async () => + root.render(), + ); + expect( + seen.at(-1)!.layout.items.find((item) => item.thread.id === "thread-0")?.thread.title, + ).toBe("Updated title"); + + await act(async () => + root.render( + , + ), + ); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]); + + await act(async () => + root.render(), + ); + expect(seen.at(-1)!.items).not.toBe(initial.items); +}); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5d..86d17524201bb 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -28,9 +28,8 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet { type LayoutInput = Parameters[0]; /** - * Home's and the thread sidebar's thread list. Its result is one value a screen only reads: - * built inline, the list's inputs stayed open to mutation across the screen's other hooks, so - * the React Compiler cached none of it and every render of the screen re-rendered every row. + * Shelf callbacks prevent compiler caching across the whole derivation, so each stage keeps + * its own memo boundary to preserve the list's identity during unrelated navigation renders. */ export function useThreadListV2(input: { readonly threads: LayoutInput["threads"]; @@ -69,101 +68,167 @@ export function useThreadListV2(input: { // 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 sectionAvailability = (section: "pinned" | "active") => - computeThreadMoveAvailability({ - allThreads: threads, - section, - pendingOrder: input.pendingOrder, - reorderableEnvironmentIds: - section === "pinned" ? input.pinReorderEnvironmentIds : input.activeReorderEnvironmentIds, - ordered: getThreadListV2OrderedSection({ - threads, + const moveAvailability = useMemo(() => { + const sectionAvailability = (section: "pinned" | "active") => + computeThreadMoveAvailability({ + allThreads: threads, section, pendingOrder: input.pendingOrder, - now: input.now, - settlementEnvironmentIds: input.settlementEnvironmentIds, - snoozeEnvironmentIds: input.snoozeEnvironmentIds, - queuedThreadKeys: input.queuedThreadKeys, - }), - }); - const moveAvailability = new Map([ - ...sectionAvailability("pinned"), - ...(workingShelfEnabled ? [] : sectionAvailability("active")), + reorderableEnvironmentIds: + section === "pinned" ? input.pinReorderEnvironmentIds : input.activeReorderEnvironmentIds, + ordered: getThreadListV2OrderedSection({ + threads, + section, + pendingOrder: input.pendingOrder, + now: input.now, + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + }), + }); + return new Map([ + ...sectionAvailability("pinned"), + ...(workingShelfEnabled ? [] : sectionAvailability("active")), + ]); + }, [ + threads, + input.pendingOrder, + input.pinReorderEnvironmentIds, + input.activeReorderEnvironmentIds, + input.now, + input.settlementEnvironmentIds, + input.snoozeEnvironmentIds, + input.queuedThreadKeys, + workingShelfEnabled, ]); // Older settled threads load in pages; the shelf lists them only as far as they are loaded. - const scopes = olderSettledScopes(input); + const scopes = useMemo( + () => + olderSettledScopes({ + environmentId: input.environmentId, + projectRefs: input.projectRefs, + settlementEnvironmentIds: input.settlementEnvironmentIds, + }), + [input.environmentId, input.projectRefs, input.settlementEnvironmentIds], + ); const olderSettledShelf = useOlderSettledShelf({ scopes, expanded: input.settledShelfExpanded, // A search lists only matches, so the shelf would page forever; loaded pages stay. visibleCount: input.searchQuery.trim().length === 0 ? input.settledLimit : 0, }); - const layout = buildThreadListV2Items({ - pendingOrder: input.pendingOrder, - threads: threads.filter((thread) => thread.archivedAt === null), - environmentId: input.environmentId, - projectRefs: input.projectRefs, - searchQuery: input.searchQuery, - matchedThreadKeys: new Set(input.searchMatches.keys()), - settlementEnvironmentIds: input.settlementEnvironmentIds, - snoozeEnvironmentIds: input.snoozeEnvironmentIds, - queuedThreadKeys: input.queuedThreadKeys, - settledLimit: input.settledLimit, - now: input.now, - workingShelfEnabled, - workingShelfExpanded: input.workingShelfExpanded, - inboxReturnAt: inbox.returnedAt, - isWorking: inbox.isWorking, - snoozedShelfExpanded: input.snoozedShelfExpanded, - settledShelfExpanded: input.settledShelfExpanded, - selectedThreadKey: input.selectedThreadKey, - isListedSettled: olderSettledShelf.isListed, - unloadedSettledCount: olderSettledShelf.unloadedCount( - threads.filter( - (thread) => - thread.archivedAt === null && - scopes.some( - (scope) => - scope.environmentId === thread.environmentId && - (scope.projectIds === null || scope.projectIds.includes(thread.projectId)), + const { isListed, unloadedCount } = olderSettledShelf; + const layout = useMemo( + () => + buildThreadListV2Items({ + pendingOrder: input.pendingOrder, + threads: threads.filter((thread) => thread.archivedAt === null), + environmentId: input.environmentId, + projectRefs: input.projectRefs, + searchQuery: input.searchQuery, + matchedThreadKeys: new Set(input.searchMatches.keys()), + settlementEnvironmentIds: input.settlementEnvironmentIds, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + settledLimit: input.settledLimit, + now: input.now, + workingShelfEnabled, + workingShelfExpanded: input.workingShelfExpanded, + inboxReturnAt: inbox.returnedAt, + isWorking: inbox.isWorking, + snoozedShelfExpanded: input.snoozedShelfExpanded, + settledShelfExpanded: input.settledShelfExpanded, + selectedThreadKey: input.selectedThreadKey, + isListedSettled: isListed, + unloadedSettledCount: unloadedCount( + threads.filter( + (thread) => + thread.archivedAt === null && + scopes.some( + (scope) => + scope.environmentId === thread.environmentId && + (scope.projectIds === null || scope.projectIds.includes(thread.projectId)), + ), ), - ), - ), - }); + ), + }), + [ + input.pendingOrder, + threads, + input.environmentId, + input.projectRefs, + input.searchQuery, + input.searchMatches, + input.settlementEnvironmentIds, + input.snoozeEnvironmentIds, + input.queuedThreadKeys, + input.settledLimit, + input.now, + workingShelfEnabled, + input.workingShelfExpanded, + inbox.returnedAt, + inbox.isWorking, + input.snoozedShelfExpanded, + input.settledShelfExpanded, + input.selectedThreadKey, + isListed, + unloadedCount, + scopes, + ], + ); useOlderSettledShelfGrowth(olderSettledShelf, layout.listedSettledThreads); const contentOnlyThreadKeys = useKeySet([...layout.contentOnlyThreadKeys].sort().join("\n")); - const scopedProjectKeys = - input.projectRefs == null - ? null - : new Set(input.projectRefs.map((ref) => scopedProjectKey(ref.environmentId, ref.projectId))); - const searchQuery = input.searchQuery.trim().toLocaleLowerCase(); - const pendingTasks = input.pendingTasks.filter( - (pendingTask) => - (input.environmentId === null || pendingTask.environmentId === input.environmentId) && - (scopedProjectKeys === null || - scopedProjectKeys.has( - scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), - )) && - (searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)), + const pendingTasks = useMemo(() => { + const scopedProjectKeys = + input.projectRefs == null + ? null + : new Set( + input.projectRefs.map((ref) => scopedProjectKey(ref.environmentId, ref.projectId)), + ); + const searchQuery = input.searchQuery.trim().toLocaleLowerCase(); + return input.pendingTasks.filter( + (pendingTask) => + (input.environmentId === null || pendingTask.environmentId === input.environmentId) && + (scopedProjectKeys === null || + scopedProjectKeys.has( + scopedProjectKey(pendingTask.environmentId, pendingTask.projectId), + )) && + (searchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(searchQuery)), + ); + }, [input.projectRefs, input.searchQuery, input.pendingTasks, input.environmentId]); + const items = useMemo( + () => + buildThreadListV2ListItems({ + items: layout.items, + pendingTasks, + workingCount: layout.workingCount, + workingShelfExpanded: input.workingShelfExpanded, + workingShelfHeaderIndex: layout.workingShelfHeaderIndex, + snoozedCount: layout.snoozedCount, + snoozedShelfExpanded: input.snoozedShelfExpanded, + snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, + settledCount: layout.settledCount, + settledShelfExpanded: input.settledShelfExpanded, + settledShelfHeaderIndex: layout.settledShelfHeaderIndex, + snoozeLabelNow: `${input.now.slice(0, 16)}:00.000Z`, + snoozeEnvironmentIds: input.snoozeEnvironmentIds, + queuedThreadKeys: input.queuedThreadKeys, + moveAvailability, + shelfPreferencesLoading: !input.shelfPreferencesLoaded, + }), + [ + layout, + pendingTasks, + input.workingShelfExpanded, + input.snoozedShelfExpanded, + input.settledShelfExpanded, + input.now, + input.snoozeEnvironmentIds, + input.queuedThreadKeys, + moveAvailability, + input.shelfPreferencesLoaded, + ], ); - const items = buildThreadListV2ListItems({ - items: layout.items, - pendingTasks, - workingCount: layout.workingCount, - workingShelfExpanded: input.workingShelfExpanded, - workingShelfHeaderIndex: layout.workingShelfHeaderIndex, - snoozedCount: layout.snoozedCount, - snoozedShelfExpanded: input.snoozedShelfExpanded, - snoozedShelfHeaderIndex: layout.snoozedShelfHeaderIndex, - settledCount: layout.settledCount, - settledShelfExpanded: input.settledShelfExpanded, - settledShelfHeaderIndex: layout.settledShelfHeaderIndex, - snoozeLabelNow: `${input.now.slice(0, 16)}:00.000Z`, - snoozeEnvironmentIds: input.snoozeEnvironmentIds, - queuedThreadKeys: input.queuedThreadKeys, - moveAvailability, - shelfPreferencesLoading: !input.shelfPreferencesLoaded, - }); return { layout, items, contentOnlyThreadKeys, olderSettledShelf }; } diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6a92a025778d9..477bdddb4e52b 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -29,6 +29,7 @@ const reactTests = [ "src/state/use-selected-thread-git-actions.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", "src/features/threads/use-thread-list-now.test.tsx", + "src/features/threads/useThreadListV2.test.tsx", "src/features/home/home-list-options.test.tsx", "src/features/connection/ConnectionsNewRouteScreen.test.tsx", ]; From b238f41e8038c5f14599840d0eae73a66c20566f Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 13:00:09 -0700 Subject: [PATCH 2/6] fix(mobile): preserve chat caches while tabs are hidden --- apps/mobile/src/features/home/HomeScreen.tsx | 17 ++-- .../features/threads/useThreadListV2.test.tsx | 22 ++++- .../src/state/use-thread-outbox.test.ts | 80 +++++++++++++++++++ apps/mobile/src/state/use-thread-outbox.ts | 2 +- 4 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/state/use-thread-outbox.test.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 1edb92f0d556b..f97fe7f0a1d7f 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -12,7 +12,7 @@ import { import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import { use, useEffect, useRef, useState, type ComponentProps } from "react"; +import { use, useEffect, useMemo, useRef, useState, type ComponentProps } from "react"; import { Platform, ScrollView, @@ -361,11 +361,16 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); - const projectScopes = buildHomeProjectScopes({ - projects: props.projects, - environmentId: props.selectedEnvironmentId, - projectGroupingMode: props.projectGroupingMode, - }); + const { projects, selectedEnvironmentId, projectGroupingMode } = props; + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode, + }), + [projects, selectedEnvironmentId, projectGroupingMode], + ); const hasSearchQuery = props.searchQuery.trim().length > 0; const projectByKey = (() => { const map = new Map(); diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index fbc0c47b479a0..3d94cdddf5902 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -113,8 +113,26 @@ it("reuses a large thread list across tab renders and updates when its inputs ch ); expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]); + const clockThreads = threads.map((thread) => + thread.id === "thread-0" ? { ...thread, snoozedUntil: "2026-01-02T00:00:30.000Z" } : thread, + ); + const clockInput = { ...input, threads: clockThreads, snoozedShelfExpanded: true }; + await act(async () => root.render()); + const beforeClock = seen.at(-1)!; + expect(beforeClock.layout.snoozedCount).toBe(1); + expect(beforeClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe( + true, + ); + await act(async () => root.render()); + expect(seen.at(-1)!.items).toBe(beforeClock.items); + await act(async () => - root.render(), + root.render(), + ); + const afterClock = seen.at(-1)!; + expect(afterClock.items).not.toBe(beforeClock.items); + expect(afterClock.layout.snoozedCount).toBe(0); + expect(afterClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe( + false, ); - expect(seen.at(-1)!.items).not.toBe(initial.items); }); diff --git a/apps/mobile/src/state/use-thread-outbox.test.ts b/apps/mobile/src/state/use-thread-outbox.test.ts new file mode 100644 index 0000000000000..ac4f6542f6752 --- /dev/null +++ b/apps/mobile/src/state/use-thread-outbox.test.ts @@ -0,0 +1,80 @@ +import { CommandId, EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; +import { expect, it, vi } from "vite-plus/test"; + +import type { QueuedThreadMessage } from "./thread-outbox-model"; + +vi.mock("./thread-outbox", async () => { + const { Atom } = await import("effect/reactivity"); + return { + threadOutboxManager: { + queuedMessagesByThreadKeyAtom: Atom.make< + Readonly>> + >({}).pipe(Atom.keepAlive), + }, + }; +}); +vi.mock("./atom-registry", () => ({ appAtomRegistry: undefined })); +vi.mock("./shell", () => ({ environmentShell: {} })); + +import { threadOutboxManager } from "./thread-outbox"; +import { queuedThreadKeysAtom } from "./use-thread-outbox"; + +it("retains queue membership while chat lists are hidden and follows queued messages", () => { + const scheduled = new Set<() => void>(); + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + scheduled.add(task); + return () => { + scheduled.delete(task); + }; + }, + }); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; + const flush = () => { + for (const task of scheduled) { + scheduled.delete(task); + task(); + } + }; + const release = registry.mount(queuedThreadKeysAtom); + const initial = registry.get(queuedThreadKeysAtom); + expect([...initial]).toEqual([]); + release(); + flush(); + expect(registry.get(queuedThreadKeysAtom)).toBe(initial); + + const message: QueuedThreadMessage = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("message-1"), + commandId: CommandId.make("command-1"), + text: "Follow up", + attachments: [], + createdAt: "2026-10-29T12:00:00.000Z", + }; + registry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, { + "environment-1:thread-1": [message], + "environment-1:new-thread": [ + { + ...message, + threadId: ThreadId.make("new-thread"), + creation: { + projectId: ProjectId.make("project-1"), + workspaceMode: "local", + branch: null, + worktreePath: null, + }, + }, + ], + }); + const queued = registry.get(queuedThreadKeysAtom); + expect([...queued]).toEqual(["environment-1:thread-1"]); + const releaseAgain = registry.mount(queuedThreadKeysAtom); + releaseAgain(); + flush(); + expect(registry.get(queuedThreadKeysAtom)).toBe(queued); + + registry.set(threadOutboxManager.queuedMessagesByThreadKeyAtom, {}); + expect([...registry.get(queuedThreadKeysAtom)]).toEqual([]); +}); diff --git a/apps/mobile/src/state/use-thread-outbox.ts b/apps/mobile/src/state/use-thread-outbox.ts index a1128cbd08b30..caffa99f8cfa0 100644 --- a/apps/mobile/src/state/use-thread-outbox.ts +++ b/apps/mobile/src/state/use-thread-outbox.ts @@ -63,7 +63,7 @@ export const queuedThreadKeysAtom = Atom.make((get): ReadonlySet => { } } return keys; -}).pipe(Atom.withLabel("mobile:thread-outbox:queued-thread-keys")); +}).pipe(Atom.keepAlive, Atom.withLabel("mobile:thread-outbox:queued-thread-keys")); export function useQueuedThreadKeys(): ReadonlySet { return useAtomValue(queuedThreadKeysAtom); From 5f0c64207838d65af1426e8bc1637a4b3154eff0 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 14:43:47 -0700 Subject: [PATCH 3/6] test(mobile): cover real settled shelf list reuse --- .../features/threads/useThreadListV2.test.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 3d94cdddf5902..4a042b4293944 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -1,23 +1,29 @@ import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; +import { RegistryContext } from "@effect/atom-react"; +import type { OlderSettledShelfEnvironment } from "@t3tools/client-runtime/state/threads"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { makeThreadShellFixture } from "../../test-fixtures"; import { useThreadListV2 } from "./useThreadListV2"; -vi.mock("../../state/olderSettledShelf", () => { - const isListed = () => true; - const unloadedCount = () => 0; +vi.mock("../../state/threads", async () => { + const { Atom } = await import("effect/reactivity"); + const shelf = Atom.make>(new Map()); return { - useOlderSettledShelf: () => ({ isListed, unloadedCount }), - useOlderSettledShelfGrowth: () => undefined, + olderSettledThreads: { shelfAtom: () => shelf }, + threadFamilies: { mountRows: () => () => {} }, }; }); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); it("reuses a large thread list across tab renders and updates when its inputs change", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; + const registry = AtomRegistry.make(); + using _registry = { [Symbol.dispose]: () => registry.dispose() }; const environmentId = EnvironmentId.make("environment-1"); const otherEnvironmentId = EnvironmentId.make("environment-2"); const threads = Array.from({ length: 1200 }, (_, index) => @@ -54,6 +60,13 @@ it("reuses a large thread list across tab renders and updates when its inputs ch }; const seen: Array> = []; function Probe(props: { input: typeof input; tab: string }) { + return ( + + + + ); + } + function ListProbe(props: { input: typeof input; tab: string }) { const result = useThreadListV2(props.input); useLayoutEffect(() => { seen.push(result); @@ -70,6 +83,10 @@ it("reuses a large thread list across tab renders and updates when its inputs ch for (const tab of ["pullRequests", "chats", "settings", "chats"]) { await act(async () => root.render()); + expect(seen.at(-1)!.olderSettledShelf.isListed).toBe(initial.olderSettledShelf.isListed); + expect(seen.at(-1)!.olderSettledShelf.unloadedCount).toBe( + initial.olderSettledShelf.unloadedCount, + ); expect(seen.at(-1)!.layout).toBe(initial.layout); expect(seen.at(-1)!.items).toBe(initial.items); } From f1faee5d0082317ee46c2ee5d73927f24f6ee04b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 04:48:55 +0000 Subject: [PATCH 4/6] test(mobile): verify chat cache updates through real shelf callbacks --- .../features/threads/useThreadListV2.test.tsx | 87 +++++++++++++++---- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index 4a042b4293944..df978d40b2111 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -2,38 +2,41 @@ import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { RegistryContext } from "@effect/atom-react"; import type { OlderSettledShelfEnvironment } from "@t3tools/client-runtime/state/threads"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { AtomRegistry } from "effect/reactivity"; +import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; +import * as DateTime from "effect/DateTime"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { makeThreadShellFixture } from "../../test-fixtures"; +import { olderSettledThreads } from "../../state/threads"; +import { makeRawThreadShell } from "../../test-fixtures"; import { useThreadListV2 } from "./useThreadListV2"; -vi.mock("../../state/threads", async () => { - const { Atom } = await import("effect/reactivity"); - const shelf = Atom.make>(new Map()); - return { - olderSettledThreads: { shelfAtom: () => shelf }, - threadFamilies: { mountRows: () => () => {} }, - }; -}); +vi.mock("../../state/threads", () => ({ + olderSettledThreads: { shelfAtom: vi.fn() }, + threadFamilies: { mountRows: () => () => {} }, +})); vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: undefined })); it("reuses a large thread list across tab renders and updates when its inputs change", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; + const shelfAtom = Atom.make>(new Map()); + vi.mocked(olderSettledThreads.shelfAtom).mockReturnValue(shelfAtom); const registry = AtomRegistry.make(); using _registry = { [Symbol.dispose]: () => registry.dispose() }; const environmentId = EnvironmentId.make("environment-1"); const otherEnvironmentId = EnvironmentId.make("environment-2"); const threads = Array.from({ length: 1200 }, (_, index) => - makeThreadShellFixture({ - id: ThreadId.make(`thread-${index}`), - title: `Thread ${index}`, - environmentId: index % 2 === 0 ? environmentId : otherEnvironmentId, - settledOverride: index < 150 ? "active" : "settled", - settledAt: index < 150 ? null : "2026-01-01T00:00:00.000Z", - }), + presentThreadShell( + index % 2 === 0 ? environmentId : otherEnvironmentId, + makeRawThreadShell({ + id: ThreadId.make(`thread-${index}`), + title: `Thread ${index}`, + settledOverride: index < 150 ? "active" : "settled", + settledAt: index < 150 ? null : DateTime.makeUnsafe("2026-01-01T00:00:00.000Z"), + }), + ), ); const environmentIds = new Set([environmentId, otherEnvironmentId]); const input: Parameters[0] = { @@ -152,4 +155,52 @@ it("reuses a large thread list across tab renders and updates when its inputs ch expect(afterClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe( false, ); + await act(async () => root.render()); + const before = "2026-01-01T12:00:00.000Z"; + const older = { + before, + beforeMs: Date.parse(before), + counts: new Map([[ProjectId.make("project-test"), 575]]), + }; + await act(async () => { + registry.set( + shelfAtom, + new Map([ + [ + environmentId, + { + older, + pages: { reads: [], frontier: { settledAt: before }, status: "pending" }, + }, + ], + ]), + ); + }); + const loading = seen.at(-1)!; + expect(loading.layout.settledCount).toBe(1100); + expect(loading.layout.listedSettledThreads).toHaveLength(0); + expect(loading.layout.items).toHaveLength(150); + expect(loading.olderSettledShelf.isListed(threads[150]!)).toBe(false); + await act(async () => root.render()); + expect(seen.at(-1)!.items).toBe(loading.items); + + await act(async () => { + registry.set( + shelfAtom, + new Map([ + [ + environmentId, + { + older, + pages: { reads: [], frontier: null, status: "ready" }, + }, + ], + ]), + ); + }); + const loaded = seen.at(-1)!; + expect(loaded.layout.settledCount).toBe(1100); + expect(loaded.layout.items).toHaveLength(160); + expect(loaded.olderSettledShelf.isListed(threads[150]!)).toBe(true); + expect(loaded.items).not.toBe(loading.items); }); From 5cfa1f95b6a1046541a47eebe8fd774ee31ee6de Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 22:01:59 -0700 Subject: [PATCH 5/6] perf(mobile): retain chat layout between snooze deadlines --- .../src/features/threads/threadListV2.ts | 21 ++++++++++ .../features/threads/useThreadListV2.test.tsx | 38 +++++++++++++++++++ .../src/features/threads/useThreadListV2.ts | 17 +++++---- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index ad02dd1192d63..6aa3f4d62732c 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -168,6 +168,27 @@ export function nextSnoozeWakeAfter( return next; } +// Outside search, the clock only changes list membership/order at snooze deadlines. +export function threadListV2LayoutTime(input: { + readonly threads: ReadonlyArray<{ readonly snoozedUntil?: string | null }>; + readonly now: string; + readonly searchQuery: string; +}) { + if (input.searchQuery !== "") return input.now; + const nowMs = Date.parse(input.now); + if (!Number.isFinite(nowMs)) return input.now; + let previousWakeAt: number | undefined; + let nextWakeAt: number | undefined; + for (const thread of input.threads) { + if (thread.snoozedUntil == null) continue; + const wakeAt = Date.parse(thread.snoozedUntil); + if (!Number.isFinite(wakeAt)) continue; + if (wakeAt <= nowMs) previousWakeAt = Math.max(previousWakeAt ?? wakeAt, wakeAt); + else nextWakeAt = Math.min(nextWakeAt ?? wakeAt, wakeAt); + } + return new Date(previousWakeAt ?? (nextWakeAt === undefined ? 0 : nextWakeAt - 1)).toISOString(); +} + export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10; export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25; diff --git a/apps/mobile/src/features/threads/useThreadListV2.test.tsx b/apps/mobile/src/features/threads/useThreadListV2.test.tsx index df978d40b2111..7eac8955efa14 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.test.tsx +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -94,6 +94,14 @@ it("reuses a large thread list across tab renders and updates when its inputs ch expect(seen.at(-1)!.items).toBe(initial.items); } + const nextMinute = { ...input, now: "2026-01-02T00:01:00.000Z" }; + await act(async () => root.render()); + expect(seen.at(-1)!.layout).toBe(initial.layout); + expect(seen.at(-1)!.items).not.toBe(initial.items); + expect(seen.at(-1)!.items.find((item) => item.type === "v2-thread")).toMatchObject({ + snoozePresetMinute: nextMinute.now, + }); + await act(async () => root.render()); expect(seen.at(-1)!.layout.items).toHaveLength(85); expect(seen.at(-1)!.layout.settledCount).toBe(525); @@ -132,6 +140,17 @@ it("reuses a large thread list across tab renders and updates when its inputs ch ), ); expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]); + const search = seen.at(-1)!; + await act(async () => + root.render( + , + ), + ); + expect(seen.at(-1)!.layout).not.toBe(search.layout); + expect(seen.at(-1)!.layout.items.map((item) => item.thread.title)).toEqual(["Updated title"]); const clockThreads = threads.map((thread) => thread.id === "thread-0" ? { ...thread, snoozedUntil: "2026-01-02T00:00:30.000Z" } : thread, @@ -146,6 +165,12 @@ it("reuses a large thread list across tab renders and updates when its inputs ch await act(async () => root.render()); expect(seen.at(-1)!.items).toBe(beforeClock.items); + await act(async () => + root.render(), + ); + expect(seen.at(-1)!.layout).toBe(beforeClock.layout); + expect(seen.at(-1)!.layout.snoozedCount).toBe(1); + await act(async () => root.render(), ); @@ -155,6 +180,19 @@ it("reuses a large thread list across tab renders and updates when its inputs ch expect(afterClock.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe( false, ); + await act(async () => + root.render(), + ); + expect(seen.at(-1)!.layout).toBe(afterClock.layout); + expect(seen.at(-1)!.items).not.toBe(afterClock.items); + + await act(async () => + root.render(), + ); + expect(seen.at(-1)!.layout.snoozedCount).toBe(1); + expect(seen.at(-1)!.layout.items.find((item) => item.thread.id === "thread-0")?.snoozed).toBe( + true, + ); await act(async () => root.render()); const before = "2026-01-01T12:00:00.000Z"; const older = { diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 86d17524201bb..ffb42085f944b 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -10,6 +10,7 @@ import { buildThreadListV2ListItems, getThreadListV2OrderedSection, threadListInboxReturns, + threadListV2LayoutTime, } from "./threadListV2"; import { computeThreadMoveAvailability } from "./threadOrder"; @@ -55,6 +56,10 @@ export function useThreadListV2(input: { readonly selectedThreadKey: string | null; }) { const { threads, workingShelfEnabled } = input; + const layoutTime = useMemo( + () => threadListV2LayoutTime({ threads, now: input.now, searchQuery: input.searchQuery }), + [threads, input.now, input.searchQuery], + ); // A thread can stop working when the threads change, or when its exit grace expires. const inboxVersion = useSyncExternalStore( threadListInboxReturns.subscribe, @@ -65,9 +70,7 @@ export function useThreadListV2(input: { // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace [threads, workingShelfEnabled, inboxVersion], ); - // 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. + // Compute move availability per section; per-row planning is quadratic. const moveAvailability = useMemo(() => { const sectionAvailability = (section: "pinned" | "active") => computeThreadMoveAvailability({ @@ -80,7 +83,7 @@ export function useThreadListV2(input: { threads, section, pendingOrder: input.pendingOrder, - now: input.now, + now: layoutTime, settlementEnvironmentIds: input.settlementEnvironmentIds, snoozeEnvironmentIds: input.snoozeEnvironmentIds, queuedThreadKeys: input.queuedThreadKeys, @@ -95,7 +98,7 @@ export function useThreadListV2(input: { input.pendingOrder, input.pinReorderEnvironmentIds, input.activeReorderEnvironmentIds, - input.now, + layoutTime, input.settlementEnvironmentIds, input.snoozeEnvironmentIds, input.queuedThreadKeys, @@ -131,7 +134,7 @@ export function useThreadListV2(input: { snoozeEnvironmentIds: input.snoozeEnvironmentIds, queuedThreadKeys: input.queuedThreadKeys, settledLimit: input.settledLimit, - now: input.now, + now: layoutTime, workingShelfEnabled, workingShelfExpanded: input.workingShelfExpanded, inboxReturnAt: inbox.returnedAt, @@ -163,7 +166,7 @@ export function useThreadListV2(input: { input.snoozeEnvironmentIds, input.queuedThreadKeys, input.settledLimit, - input.now, + layoutTime, workingShelfEnabled, input.workingShelfExpanded, inbox.returnedAt, From 0f885a862239e695e2b307d4bca561766ae61631 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 29 Oct 2026 22:37:05 -0700 Subject: [PATCH 6/6] chore(server): remove issue reference from adapter comment --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a821a4713b292..428e44e19020c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4216,7 +4216,7 @@ function claudeTaskString(value: unknown, key: string): string | undefined { } // A Claude Code mod can hold a tool call until the user acts in its terminal -// pane. T3 cannot provide the requested pane interaction (#17531). +// pane. T3 cannot provide the requested pane interaction. // Passive panes never request focus and keep working. function claudeFocusRequestedPanePlugin(message: SDKMessage): string | null { if (message.type !== "system" || claudeTaskField(message, "subtype") !== "ui_panes") return null;