From 620edafed7345e4586e6d6780a17cf8713ad0260 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:49:44 +1100 Subject: [PATCH 1/2] fix(mobile): release hidden sidebar PR summary demand --- .../threads/ThreadNavigationSidebar.tsx | 14 +- apps/mobile/src/state/thread-pr-demand.tsx | 16 + .../src/state/use-thread-pr.demand.test.tsx | 524 ++++++++++++++++++ apps/mobile/src/state/use-thread-pr.ts | 6 +- apps/mobile/vite.config.ts | 1 + 5 files changed, 555 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/src/state/thread-pr-demand.tsx create mode 100644 apps/mobile/src/state/use-thread-pr.demand.test.tsx diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 1e3e5a03891a4..a5d9ea6c944ae 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -68,6 +68,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, @@ -109,10 +110,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..03a1e654ff050 --- /dev/null +++ b/apps/mobile/src/state/use-thread-pr.demand.test.tsx @@ -0,0 +1,524 @@ +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(() => ({ + registry: undefined as AtomRegistry.AtomRegistry | undefined, + 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("./atom-registry", () => ({ + get appAtomRegistry() { + return fixture.registry!; + }, +})); +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"; +import { deriveLayout, deriveWorkspacePaneLayout, isPrimarySidebarPinned } from "../lib/layout"; + +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(); + fixture.registry = registry; + 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 ( + + {pr?.accessibilityLabel ?? "none"} + + ); + } + 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.each([ + { + name: "root pins the sidebar despite a closed preference", + pathname: "/", + preferred: false, + inspector: false, + visible: true, + }, + { + name: "detail route keeps an open split sidebar live", + pathname: "/threads/1", + preferred: true, + inspector: false, + visible: true, + }, + { + name: "detail route respects a closed sidebar", + pathname: "/threads/1", + preferred: false, + inspector: false, + visible: false, + }, + { + name: "inspector suppresses a retained sidebar when the split has no room", + pathname: "/threads/1", + preferred: true, + inspector: true, + visible: false, + }, +])("uses the actual pane visibility: $name", ({ pathname, preferred, inspector, visible }) => + Effect.gen(function* () { + using h = yield* setup(); + const layout = deriveLayout({ width: 1024, height: 834 }); + const pinned = isPrimarySidebarPinned({ + pathname, + nativeWorkspace: false, + usesSplitView: layout.usesSplitView, + }); + const panes = deriveWorkspacePaneLayout({ + layout, + viewportWidth: 1024, + primarySidebarPreferredVisible: pinned || preferred, + auxiliaryPanePreferredVisible: inspector, + auxiliaryPaneRole: "inspector", + }); + expect(panes.primarySidebarVisible).toBe(visible); + yield* Effect.promise(async () => { + await h.render({ visible: panes.primarySidebarVisible }); + await h.advance(190_000); + expect(h.requests).toHaveLength(visible ? 4 : 0); + expect(h.badge()).toBe(visible ? "#12 pull request open" : "none"); + }); + }), +); + +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.each([ + { field: "environment", changed: { environmentId: EnvironmentId.make("other") } }, + { + field: "project", + changed: { branchPullRequest: { ...reference, projectId: ProjectId.make("other") } }, + }, + { + field: "repository", + changed: { branchPullRequest: { ...reference, repository: "acme/other" } }, + }, + { field: "PR", changed: { branchPullRequest: { ...reference, number: 13 } } }, +])("does not show an old badge after a hidden $field change", ({ changed }) => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + await h.render({ visible: false }); + await h.advance(10_000); + const before = h.requests.length; + const value = thread(changed); + await h.render({ visible: false, value }); + expect(h.badge()).toBe("none"); + await h.advance(180_000); + expect(h.requests.length).toBe(before); + h.holdReplies(); + await h.render({ value }); + expect(h.badge()).toBe("none"); + expect(h.requests.length).toBe(before + 1); + expect(h.requests.at(-1)?.environmentId).toBe(value.environmentId); + expect(h.requests.at(-1)?.input).toMatchObject({ + projectId: value.branchPullRequest!.projectId, + repository: value.branchPullRequest!.repository, + number: value.branchPullRequest!.number, + }); + await h.releaseReplies(); + expect(h.badge()).toBe(`#${value.branchPullRequest!.number} pull request open`); + expect(h.mounts).toBe(1); + }); + }), +); + +it.effect("clears a removed PR while hidden instead of resurrecting its cached badge", () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + const value = thread({ branchPullRequest: null }); + await h.render({ visible: false, value }); + expect(h.badge()).toBe("none"); + await h.advance(190_000); + await h.render({ value }); + expect(h.requests).toHaveLength(1); + expect(h.badge()).toBe("none"); + }); + }), +); + +it.effect.each([true, false])( + "renders linked presentations with no fallback RPC, snapshot present: %s", + (snapshot) => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + const value = thread({ + pullRequests: [ + { + host: "github.com", + repository: "acme/app", + number: 14, + url: "https://github.com/acme/app/pull/14", + source: "manual", + linkedAt: "2026-10-26T00:00:00Z", + stack: null, + snapshot: snapshot + ? { + title: "Linked", + state: "open", + isDraft: false, + headBranch: "feature", + baseBranch: "main", + updatedAt: null, + syncedAt: "2026-10-26T00:00:00Z", + } + : null, + }, + ], + }); + await h.render({ value }); + expect(h.badge()).toBe(`#14 pull request ${snapshot ? "open" : "status pending"}`); + await h.render({ value, visible: false }); + await h.advance(190_000); + await h.render({ value }); + expect(h.requests).toHaveLength(0); + expect(h.badge()).toBe(`#14 pull request ${snapshot ? "open" : "status pending"}`); + }); + }), +); + +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); + }); + }), +); + +it.effect( + "allows only the existing idle-grace poll when the sidebar closes just before a tick", + () => + Effect.gen(function* () { + using h = yield* setup(); + yield* Effect.promise(async () => { + await h.render(); + await h.advance(59_000); + await h.render({ visible: false }); + await h.advance(10_000); + const afterGrace = h.requests.length; + expect(afterGrace).toBeLessThanOrEqual(2); + await h.advance(180_000); + expect(h.requests.length).toBe(afterGrace); + }); + }), +); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 566a5f6981f4f..3f61a2e89076e 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -6,12 +6,13 @@ import { pullRequestDetailToVcsStatus, } from "@t3tools/client-runtime/state/pull-requests"; import { Atom } from "effect/reactivity"; -import { useCallback, useEffect, useMemo } from "react"; +import { use, useCallback, useEffect, useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; +import { ThreadPrDemandContext } from "./thread-pr-demand"; import { resolveThreadPrSource, presentThreadPr, @@ -39,6 +40,7 @@ export { } from "./thread-pr-presentation"; export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { + const demand = use(ThreadPrDemandContext); const supportsLinks = useAtomValue( serverEnvironment.configValueAtom(thread.environmentId), (config) => config?.environment.capabilities.threadPullRequests === true, @@ -66,7 +68,7 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio ); const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; 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 77fffb6cb6117..ebaeba6a1c85c 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/state/use-thread-pr.demand.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", "src/lib/filePreview.test.ts", "src/features/threads/thread-inspector-navigation.test.tsx", From 16529822f5c66ecbbfb510ad18f51aefa0c7ee0b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 02:51:12 +0000 Subject: [PATCH 2/2] test(mobile): focus sidebar summary demand coverage --- .../src/state/use-thread-pr.demand.test.tsx | 179 ------------------ 1 file changed, 179 deletions(-) diff --git a/apps/mobile/src/state/use-thread-pr.demand.test.tsx b/apps/mobile/src/state/use-thread-pr.demand.test.tsx index 03a1e654ff050..b3e6965531f63 100644 --- a/apps/mobile/src/state/use-thread-pr.demand.test.tsx +++ b/apps/mobile/src/state/use-thread-pr.demand.test.tsx @@ -29,7 +29,6 @@ import { expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; const fixture = vi.hoisted(() => ({ - registry: undefined as AtomRegistry.AtomRegistry | undefined, environment: undefined as EnvironmentRegistry.EnvironmentRegistry["Service"] | undefined, active: "active", listeners: new Set<() => void>(), @@ -58,11 +57,6 @@ vi.mock("../connection/runtime", async () => { ), }; }); -vi.mock("./atom-registry", () => ({ - get appAtomRegistry() { - return fixture.registry!; - }, -})); vi.mock("./server", async () => { const { Atom } = await import("effect/reactivity"); const config = Atom.make({ environment: { capabilities: { threadPullRequests: true } } }); @@ -71,7 +65,6 @@ vi.mock("./server", async () => { import { ThreadPrDemandProvider } from "./thread-pr-demand"; import { useThreadPr } from "./use-thread-pr"; -import { deriveLayout, deriveWorkspacePaneLayout, isPrimarySidebarPinned } from "../lib/layout"; const environmentId = EnvironmentId.make("sidebar-environment"); const reference = { @@ -168,7 +161,6 @@ const setup = Effect.fn("TestThreadPr.setup")(function* () { fixture.environment = environment as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]; const registry = AtomRegistry.make(); - fixture.registry = registry; const container = document.createElement("div"); const root = createRoot(container); let mounts = 0; @@ -248,61 +240,6 @@ const setup = Effect.fn("TestThreadPr.setup")(function* () { }; }); -it.effect.each([ - { - name: "root pins the sidebar despite a closed preference", - pathname: "/", - preferred: false, - inspector: false, - visible: true, - }, - { - name: "detail route keeps an open split sidebar live", - pathname: "/threads/1", - preferred: true, - inspector: false, - visible: true, - }, - { - name: "detail route respects a closed sidebar", - pathname: "/threads/1", - preferred: false, - inspector: false, - visible: false, - }, - { - name: "inspector suppresses a retained sidebar when the split has no room", - pathname: "/threads/1", - preferred: true, - inspector: true, - visible: false, - }, -])("uses the actual pane visibility: $name", ({ pathname, preferred, inspector, visible }) => - Effect.gen(function* () { - using h = yield* setup(); - const layout = deriveLayout({ width: 1024, height: 834 }); - const pinned = isPrimarySidebarPinned({ - pathname, - nativeWorkspace: false, - usesSplitView: layout.usesSplitView, - }); - const panes = deriveWorkspacePaneLayout({ - layout, - viewportWidth: 1024, - primarySidebarPreferredVisible: pinned || preferred, - auxiliaryPanePreferredVisible: inspector, - auxiliaryPaneRole: "inspector", - }); - expect(panes.primarySidebarVisible).toBe(visible); - yield* Effect.promise(async () => { - await h.render({ visible: panes.primarySidebarVisible }); - await h.advance(190_000); - expect(h.requests).toHaveLength(visible ? 4 : 0); - expect(h.badge()).toBe(visible ? "#12 pull request open" : "none"); - }); - }), -); - it.effect( "releases retained hidden sidebar demand and keeps the badge while reveal revalidates", () => @@ -386,104 +323,6 @@ it.effect.each(["inactive", "background"])("releases sidebar demand while the OS }), ); -it.effect.each([ - { field: "environment", changed: { environmentId: EnvironmentId.make("other") } }, - { - field: "project", - changed: { branchPullRequest: { ...reference, projectId: ProjectId.make("other") } }, - }, - { - field: "repository", - changed: { branchPullRequest: { ...reference, repository: "acme/other" } }, - }, - { field: "PR", changed: { branchPullRequest: { ...reference, number: 13 } } }, -])("does not show an old badge after a hidden $field change", ({ changed }) => - Effect.gen(function* () { - using h = yield* setup(); - yield* Effect.promise(async () => { - await h.render(); - await h.render({ visible: false }); - await h.advance(10_000); - const before = h.requests.length; - const value = thread(changed); - await h.render({ visible: false, value }); - expect(h.badge()).toBe("none"); - await h.advance(180_000); - expect(h.requests.length).toBe(before); - h.holdReplies(); - await h.render({ value }); - expect(h.badge()).toBe("none"); - expect(h.requests.length).toBe(before + 1); - expect(h.requests.at(-1)?.environmentId).toBe(value.environmentId); - expect(h.requests.at(-1)?.input).toMatchObject({ - projectId: value.branchPullRequest!.projectId, - repository: value.branchPullRequest!.repository, - number: value.branchPullRequest!.number, - }); - await h.releaseReplies(); - expect(h.badge()).toBe(`#${value.branchPullRequest!.number} pull request open`); - expect(h.mounts).toBe(1); - }); - }), -); - -it.effect("clears a removed PR while hidden instead of resurrecting its cached badge", () => - Effect.gen(function* () { - using h = yield* setup(); - yield* Effect.promise(async () => { - await h.render(); - const value = thread({ branchPullRequest: null }); - await h.render({ visible: false, value }); - expect(h.badge()).toBe("none"); - await h.advance(190_000); - await h.render({ value }); - expect(h.requests).toHaveLength(1); - expect(h.badge()).toBe("none"); - }); - }), -); - -it.effect.each([true, false])( - "renders linked presentations with no fallback RPC, snapshot present: %s", - (snapshot) => - Effect.gen(function* () { - using h = yield* setup(); - yield* Effect.promise(async () => { - const value = thread({ - pullRequests: [ - { - host: "github.com", - repository: "acme/app", - number: 14, - url: "https://github.com/acme/app/pull/14", - source: "manual", - linkedAt: "2026-10-26T00:00:00Z", - stack: null, - snapshot: snapshot - ? { - title: "Linked", - state: "open", - isDraft: false, - headBranch: "feature", - baseBranch: "main", - updatedAt: null, - syncedAt: "2026-10-26T00:00:00Z", - } - : null, - }, - ], - }); - await h.render({ value }); - expect(h.badge()).toBe(`#14 pull request ${snapshot ? "open" : "status pending"}`); - await h.render({ value, visible: false }); - await h.advance(190_000); - await h.render({ value }); - expect(h.requests).toHaveLength(0); - expect(h.badge()).toBe(`#14 pull request ${snapshot ? "open" : "status pending"}`); - }); - }), -); - it.effect("keeps mounted passive state through an Android-style Activity tab switch", () => Effect.gen(function* () { using h = yield* setup(); @@ -504,21 +343,3 @@ it.effect("keeps mounted passive state through an Android-style Activity tab swi }); }), ); - -it.effect( - "allows only the existing idle-grace poll when the sidebar closes just before a tick", - () => - Effect.gen(function* () { - using h = yield* setup(); - yield* Effect.promise(async () => { - await h.render(); - await h.advance(59_000); - await h.render({ visible: false }); - await h.advance(10_000); - const afterGrace = h.requests.length; - expect(afterGrace).toBeLessThanOrEqual(2); - await h.advance(180_000); - expect(h.requests.length).toBe(afterGrace); - }); - }), -);