diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 7975097f6a931..d57fb7cee9ab0 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -74,6 +74,7 @@ import {
} from "./thread-list-v2-items";
import { useThreadRowProviderInstanceResolver } from "./thread-provider-instance";
import type { ThreadPrPresentation } from "../../state/thread-pr-presentation";
+import { ThreadPrDemandProvider } from "../../state/thread-pr-demand";
import {
isThreadListV2ListItem,
threadListV2ListItemsAreEqual,
@@ -115,10 +116,15 @@ interface ThreadNavigationSidebarProps {
}
export function ThreadNavigationSidebar(props: ThreadNavigationSidebarProps) {
- if (Platform.OS !== "ios") {
- return ;
- }
- return ;
+ return (
+
+ {Platform.OS !== "ios" ? (
+
+ ) : (
+
+ )}
+
+ );
}
function NativeSidebarContainer(props: ThreadNavigationSidebarProps) {
diff --git a/apps/mobile/src/state/thread-pr-demand.tsx b/apps/mobile/src/state/thread-pr-demand.tsx
new file mode 100644
index 0000000000000..d6e507bd33727
--- /dev/null
+++ b/apps/mobile/src/state/thread-pr-demand.tsx
@@ -0,0 +1,16 @@
+import { createContext, type ReactNode } from "react";
+
+import { useAppActive } from "../lib/appState";
+
+export const ThreadPrDemandContext = createContext(true);
+
+export function ThreadPrDemandProvider(props: {
+ readonly visible: boolean;
+ readonly children: ReactNode;
+}) {
+ const active = useAppActive();
+ // A sidebar can stay visible beside a focused detail route, so route focus cannot gate its reads.
+ return (
+ {props.children}
+ );
+}
diff --git a/apps/mobile/src/state/use-thread-pr.demand.test.tsx b/apps/mobile/src/state/use-thread-pr.demand.test.tsx
new file mode 100644
index 0000000000000..b3e6965531f63
--- /dev/null
+++ b/apps/mobile/src/state/use-thread-pr.demand.test.tsx
@@ -0,0 +1,345 @@
+import { RegistryContext } from "@effect/atom-react";
+import {
+ EnvironmentId,
+ ProjectId,
+ ThreadId,
+ WS_METHODS,
+ type PullRequestRef,
+} from "@t3tools/contracts";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import {
+ EnvironmentRegistry,
+ EnvironmentSupervisor,
+ AVAILABLE_CONNECTION_STATE,
+ PrimaryConnectionTarget,
+ type SupervisorConnectionState,
+ type PreparedConnection,
+ type ConnectionCatalogEntry,
+} from "@t3tools/client-runtime/connection";
+import type { RpcSession } from "@t3tools/client-runtime/rpc";
+import * as Effect from "effect/Effect";
+import * as Latch from "effect/Latch";
+import * as Option from "effect/Option";
+import * as Stream from "effect/Stream";
+import * as SubscriptionRef from "effect/SubscriptionRef";
+import { AtomRegistry } from "effect/reactivity";
+import { Activity, act, useState } from "react";
+import { createRoot } from "react-dom/client";
+import { expect, it } from "@effect/vitest";
+import { vi } from "vite-plus/test";
+
+const fixture = vi.hoisted(() => ({
+ environment: undefined as EnvironmentRegistry.EnvironmentRegistry["Service"] | undefined,
+ active: "active",
+ listeners: new Set<() => void>(),
+}));
+
+vi.mock("react-native", () => ({
+ AppState: {
+ get currentState() {
+ return fixture.active;
+ },
+ addEventListener: (_event: string, listener: () => void) => {
+ fixture.listeners.add(listener);
+ return { remove: () => fixture.listeners.delete(listener) };
+ },
+ },
+}));
+vi.mock("../connection/runtime", async () => {
+ const { Atom } = await import("effect/reactivity");
+ const Layer = await import("effect/Layer");
+ const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection");
+ return {
+ connectionAtomRuntime: Atom.runtime(
+ Layer.suspend(() =>
+ Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, fixture.environment!),
+ ),
+ ),
+ };
+});
+vi.mock("./server", async () => {
+ const { Atom } = await import("effect/reactivity");
+ const config = Atom.make({ environment: { capabilities: { threadPullRequests: true } } });
+ return { serverEnvironment: { configValueAtom: () => config } };
+});
+
+import { ThreadPrDemandProvider } from "./thread-pr-demand";
+import { useThreadPr } from "./use-thread-pr";
+
+const environmentId = EnvironmentId.make("sidebar-environment");
+const reference = {
+ projectId: ProjectId.make("project"),
+ repository: "acme/app",
+ number: 12,
+ url: "https://github.com/acme/app/pull/12",
+};
+function thread(overrides: Partial = {}): EnvironmentThreadShell {
+ return {
+ environmentId,
+ id: ThreadId.make("thread"),
+ pullRequests: [],
+ linkedPullRequest: null,
+ branchPullRequest: reference,
+ ...overrides,
+ } as EnvironmentThreadShell;
+}
+
+const setup = Effect.fn("TestThreadPr.setup")(function* () {
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ vi.useFakeTimers({
+ toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval"],
+ });
+ vi.setSystemTime(0);
+ fixture.active = "active";
+ fixture.listeners.clear();
+ const requests: Array<{ environmentId: EnvironmentId; input: PullRequestRef }> = [];
+ let state = "open";
+ let replyGate: Latch.Latch | null = null;
+ const target = new PrimaryConnectionTarget({
+ environmentId,
+ label: "Sidebar environment",
+ httpBaseUrl: "https://sidebar.test",
+ wsBaseUrl: "wss://sidebar.test",
+ });
+ const supervisor = yield* Effect.gen(function* () {
+ return EnvironmentSupervisor.EnvironmentSupervisor.of({
+ target,
+ state: yield* SubscriptionRef.make({
+ ...AVAILABLE_CONNECTION_STATE,
+ phase: "connected" as const,
+ desired: true,
+ network: "online" as const,
+ attempt: 1,
+ generation: 1,
+ }),
+ session: yield* SubscriptionRef.make(
+ Option.some({
+ client: {
+ [WS_METHODS.pullRequestsSummary]: (input: PullRequestRef) =>
+ Effect.gen(function* () {
+ requests.push({ environmentId: currentEnvironment, input });
+ if (replyGate !== null) yield* replyGate.await;
+ return {
+ ...input,
+ provider: "github",
+ state,
+ title: `Change ${input.number}`,
+ url: `https://github.com/${input.repository}/pull/${input.number}`,
+ baseBranch: "main",
+ headBranch: "feature",
+ isDraft: false,
+ updatedAt: null,
+ };
+ }),
+ [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never,
+ },
+ ready: Effect.void,
+ } as unknown as RpcSession),
+ ),
+ prepared: yield* SubscriptionRef.make(Option.none()),
+ connect: Effect.void,
+ disconnect: Effect.void,
+ retryNow: Effect.void,
+ });
+ });
+ let currentEnvironment = environmentId;
+ const environment = {
+ entries: yield* SubscriptionRef.make>(
+ new Map(),
+ ),
+ run: (id, effect) => {
+ currentEnvironment = id;
+ return Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor);
+ },
+ followStream: (_id, stream) =>
+ Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ stateChanges: () => SubscriptionRef.changes(supervisor.state),
+ } satisfies Pick<
+ EnvironmentRegistry.EnvironmentRegistry["Service"],
+ "entries" | "run" | "followStream" | "stateChanges"
+ >;
+ fixture.environment =
+ environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"];
+ const registry = AtomRegistry.make();
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ let mounts = 0;
+ function Row({ value, name }: { value: EnvironmentThreadShell; name: string }) {
+ const [passive] = useState(() => ++mounts);
+ const pr = useThreadPr(value);
+ return (
+
+ );
+ }
+ return {
+ requests,
+ get mounts() {
+ return mounts;
+ },
+ setState(next: string) {
+ state = next;
+ },
+ holdReplies() {
+ replyGate = Latch.makeUnsafe();
+ },
+ async releaseReplies() {
+ await act(async () => {
+ replyGate?.openUnsafe();
+ replyGate = null;
+ });
+ },
+ badge(name = "sidebar") {
+ return container.querySelector(`output[data-name="${name}"]`)?.textContent;
+ },
+ passive() {
+ return container.querySelector("output")?.getAttribute("data-passive");
+ },
+ async render({
+ visible = true,
+ value = thread(),
+ shared = false,
+ activity = "visible",
+ }: {
+ visible?: boolean;
+ value?: EnvironmentThreadShell;
+ shared?: boolean;
+ activity?: "visible" | "hidden";
+ } = {}) {
+ await act(async () =>
+ root.render(
+
+
+
+
+
+
+
+
+ {shared ?
: null}
+ ,
+ ),
+ );
+ },
+ async advance(ms: number) {
+ await act(async () => vi.advanceTimersByTimeAsync(ms));
+ },
+ async appState(next: string) {
+ await act(async () => {
+ fixture.active = next;
+ for (const listener of fixture.listeners) listener();
+ });
+ },
+ [Symbol.dispose]() {
+ act(() => root.unmount());
+ registry.dispose();
+ expect(fixture.listeners.size).toBe(0);
+ vi.useRealTimers();
+ },
+ };
+});
+
+it.effect(
+ "releases retained hidden sidebar demand and keeps the badge while reveal revalidates",
+ () =>
+ Effect.gen(function* () {
+ using h = yield* setup();
+ yield* Effect.promise(async () => {
+ await h.render();
+ expect(h.badge()).toBe("#12 pull request open");
+ const passive = h.passive();
+ expect(h.requests).toHaveLength(1);
+ await h.render({ visible: false });
+ await h.advance(10_000);
+ const afterGrace = h.requests.length;
+ await h.advance(180_000);
+ expect(h.requests.length - afterGrace).toBe(0);
+ expect(h.passive()).toBe(passive);
+ h.holdReplies();
+ h.setState("closed");
+ await h.render();
+ expect(h.badge()).toBe("#12 pull request open");
+ expect(h.requests.length).toBe(afterGrace + 1);
+ expect(h.mounts).toBe(1);
+ await h.releaseReplies();
+ expect(h.badge()).toBe("#12 pull request closed");
+ });
+ }),
+);
+
+it.effect("does not start a fallback query for a sidebar first mounted hidden", () =>
+ Effect.gen(function* () {
+ using h = yield* setup();
+ yield* Effect.promise(async () => {
+ await h.render({ visible: false });
+ await h.advance(180_000);
+ expect(h.requests).toHaveLength(0);
+ expect(h.badge()).toBe("none");
+ await h.render();
+ expect(h.requests).toHaveLength(1);
+ expect(h.badge()).toBe("#12 pull request open");
+ expect(h.mounts).toBe(1);
+ });
+ }),
+);
+
+it.effect("keeps a shared visible reader polling while its sidebar reader is hidden", () =>
+ Effect.gen(function* () {
+ using h = yield* setup();
+ yield* Effect.promise(async () => {
+ await h.render({ shared: true });
+ expect(h.requests).toHaveLength(1);
+ await h.render({ visible: false, shared: true });
+ await h.advance(180_000);
+ expect(h.requests).toHaveLength(4);
+ expect(h.badge("shared")).toBe("#12 pull request open");
+ h.setState("closed");
+ await h.advance(60_000);
+ expect(h.requests).toHaveLength(5);
+ expect(h.badge("shared")).toBe("#12 pull request closed");
+ await h.render({ shared: true });
+ expect(h.badge()).toBe("#12 pull request closed");
+ });
+ }),
+);
+
+it.effect.each(["inactive", "background"])("releases sidebar demand while the OS is %s", (state) =>
+ Effect.gen(function* () {
+ using h = yield* setup();
+ yield* Effect.promise(async () => {
+ await h.render();
+ await h.appState(state);
+ await h.advance(10_000);
+ const before = h.requests.length;
+ await h.advance(180_000);
+ expect(h.requests.length - before).toBe(0);
+ h.holdReplies();
+ await h.appState("active");
+ expect(h.badge()).toBe("#12 pull request open");
+ expect(h.requests.length - before).toBe(1);
+ expect(h.mounts).toBe(1);
+ });
+ }),
+);
+
+it.effect("keeps mounted passive state through an Android-style Activity tab switch", () =>
+ Effect.gen(function* () {
+ using h = yield* setup();
+ yield* Effect.promise(async () => {
+ await h.render();
+ const passive = h.passive();
+ await h.render({ activity: "hidden" });
+ await h.advance(10_000);
+ const before = h.requests.length;
+ await h.advance(180_000);
+ expect(h.requests.length - before).toBe(0);
+ h.holdReplies();
+ await h.render();
+ expect(h.badge()).toBe("#12 pull request open");
+ expect(h.passive()).toBe(passive);
+ expect(h.mounts).toBe(1);
+ expect(h.requests.length - before).toBe(1);
+ });
+ }),
+);
diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts
index 549bdb5f1a91c..af14ce3827b4e 100644
--- a/apps/mobile/src/state/use-thread-pr.ts
+++ b/apps/mobile/src/state/use-thread-pr.ts
@@ -7,11 +7,12 @@ import {
import type { EnvironmentId, PullRequestSummary } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import { Atom } from "effect/reactivity";
-import { useMemo } from "react";
+import { use, useMemo } from "react";
import { connectionAtomRuntime } from "../connection/runtime";
import { serverEnvironment } from "./server";
import { useEnvironmentQuery } from "./query";
+import { ThreadPrDemandContext } from "./thread-pr-demand";
import {
rememberBounded,
resolveThreadPrSource,
@@ -59,6 +60,7 @@ const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(
export { type ThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation";
export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null {
+ const demand = use(ThreadPrDemandContext);
const supportsLinks = useAtomValue(
serverEnvironment.configValueAtom(thread.environmentId),
(config) => config !== null,
@@ -81,7 +83,7 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio
key === null ? undefined : current.get(key),
);
const pullRequestSummary = useEnvironmentQuery(
- pullRequestRef === null
+ pullRequestRef === null || !demand
? null
: pullRequestSummaryAtom({
environmentId: thread.environmentId,
diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts
index 375cf6a3f7622..9097287717f7b 100644
--- a/apps/mobile/vite.config.ts
+++ b/apps/mobile/vite.config.ts
@@ -5,6 +5,7 @@ import "vite-plus/test/config";
const reactTests = [
"src/features/workspace/workspace-tabs-state.test.ts",
+ "src/state/use-thread-pr.demand.test.tsx",
"src/features/threads/git/GitBranchesSheet.occupancy.test.tsx",
"src/features/files/useFileTreeEntries.test.tsx",
"src/features/projects/AddProjectScreen.test.ts",