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..6640cfd570a8c --- /dev/null +++ b/apps/mobile/src/features/threads/useThreadListV2.test.tsx @@ -0,0 +1,80 @@ +import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { act, useLayoutEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +import { makeThreadShellFixture } from "../../test-fixtures"; +import { threadListInboxReturns } from "./threadListV2"; +import { useThreadListV2 } from "./useThreadListV2"; + +// The real shelf reads the app's atom registry, which imports Expo; this list has no older pages. +vi.mock("../../state/olderSettledShelf", () => ({ + useOlderSettledShelf: () => ({ isListed: () => true, unloadedCount: () => 0 }), + useOlderSettledShelfGrowth: () => {}, +})); + +const NOW = "2026-06-02T00:00:00.000Z"; +const environmentId = EnvironmentId.make("environment-1"); + +it("returns a thread from the Working shelf once its exit grace expires", async () => { + using _globals = { [Symbol.dispose]: () => vi.unstubAllGlobals() }; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + using _tracker = { [Symbol.dispose]: () => threadListInboxReturns.observe(null) }; + vi.useFakeTimers({ now: Date.parse(NOW) }); + const working = makeThreadShellFixture({ + environmentId, + id: ThreadId.make("echo"), + title: "echo", + runtime: { + status: "running", + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: NOW, + }, + }); + const completed = { ...working, runtime: null }; + const seen: Array["layout"]> = []; + function Probe(props: { readonly threads: ReadonlyArray }) { + const { layout } = useThreadListV2({ + threads: props.threads, + pendingTasks: [], + environmentId: null, + projectRefs: null, + searchQuery: "", + searchMatches: new Map(), + settlementEnvironmentIds: new Set(), + snoozeEnvironmentIds: new Set(), + pinReorderEnvironmentIds: new Set(), + activeReorderEnvironmentIds: new Set(), + queuedThreadKeys: new Set(), + settledLimit: 10, + now: NOW, + pendingOrder: null, + shelfPreferencesLoaded: true, + workingShelfEnabled: true, + workingShelfExpanded: false, + snoozedShelfExpanded: false, + settledShelfExpanded: false, + selectedThreadKey: null, + }); + useLayoutEffect(() => { + seen.push(layout); + }); + return null; + } + + const root = createRoot(document.createElement("div")); + using _root = { [Symbol.dispose]: () => act(() => root.unmount()) }; + await act(async () => root.render()); + await act(async () => root.render()); + expect(seen.at(-1)?.workingCount).toBe(1); + + await act(async () => vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS)); + expect(threadListInboxReturns.isWorking(completed)).toBe(false); + expect(seen.at(-1)?.workingCount, "the list still shows the finished thread as working").toBe(0); + expect(seen.at(-1)?.items.map((item) => item.thread.id)).toEqual(["echo"]); +}); diff --git a/apps/mobile/src/features/threads/useThreadListV2.ts b/apps/mobile/src/features/threads/useThreadListV2.ts index 2a6f3504e2f5d..0b27d4481c14f 100644 --- a/apps/mobile/src/features/threads/useThreadListV2.ts +++ b/apps/mobile/src/features/threads/useThreadListV2.ts @@ -27,6 +27,14 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet { type LayoutInput = Parameters[0]; +/** The React Compiler memoizes on arguments, so an unused version would not re-observe. */ +function observeInboxReturns( + threads: Parameters[0], + _version: number, +) { + return threadListInboxReturns.observe(threads); +} + /** * 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 @@ -62,8 +70,7 @@ export function useThreadListV2(input: { threadListInboxReturns.getSnapshot, ); const inbox = useMemo( - () => threadListInboxReturns.observe(workingShelfEnabled ? threads : null), - // oxlint-disable-next-line react/memo-dependencies -- the version signals an expired grace + () => observeInboxReturns(workingShelfEnabled ? threads : null, inboxVersion), [threads, workingShelfEnabled, inboxVersion], ); // Up/down menu availability for every card, computed once per section per diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6e24636671608..7c08d985929c0 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -35,6 +35,7 @@ const reactTests = [ "src/state/use-selected-thread-git-state.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", ];