Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/mobile/src/features/threads/useThreadListV2.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useThreadListV2>["layout"]> = [];
function Probe(props: { readonly threads: ReadonlyArray<typeof working> }) {
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(<Probe threads={[working]} />));
await act(async () => root.render(<Probe threads={[completed]} />));
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"]);
});
11 changes: 9 additions & 2 deletions apps/mobile/src/features/threads/useThreadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ function useKeySet(newlineSeparatedKeys: string): ReadonlySet<string> {

type LayoutInput = Parameters<typeof buildThreadListV2Items>[0];

/** The React Compiler memoizes on arguments, so an unused version would not re-observe. */
function observeInboxReturns(
threads: Parameters<typeof threadListInboxReturns.observe>[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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
];
Expand Down
Loading