diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 84de2bf777cfb..19102825109fa 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -75,6 +75,7 @@ import { import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme"; import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc"; import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; +import { PullRequestListWarmup } from "./features/pull-requests/PullRequestListWarmup"; import { ConnectDeviceApprovalCoordinator } from "./features/connection/ConnectDeviceApprovalCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; @@ -282,6 +283,7 @@ function AppContent() { <> + diff --git a/apps/mobile/src/features/pull-requests/PullRequestListWarmup.tsx b/apps/mobile/src/features/pull-requests/PullRequestListWarmup.tsx new file mode 100644 index 0000000000000..7ef0605f53ae1 --- /dev/null +++ b/apps/mobile/src/features/pull-requests/PullRequestListWarmup.tsx @@ -0,0 +1,36 @@ +import { useAtomMount, useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, PullRequestListInput } from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; + +import { useProjects } from "../../state/entities"; +import { mobilePreferencesAtom } from "../../state/preferences"; +import { composerPullRequests } from "../../state/pull-requests"; +import { useEnabledNavigationTabs } from "../layout/workspace/navigation-tabs"; +import { usePullRequestListTargets } from "./usePullRequestListControls"; + +/** Fills an empty saved list for the scope the PRs tab opens on, so its first visit paints. */ +export function PullRequestListWarmup() { + // Until the saved filters load, the tab's scope is not known and a read could be wasted. + const loaded = AsyncResult.isSuccess(useAtomValue(mobilePreferencesAtom)); + const enabled = useEnabledNavigationTabs().includes("pullRequests"); + return loaded && enabled ? : null; +} + +function WarmLists() { + const { targets } = usePullRequestListTargets(""); + const projects = useProjects(); + return targets + .filter((target) => + projects.some( + (project) => project.environmentId === target.environmentId && !project.isScratch, + ), + ) + .map((target) => ); +} + +function WarmList(props: { + target: { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput }; +}) { + useAtomMount(composerPullRequests.warmList(props.target)); + return null; +} diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index d0672f064064a..8092fa1022d55 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -30,16 +30,14 @@ import { type PullRequestFilterMenu, } from "./PullRequestListFilters"; import { usePullRequestList } from "./usePullRequestList"; -import { usePullRequestListControls } from "./usePullRequestListControls"; +import { usePullRequestListTargets } from "./usePullRequestListControls"; import { usePullRequestListActions } from "./usePullRequestListActions"; import { filterPullRequestList, pullRequestListFacets, - pullRequestListInput, pullRequestListRowsAreEqual, scopedPullRequestListKey as pullRequestKey, type EnvironmentPullRequestListEntry, - withoutScratchProjectScope, pullRequestProjectChoices, } from "./pullRequestList"; import { useDebouncedValue } from "../../state/queries"; @@ -121,11 +119,7 @@ export function PullRequestsScreen( const query = tab.searchMode === "resting" ? "" : debouncedQuery; const typedQuery = tab.searchMode === "resting" ? "" : tab.query; const typedDraft = parsePullRequestQuery(typedQuery).filters.draft; - const listControls = usePullRequestListControls(); - const selectedEnvironmentId = listControls.environmentId; - const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) - ? selectedEnvironmentId - : null; + const { listControls, environmentId, controls, targets } = usePullRequestListTargets(query); const selectDetail = useSelectWorkspaceDetail(); const selectPullRequest = (item: EnvironmentPullRequestListEntry) => { Keyboard.dismiss(); @@ -140,28 +134,6 @@ export function PullRequestsScreen( selectDetail({ name: "PullRequest", params }); }; const allProjects = useProjects(); - const savedControls = listControls.controlsFor(environmentId); - const controls = useMemo( - () => withoutScratchProjectScope(savedControls, allProjects), - [allProjects, savedControls], - ); - const targets = useMemo( - () => - environments - .filter( - (environment) => - configs.get(environment.environmentId) !== undefined && - (environmentId === null || environment.environmentId === environmentId) && - (!controls.projectEnvironmentId || - environment.environmentId === controls.projectEnvironmentId), - ) - .sort((left, right) => left.environmentId.localeCompare(right.environmentId)) - .map((environment) => ({ - environmentId: environment.environmentId, - input: pullRequestListInput(controls, query), - })), - [environments, environmentId, configs, controls, query], - ); const result = usePullRequestList(targets); const connectedTargets = targets.filter((target) => environments.some( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts b/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts index 6b97390908abc..e9a583e05ee6a 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestListControls.ts @@ -1,8 +1,17 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/reactivity"; +import { useMemo } from "react"; +import { useProjects } from "../../state/entities"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; -import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList"; +import { environmentServerConfigsAtom } from "../../state/server"; +import { useWorkspaceState } from "../../state/workspace"; +import { + DEFAULT_PR_LIST_CONTROLS, + pullRequestListInput, + withoutScratchProjectScope, + type PullRequestListControls, +} from "./pullRequestList"; export function usePullRequestListControls() { const preferences = useAtomValue(mobilePreferencesAtom); @@ -45,3 +54,38 @@ function withEnvironmentControls( ): Record { return { ...saved, [environment]: controls }; } + +/** The list reads for the saved controls; the PRs tab and its warm-up share these keys. */ +export function usePullRequestListTargets(query: string) { + const { environments } = useWorkspaceState(); + const configs = useAtomValue(environmentServerConfigsAtom); + const allProjects = useProjects(); + const listControls = usePullRequestListControls(); + const selectedEnvironmentId = listControls.environmentId; + const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId) + ? selectedEnvironmentId + : null; + const savedControls = listControls.controlsFor(environmentId); + const controls = useMemo( + () => withoutScratchProjectScope(savedControls, allProjects), + [allProjects, savedControls], + ); + const targets = useMemo( + () => + environments + .filter( + (environment) => + configs.get(environment.environmentId) !== undefined && + (environmentId === null || environment.environmentId === environmentId) && + (!controls.projectEnvironmentId || + environment.environmentId === controls.projectEnvironmentId), + ) + .sort((left, right) => left.environmentId.localeCompare(right.environmentId)) + .map((environment) => ({ + environmentId: environment.environmentId, + input: pullRequestListInput(controls, query), + })), + [environments, environmentId, configs, controls, query], + ); + return { listControls, environmentId, controls, targets }; +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts index 74a19737ac85d..6efe6143856c5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestStack.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestStack.ts @@ -1,6 +1,8 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility"; import type { PullRequestDetail } from "@t3tools/contracts"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; import { useMemo } from "react"; import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities"; import { pullRequestStackAtom } from "../../state/pull-requests"; @@ -8,6 +10,8 @@ import { useEnvironmentQuery } from "../../state/query"; import { savedPullRequestStack } from "./pullRequestStack"; import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff"; +const NOT_ANSWERED = Atom.make(false); + export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDetail | null) { const target = pullRequestTarget(params); const config = useEnvironmentServerConfig(target.environmentId); @@ -37,11 +41,14 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe const query = useEnvironmentQuery( supported ? pullRequestStackAtom({ ...target, input: reference }) : null, ); + const answered = useAtomValue( + supported ? pullRequestStackAtom.answered({ ...target, input: reference }) : NOT_ANSWERED, + ); const stack = query.isSuccess ? query.data : (query.data ?? saved); return { ...query, stack, - isFresh: supported && query.isSuccess && !query.isPending, + isFresh: supported && query.isSuccess && !query.isPending && answered, supportsStackActions: supported && pr?.capabilities.stackActions === true, }; } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 8e4baf20451c8..e208b53c3c2c0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -39,6 +39,7 @@ import { } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; +import { PullRequestListWarmup } from "./pullRequest/PullRequestListWarmup"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; @@ -494,6 +495,7 @@ export function AppSidebarLayout({ ) : null} {children} + {isElectron ? : null} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx index c9bcfc1a378fd..d86cf10b6629c 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx @@ -1,5 +1,5 @@ import { ArrowLeftIcon, FileDiffIcon } from "lucide-react"; -import type { ComponentProps, ReactNode } from "react"; +import { useRef, type ComponentProps, type ReactNode } from "react"; import { cn } from "~/lib/utils"; @@ -77,6 +77,8 @@ export function PullRequestDetailTabBar({ inert?: boolean; children?: ReactNode; }) { + // A pointer passing over a tab is not intent; it must rest like a list row does. + const pointerIntent = useRef | undefined>(undefined); return (