From 9c24c33fc7d3fd150d1fc7e99ea7558e2780b42a Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Thu, 9 Apr 2026 19:02:03 +0200 Subject: [PATCH 1/5] chore(code): loading all branches --- apps/code/src/renderer/api/posthogClient.ts | 37 ++++++++++++ .../components/BranchSelector.tsx | 22 +++++++- .../onboarding/components/TutorialStep.tsx | 8 ++- .../task-detail/components/TaskInput.tsx | 8 ++- .../src/renderer/hooks/useIntegrations.ts | 56 +++++++++++++++++-- 5 files changed, 120 insertions(+), 11 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 913308d60..ea16375f1 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -995,6 +995,43 @@ export class PostHogAPIClient { }; } + async getGithubBranchesPage( + integrationId: string | number, + repo: string, + offset: number, + limit: number, + ): Promise<{ + branches: string[]; + defaultBranch: string | null; + hasMore: boolean; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + ); + url.searchParams.set("repo", repo); + url.searchParams.set("offset", String(offset)); + url.searchParams.set("limit", String(limit)); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_branches/`, + }); + + if (!response.ok) { + throw new Error( + `Failed to fetch GitHub branches: ${response.statusText}`, + ); + } + + const data = await response.json(); + return { + branches: data.branches ?? data.results ?? data ?? [], + defaultBranch: data.default_branch ?? null, + hasMore: data.has_more ?? false, + }; + } + async getGithubRepositories( integrationId: string | number, ): Promise { diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index 55530a2bf..cb8c073b7 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -21,6 +21,7 @@ interface BranchSelectorProps { onBranchSelect?: (branch: string | null) => void; cloudBranches?: string[]; cloudBranchesLoading?: boolean; + cloudBranchesFetchingMore?: boolean; taskId?: string; } @@ -36,6 +37,7 @@ export function BranchSelector({ onBranchSelect, cloudBranches, cloudBranchesLoading, + cloudBranchesFetchingMore, taskId, }: BranchSelectorProps) { const [open, setOpen] = useState(false); @@ -61,6 +63,8 @@ export function BranchSelector({ const branches = isCloudMode ? (cloudBranches ?? []) : localBranches; const effectiveLoading = loading || (isCloudMode && cloudBranchesLoading); + const cloudStillLoading = + isCloudMode && cloudBranchesLoading && branches.length === 0; const checkoutMutation = useMutation( trpc.git.checkoutBranch.mutationOptions({ @@ -93,9 +97,12 @@ export function BranchSelector({ ? "Loading..." : (displayedBranch ?? "No branch"); + const showSpinner = + effectiveLoading || (isCloudMode && cloudBranchesFetchingMore); + const triggerContent = ( - {effectiveLoading ? ( + {showSpinner ? ( ) : ( @@ -112,7 +119,7 @@ export function BranchSelector({ open={open} onOpenChange={setOpen} size="1" - disabled={disabled || !repoPath} + disabled={disabled || !repoPath || cloudStillLoading} > {triggerContent} @@ -126,6 +133,17 @@ export function BranchSelector({ {({ filtered, hasMore, moreCount }) => ( <> + {isCloudMode && cloudBranchesFetchingMore && ( + + + Loading more ({branches.length})… + + )} No branches found. {filtered.length > 0 && ( diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 6e9bd4cb9..107b94733 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -102,8 +102,11 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { ? getIntegrationIdForRepo(selectedRepository) : undefined; - const { data: cloudBranchData, isPending: cloudBranchesLoading } = - useGithubBranches(selectedIntegrationId, selectedRepository); + const { + data: cloudBranchData, + isPending: cloudBranchesLoading, + isFetchingMore: cloudBranchesFetchingMore, + } = useGithubBranches(selectedIntegrationId, selectedRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -359,6 +362,7 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { onBranchSelect={setSelectedBranch} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} /> diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index fde1e8f4a..2bb7aee0e 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -119,8 +119,11 @@ export function TaskInput({ ? getIntegrationIdForRepo(selectedCloudRepository) : undefined; - const { data: cloudBranchData, isPending: cloudBranchesLoading } = - useGithubBranches(selectedIntegrationId, selectedCloudRepository); + const { + data: cloudBranchData, + isPending: cloudBranchesLoading, + isFetchingMore: cloudBranchesFetchingMore, + } = useGithubBranches(selectedIntegrationId, selectedCloudRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -464,6 +467,7 @@ export function TaskInput({ onBranchSelect={setSelectedBranch} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} + cloudBranchesFetchingMore={cloudBranchesFetchingMore} /> {workspaceMode === "worktree" && ( ( integrationKeys.branches(integrationId, repo), - async (client) => { - if (!integrationId || !repo) return { branches: [], defaultBranch: null }; - return await client.getGithubBranches(integrationId, repo); + async (client, offset) => { + if (!integrationId || !repo) { + return { branches: [], defaultBranch: null, hasMore: false }; + } + return await client.getGithubBranchesPage( + integrationId, + repo, + offset, + BRANCHES_PAGE_SIZE, + ); + }, + { + initialPageParam: 0, + getNextPageParam: (lastPage, allPages) => { + if (!lastPage.hasMore) return undefined; + return allPages.reduce((n, p) => n + p.branches.length, 0); + }, + staleTime: 0, }, - { staleTime: 0, refetchOnMount: "always" }, ); + + // Auto-fetch remaining pages in background once the first page arrives + useEffect(() => { + if (query.hasNextPage && !query.isFetchingNextPage) { + query.fetchNextPage(); + } + }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + + const data = useMemo(() => { + if (!query.data?.pages.length) { + return { branches: [] as string[], defaultBranch: null }; + } + return { + branches: query.data.pages.flatMap((p) => p.branches), + defaultBranch: query.data.pages[0]?.defaultBranch ?? null, + }; + }, [query.data?.pages]); + + return { + data, + isPending: query.isPending, + isFetchingMore: query.isFetchingNextPage || (query.hasNextPage ?? false), + }; } export function useRepositoryIntegration() { From b90fab79c874124def2c518a783706454b302f88 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Fri, 10 Apr 2026 12:43:47 +0200 Subject: [PATCH 2/5] chore(code): fetching first page small for GH branches for perf --- apps/code/src/renderer/hooks/useIntegrations.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts index 764a705fb..4696f4d35 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/apps/code/src/renderer/hooks/useIntegrations.ts @@ -68,6 +68,10 @@ function useAllGithubRepositories(githubIntegrations: Integration[]) { }); } +// Keep the first page small so it returns in a single upstream GitHub round +// trip (GitHub's max per_page is 100), then fetch the remainder in larger +// chunks to keep the total number of client/PostHog round trips low. +const BRANCHES_FIRST_PAGE_SIZE = 100; const BRANCHES_PAGE_SIZE = 1000; interface GithubBranchesPage { @@ -86,11 +90,13 @@ export function useGithubBranches( if (!integrationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; } + const pageSize = + offset === 0 ? BRANCHES_FIRST_PAGE_SIZE : BRANCHES_PAGE_SIZE; return await client.getGithubBranchesPage( integrationId, repo, offset, - BRANCHES_PAGE_SIZE, + pageSize, ); }, { From 9b709f3ebb9447d15c9df37b70337704a8cb9e12 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Fri, 10 Apr 2026 12:44:10 +0200 Subject: [PATCH 3/5] chore(code): removing staleTime for gh branches query --- apps/code/src/renderer/hooks/useIntegrations.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts index 4696f4d35..8ae2071a2 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/apps/code/src/renderer/hooks/useIntegrations.ts @@ -105,7 +105,6 @@ export function useGithubBranches( if (!lastPage.hasMore) return undefined; return allPages.reduce((n, p) => n + p.branches.length, 0); }, - staleTime: 0, }, ); From 04184d090b6e6f32c9c5021638ad3fdd8ba04ded Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Fri, 10 Apr 2026 14:14:02 +0200 Subject: [PATCH 4/5] chore(code): show spinner only for first cloud page when collapsed --- .../features/git-interaction/components/BranchSelector.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index cb8c073b7..ac9d3fb12 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -97,8 +97,12 @@ export function BranchSelector({ ? "Loading..." : (displayedBranch ?? "No branch"); + // Show the spinner on the trigger while the first page is still loading. + // Once we have branches to show, any "loading more" background work is + // surfaced inside the open picker instead, so the trigger goes back to its + // normal branch icon. const showSpinner = - effectiveLoading || (isCloudMode && cloudBranchesFetchingMore); + effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); const triggerContent = ( From a7efeb5d0781df345166705b9fe4836fd2aba857 Mon Sep 17 00:00:00 2001 From: Vojta Bartos Date: Fri, 10 Apr 2026 14:15:15 +0200 Subject: [PATCH 5/5] chore(code): pausing/resuming fetching branches from GH --- .../components/BranchSelector.tsx | 19 ++++++++++- .../onboarding/components/TutorialStep.tsx | 4 +++ .../task-detail/components/TaskInput.tsx | 4 +++ .../src/renderer/hooks/useIntegrations.ts | 32 ++++++++++++++++--- 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx index ac9d3fb12..fa2264aec 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -22,6 +22,8 @@ interface BranchSelectorProps { cloudBranches?: string[]; cloudBranchesLoading?: boolean; cloudBranchesFetchingMore?: boolean; + onCloudPickerOpen?: () => void; + onCloudBranchCommit?: () => void; taskId?: string; } @@ -38,6 +40,8 @@ export function BranchSelector({ cloudBranches, cloudBranchesLoading, cloudBranchesFetchingMore, + onCloudPickerOpen, + onCloudBranchCommit, taskId, }: BranchSelectorProps) { const [open, setOpen] = useState(false); @@ -90,9 +94,22 @@ export function BranchSelector({ branchName: value, }); } + if (isCloudMode && value) { + // User committed to a branch — pause the background pagination. If they + // later re-open the picker, `onCloudPickerOpen` will resume it from + // wherever the cached pages left off. + onCloudBranchCommit?.(); + } setOpen(false); }; + const handleOpenChange = (next: boolean) => { + setOpen(next); + if (isCloudMode && next) { + onCloudPickerOpen?.(); + } + }; + const displayText = effectiveLoading ? "Loading..." : (displayedBranch ?? "No branch"); @@ -121,7 +138,7 @@ export function BranchSelector({ value={displayedBranch ?? ""} onValueChange={handleBranchChange} open={open} - onOpenChange={setOpen} + onOpenChange={handleOpenChange} size="1" disabled={disabled || !repoPath || cloudStillLoading} > diff --git a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx index 107b94733..03c473876 100644 --- a/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/TutorialStep.tsx @@ -106,6 +106,8 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { data: cloudBranchData, isPending: cloudBranchesLoading, isFetchingMore: cloudBranchesFetchingMore, + pauseLoadingMore: pauseCloudBranchesLoading, + resumeLoadingMore: resumeCloudBranchesLoading, } = useGithubBranches(selectedIntegrationId, selectedRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -363,6 +365,8 @@ export function TutorialStep({ onComplete, onBack }: TutorialStepProps) { cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} cloudBranchesFetchingMore={cloudBranchesFetchingMore} + onCloudPickerOpen={resumeCloudBranchesLoading} + onCloudBranchCommit={pauseCloudBranchesLoading} /> diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 2bb7aee0e..10bacc8af 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -123,6 +123,8 @@ export function TaskInput({ data: cloudBranchData, isPending: cloudBranchesLoading, isFetchingMore: cloudBranchesFetchingMore, + pauseLoadingMore: pauseCloudBranchesLoading, + resumeLoadingMore: resumeCloudBranchesLoading, } = useGithubBranches(selectedIntegrationId, selectedCloudRepository); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -468,6 +470,8 @@ export function TaskInput({ cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} cloudBranchesFetchingMore={cloudBranchesFetchingMore} + onCloudPickerOpen={resumeCloudBranchesLoading} + onCloudBranchCommit={pauseCloudBranchesLoading} /> {workspaceMode === "worktree" && ( { + setPaused(false); + }, [integrationId, repo]); + const query = useAuthenticatedInfiniteQuery( integrationKeys.branches(integrationId, repo), async (client, offset) => { @@ -108,12 +117,21 @@ export function useGithubBranches( }, ); - // Auto-fetch remaining pages in background once the first page arrives + // Auto-fetch remaining pages in the background whenever we are not paused. + // Any in-flight page is allowed to finish and land in the cache; the pause + // just prevents us from kicking off the next one. Resuming picks up from + // wherever `getNextPageParam` computes the next offset to be. useEffect(() => { + if (paused) return; if (query.hasNextPage && !query.isFetchingNextPage) { query.fetchNextPage(); } - }, [query.hasNextPage, query.isFetchingNextPage, query.fetchNextPage]); + }, [ + paused, + query.hasNextPage, + query.isFetchingNextPage, + query.fetchNextPage, + ]); const data = useMemo(() => { if (!query.data?.pages.length) { @@ -125,10 +143,16 @@ export function useGithubBranches( }; }, [query.data?.pages]); + const pauseLoadingMore = useCallback(() => setPaused(true), []); + const resumeLoadingMore = useCallback(() => setPaused(false), []); + return { data, isPending: query.isPending, - isFetchingMore: query.isFetchingNextPage || (query.hasNextPage ?? false), + isFetchingMore: + !paused && (query.isFetchingNextPage || (query.hasNextPage ?? false)), + pauseLoadingMore, + resumeLoadingMore, }; }