diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 6f6e7280b..53944ed46 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1253,6 +1253,7 @@ export class PostHogAPIClient { repo: string, offset: number, limit: number, + search?: string, ): Promise<{ branches: string[]; defaultBranch: string | null; @@ -1265,6 +1266,9 @@ export class PostHogAPIClient { url.searchParams.set("repo", repo); url.searchParams.set("offset", String(offset)); url.searchParams.set("limit", String(limit)); + if (search?.trim()) { + url.searchParams.set("search", search.trim()); + } const response = await this.api.fetcher.fetch({ method: "get", url, @@ -1305,13 +1309,44 @@ export class PostHogAPIClient { } const data = await response.json(); + return this.normalizeGithubRepositories(data); + } - const repos = - data.repositories ?? data.results ?? (Array.isArray(data) ? data : []); - return repos.map((repo: string | { full_name?: string; name?: string }) => { - if (typeof repo === "string") return repo; - return (repo.full_name ?? repo.name ?? "").toLowerCase(); + async refreshGithubRepositories( + integrationId: string | number, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, + ); + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path: `/api/environments/${teamId}/integrations/${integrationId}/github_repos/refresh/`, }); + + if (!response.ok) { + throw new Error( + `Failed to refresh GitHub repositories: ${response.statusText}`, + ); + } + + const data = await response.json(); + return this.normalizeGithubRepositories(data); + } + + private normalizeGithubRepositories(data: unknown): string[] { + const repos = + (data as { repositories?: unknown[] }).repositories ?? + (data as { results?: unknown[] }).results ?? + (Array.isArray(data) ? data : []); + + return (repos as (string | { full_name?: string; name?: string })[]).map( + (repo) => { + if (typeof repo === "string") return repo; + return (repo.full_name ?? repo.name ?? "").toLowerCase(); + }, + ); } async getAgents() { diff --git a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx index b9e4e9ca9..d67091cc7 100644 --- a/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx +++ b/apps/code/src/renderer/features/folder-picker/components/GitHubRepoPicker.tsx @@ -1,5 +1,5 @@ import { Tooltip } from "@components/ui/Tooltip"; -import { GithubLogo } from "@phosphor-icons/react"; +import { ArrowClockwise, GithubLogo } from "@phosphor-icons/react"; import { Button, Combobox, @@ -16,7 +16,7 @@ const COMBOBOX_LIMIT = 50; interface GitHubRepoPickerProps { value: string | null; - onChange: (repo: string) => void; + onChange: (repo: string | null) => void; repositories: string[]; isLoading: boolean; placeholder?: string; @@ -25,6 +25,8 @@ interface GitHubRepoPickerProps { anchor?: RefObject; /** When false, the list is shown without a filter field (e.g. short lists in modals). */ showSearchInput?: boolean; + onRefresh?: () => void; + isRefreshing?: boolean; } export function GitHubRepoPicker({ @@ -36,6 +38,8 @@ export function GitHubRepoPicker({ disabled = false, anchor, showSearchInput = true, + onRefresh, + isRefreshing = false, }: GitHubRepoPickerProps) { const triggerRef = useRef(null); const [searchQuery, setSearchQuery] = useState(""); @@ -91,7 +95,7 @@ export function GitHubRepoPicker({ limit={COMBOBOX_LIMIT} value={value} onValueChange={(v) => { - if (v) onChange(v as string); + onChange(v ? (v as string) : null); }} inputValue={searchQuery} onInputValueChange={setSearchQuery} @@ -118,7 +122,33 @@ export function GitHubRepoPicker({ className="min-w-[280px]" > {showSearchInput ? ( - +
+
+ +
+ {onRefresh ? ( + + ) : null} +
) : null} No repositories found. 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 6af565fd4..c416f9816 100644 --- a/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx +++ b/apps/code/src/renderer/features/git-interaction/components/BranchSelector.tsx @@ -1,7 +1,13 @@ import { useGitInteractionStore } from "@features/git-interaction/state/gitInteractionStore"; import { getSuggestedBranchName } from "@features/git-interaction/utils/getSuggestedBranchName"; import { invalidateGitBranchQueries } from "@features/git-interaction/utils/gitCacheKeys"; -import { CaretDown, GitBranch, Plus, Spinner } from "@phosphor-icons/react"; +import { + ArrowClockwise, + CaretDown, + GitBranch, + Plus, + Spinner, +} from "@phosphor-icons/react"; import { Button, Combobox, @@ -10,6 +16,7 @@ import { ComboboxInput, ComboboxItem, ComboboxList, + ComboboxListFooter, ComboboxTrigger, } from "@posthog/quill"; import { useTRPC } from "@renderer/trpc"; @@ -30,10 +37,17 @@ interface BranchSelectorProps { selectedBranch?: string | null; onBranchSelect?: (branch: string | null) => void; cloudBranches?: string[]; + cloudBranchesHasMore?: boolean; cloudBranchesLoading?: boolean; cloudBranchesFetchingMore?: boolean; + cloudSearchQuery?: string; onCloudPickerOpen?: () => void; + onCloudPickerClose?: () => void; + onCloudSearchChange?: (value: string) => void; + onCloudLoadMore?: () => void; onCloudBranchCommit?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; taskId?: string; anchor?: RefObject; } @@ -48,10 +62,17 @@ export function BranchSelector({ selectedBranch, onBranchSelect, cloudBranches, + cloudBranchesHasMore, cloudBranchesLoading, cloudBranchesFetchingMore, + cloudSearchQuery, onCloudPickerOpen, + onCloudPickerClose, + onCloudSearchChange, + onCloudLoadMore, onCloudBranchCommit, + onRefresh, + isRefreshing = false, taskId, anchor, }: BranchSelectorProps) { @@ -101,14 +122,14 @@ export function BranchSelector({ const handleBranchChange = (value: string | null) => { if (!value) return; if (isSelectionOnly) { - onBranchSelect?.(value || null); - } else if (value && value !== currentBranch) { + onBranchSelect?.(value); + } else if (value !== currentBranch) { checkoutMutation.mutate({ directoryPath: repoPath as string, branchName: value, }); } - if (isCloudMode && value) { + if (isCloudMode) { onCloudBranchCommit?.(); } setOpen(false); @@ -118,6 +139,8 @@ export function BranchSelector({ setOpen(next); if (isCloudMode && next) { onCloudPickerOpen?.(); + } else if (isCloudMode && !next) { + onCloudPickerClose?.(); } }; @@ -129,18 +152,24 @@ export function BranchSelector({ effectiveLoading || (isCloudMode && open && cloudBranchesFetchingMore); const isDisabled = !!(disabled || !repoPath || cloudStillLoading); + const inputValue = isCloudMode ? (cloudSearchQuery ?? "") : searchQuery; return ( onCloudSearchChange?.((value as string | null) ?? "") + : setSearchQuery + } onValueChange={(v) => handleBranchChange(v as string | null)} - inputValue={searchQuery} - onInputValueChange={setSearchQuery} open={open} - onOpenChange={(nextOpen) => handleOpenChange(nextOpen)} + onOpenChange={handleOpenChange} disabled={isDisabled} + filter={isCloudMode ? null : undefined} > - +
+
+ +
+ {onRefresh ? ( + + ) : null} +
- {isCloudMode && cloudBranchesFetchingMore && ( + {isCloudMode && cloudBranchesFetchingMore ? (
Loading more ({branches.length})…
- )} + ) : null} No branches found. @@ -196,7 +254,7 @@ export function BranchSelector({ )}
- {!isCloudMode && ( + {!isCloudMode ? ( - )} + ) : null} + + {isCloudMode && cloudBranchesHasMore ? ( + +
+ +
+
+ ) : null} - {branches.length > COMBOBOX_LIMIT && ( + {!isCloudMode && branches.length > COMBOBOX_LIMIT ? (
{searchQuery - ? `Showing up to ${COMBOBOX_LIMIT} matches — refine your search` - : `Showing ${COMBOBOX_LIMIT} of ${branches.length} — type to filter`} + ? `Showing up to ${COMBOBOX_LIMIT} matches - refine your search` + : `Showing ${COMBOBOX_LIMIT} of ${branches.length} - type to filter`}
- )} + ) : null} ); diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index f72c75828..78230b526 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -63,6 +63,8 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { repositories, getIntegrationIdForRepo, isLoadingRepos, + isRefreshingRepos, + refreshRepositories, hasGithubIntegration, } = useRepositoryIntegration(); const [repo, setRepo] = useState(null); @@ -70,6 +72,9 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { const [connecting, setConnecting] = useState(false); const pollTimerRef = useRef | null>(null); const pollTimeoutRef = useRef | null>(null); + const selectedIntegrationId = repo + ? getIntegrationIdForRepo(repo) + : undefined; const stopPolling = useCallback(() => { if (pollTimerRef.current) { @@ -84,6 +89,14 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { useEffect(() => stopPolling, [stopPolling]); + useEffect(() => { + if (isLoadingRepos || !repo || repositories.includes(repo)) { + return; + } + + setRepo(null); + }, [isLoadingRepos, repo, repositories]); + // Stop polling once integration appears useEffect(() => { if (hasGithubIntegration && connecting) { @@ -141,10 +154,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { }, [cloudRegion, projectId, client, stopPolling]); const handleSubmit = useCallback(async () => { - const githubIntegrationId = repo - ? getIntegrationIdForRepo(repo) - : undefined; - if (!projectId || !client || !repo || !githubIntegrationId) return; + if (!projectId || !client || !repo || !selectedIntegrationId) return; setLoading(true); try { @@ -154,7 +164,7 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { repository: repo, auth_method: { selection: "oauth", - github_integration_id: githubIntegrationId, + github_integration_id: selectedIntegrationId, }, schemas: schemasPayload("github"), }, @@ -168,7 +178,21 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { } finally { setLoading(false); } - }, [projectId, client, repo, getIntegrationIdForRepo, onComplete]); + }, [projectId, client, onComplete, repo, selectedIntegrationId]); + + const handleRefreshRepositories = useCallback(() => { + void refreshRepositories() + .then(() => { + toast.success("Repositories refreshed"); + }) + .catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to refresh repositories", + ); + }); + }, [refreshRepositories]); if (!hasGithubIntegration) { return ( @@ -207,6 +231,8 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { onChange={setRepo} repositories={repositories} isLoading={isLoadingRepos} + isRefreshing={isRefreshingRepos} + onRefresh={handleRefreshRepositories} placeholder="Select repository..." size="2" /> @@ -215,7 +241,11 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { - 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 fda06c82a..836c3e47e 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -31,6 +31,7 @@ import { Flex, Text } from "@radix-ui/themes"; import { useAuthStore } from "@renderer/features/auth/stores/authStore"; import { useDraftStore } from "@renderer/features/message-editor/stores/draftStore"; import { trpcClient, useTRPC } from "@renderer/trpc/client"; +import { toast } from "@renderer/utils/toast"; import { useNavigationStore } from "@stores/navigationStore"; import { useQuery } from "@tanstack/react-query"; import { getFilePath } from "@utils/getFilePath"; @@ -78,6 +79,8 @@ export function TaskInput({ const [isDraggingFile, setIsDraggingFile] = useState(false); const [isCreatingBranch, setIsCreatingBranch] = useState(false); const [selectedBranch, setSelectedBranch] = useState(null); + const [cloudBranchSearchQuery, setCloudBranchSearchQuery] = useState(""); + const [isCloudBranchPickerOpen, setIsCloudBranchPickerOpen] = useState(false); const [selectedEnvironment, setSelectedEnvironmentRaw] = useState< string | null >(null); @@ -104,8 +107,13 @@ export function TaskInput({ const setAdapter = (newAdapter: AgentAdapter) => setLastUsedAdapter(newAdapter); - const { repositories, getIntegrationIdForRepo, isLoadingRepos } = - useRepositoryIntegration(); + const { + repositories, + getIntegrationIdForRepo, + isLoadingRepos, + isRefreshingRepos, + refreshRepositories, + } = useRepositoryIntegration(); const [selectedRepository, setSelectedRepository] = useState( () => lastUsedCloudRepository?.toLowerCase() ?? null, ); @@ -124,10 +132,17 @@ export function TaskInput({ const { data: cloudBranchData, isPending: cloudBranchesLoading, + isRefreshing: cloudBranchesRefreshing, isFetchingMore: cloudBranchesFetchingMore, - pauseLoadingMore: pauseCloudBranchesLoading, - resumeLoadingMore: resumeCloudBranchesLoading, - } = useGithubBranches(selectedIntegrationId, selectedCloudRepository); + hasMore: cloudBranchesHasMore, + loadMore: loadMoreCloudBranches, + refresh: refreshCloudBranches, + } = useGithubBranches( + selectedIntegrationId, + selectedCloudRepository, + cloudBranchSearchQuery, + isCloudBranchPickerOpen, + ); const cloudBranches = cloudBranchData?.branches; const cloudDefaultBranch = cloudBranchData?.defaultBranch ?? null; @@ -168,7 +183,13 @@ export function TaskInput({ }, [selectedDirectory, newBranchName, gitActions]); const handleRepositorySelect = useCallback( - (repo: string) => { + (repo: string | null) => { + if (!repo) { + setSelectedRepository(null); + setLastUsedCloudRepository(null); + return; + } + const normalizedRepo = repo.toLowerCase(); setSelectedRepository(normalizedRepo); setLastUsedCloudRepository(normalizedRepo); @@ -176,6 +197,41 @@ export function TaskInput({ [setLastUsedCloudRepository], ); + const handleRefreshRepositories = useCallback(() => { + void refreshRepositories().catch((error) => { + toast.error("Failed to refresh repositories", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + }); + }, [refreshRepositories]); + + const handleRefreshBranches = useCallback(() => { + void refreshCloudBranches().catch((error) => { + toast.error("Failed to refresh branches", { + description: + error instanceof Error ? error.message : "Please try again.", + }); + }); + }, [refreshCloudBranches]); + + const handleCloudBranchPickerOpen = useCallback(() => { + setIsCloudBranchPickerOpen(true); + }, []); + + const handleCloudBranchPickerClose = useCallback(() => { + setIsCloudBranchPickerOpen(false); + setCloudBranchSearchQuery(""); + }, []); + + const handleCloudBranchSearchChange = useCallback((value: string) => { + setCloudBranchSearchQuery(value); + }, []); + + const handleLoadMoreCloudBranches = useCallback(() => { + loadMoreCloudBranches(); + }, [loadMoreCloudBranches]); + const { modeOption, modelOption, @@ -220,6 +276,11 @@ export function TaskInput({ } }, [view.folderId, folders]); + useEffect(() => { + setCloudBranchSearchQuery(""); + setIsCloudBranchPickerOpen(false); + }, []); + const effectiveRepoPath = workspaceMode === "cloud" ? selectedCloudRepository : selectedDirectory; @@ -462,6 +523,8 @@ export function TaskInput({ onChange={handleRepositorySelect} repositories={repositories} isLoading={isLoadingRepos} + isRefreshing={isRefreshingRepos} + onRefresh={handleRefreshRepositories} placeholder="Select repository..." size="1" disabled={isCreatingTask} @@ -490,15 +553,23 @@ export function TaskInput({ isCreatingTask || (workspaceMode === "cloud" && !selectedCloudRepository) } - loading={branchLoading} + loading={workspaceMode === "cloud" ? false : branchLoading} workspaceMode={workspaceMode} selectedBranch={selectedBranch} onBranchSelect={setSelectedBranch} cloudBranches={cloudBranches} cloudBranchesLoading={cloudBranchesLoading} + isRefreshing={cloudBranchesRefreshing} cloudBranchesFetchingMore={cloudBranchesFetchingMore} - onCloudPickerOpen={resumeCloudBranchesLoading} - onCloudBranchCommit={pauseCloudBranchesLoading} + cloudBranchesHasMore={cloudBranchesHasMore} + cloudSearchQuery={cloudBranchSearchQuery} + onCloudPickerOpen={handleCloudBranchPickerOpen} + onCloudPickerClose={handleCloudBranchPickerClose} + onCloudSearchChange={handleCloudBranchSearchChange} + onCloudLoadMore={handleLoadMoreCloudBranches} + onRefresh={ + workspaceMode === "cloud" ? handleRefreshBranches : undefined + } anchor={buttonGroupRef} /> diff --git a/apps/code/src/renderer/hooks/useIntegrations.ts b/apps/code/src/renderer/hooks/useIntegrations.ts index cb1e32fd5..571c64aaa 100644 --- a/apps/code/src/renderer/hooks/useIntegrations.ts +++ b/apps/code/src/renderer/hooks/useIntegrations.ts @@ -5,8 +5,14 @@ import { useIntegrationSelectors, useIntegrationStore, } from "@features/integrations/stores/integrationStore"; -import { useQueries } from "@tanstack/react-query"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQueries, useQueryClient } from "@tanstack/react-query"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useState, +} from "react"; import { useAuthenticatedInfiniteQuery } from "./useAuthenticatedInfiniteQuery"; import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; @@ -15,8 +21,8 @@ const integrationKeys = { list: () => [...integrationKeys.all, "list"] as const, repositories: (integrationId?: number) => [...integrationKeys.all, "repositories", integrationId] as const, - branches: (integrationId?: number, repo?: string | null) => - [...integrationKeys.all, "branches", integrationId, repo] as const, + branches: (integrationId?: number, repo?: string | null, search?: string) => + [...integrationKeys.all, "branches", integrationId, repo, search] as const, }; export function useIntegrations() { @@ -68,11 +74,8 @@ 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; +const BRANCHES_FIRST_PAGE_SIZE = 50; +const BRANCHES_PAGE_SIZE = 100; interface GithubBranchesPage { branches: string[]; @@ -83,18 +86,14 @@ interface GithubBranchesPage { export function useGithubBranches( integrationId?: number, repo?: string | null, + search?: string, + enabled: boolean = true, ) { - // While paused we stop chaining `fetchNextPage` calls. The flag is scoped - // to the current query target and resets whenever it changes, so switching - // repos or integrations starts a fresh fetch. - const [paused, setPaused] = useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on key change - useEffect(() => { - setPaused(false); - }, [integrationId, repo]); + const deferredSearch = useDeferredValue(search?.trim() ?? ""); + const queryEnabled = enabled && !!integrationId && !!repo; const query = useAuthenticatedInfiniteQuery( - integrationKeys.branches(integrationId, repo), + integrationKeys.branches(integrationId, repo, deferredSearch), async (client, offset) => { if (!integrationId || !repo) { return { branches: [], defaultBranch: null, hasMore: false }; @@ -106,9 +105,11 @@ export function useGithubBranches( repo, offset, pageSize, + deferredSearch, ); }, { + enabled: queryEnabled, initialPageParam: 0, getNextPageParam: (lastPage, allPages) => { if (!lastPage.hasMore) return undefined; @@ -117,22 +118,6 @@ export function useGithubBranches( }, ); - // 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(); - } - }, [ - paused, - query.hasNextPage, - query.isFetchingNextPage, - query.fetchNextPage, - ]); - const data = useMemo(() => { if (!query.data?.pages.length) { return { branches: [] as string[], defaultBranch: null }; @@ -143,23 +128,36 @@ export function useGithubBranches( }; }, [query.data?.pages]); - const pauseLoadingMore = useCallback(() => setPaused(true), []); - const resumeLoadingMore = useCallback(() => setPaused(false), []); + const loadMore = useCallback(() => { + if (!query.hasNextPage || query.isFetchingNextPage) { + return; + } + + void query.fetchNextPage(); + }, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]); + + const refresh = useCallback(async () => { + await query.refetch(); + }, [query.refetch]); return { data, - isPending: query.isPending, - isFetchingMore: - !paused && (query.isFetchingNextPage || (query.hasNextPage ?? false)), - pauseLoadingMore, - resumeLoadingMore, + isPending: queryEnabled ? query.isPending : false, + isRefreshing: queryEnabled ? query.isRefetching : false, + isFetchingMore: query.isFetchingNextPage, + hasMore: query.hasNextPage ?? false, + loadMore, + refresh, }; } export function useRepositoryIntegration() { + const client = useOptionalAuthenticatedClient(); + const queryClient = useQueryClient(); const { isPending: integrationsPending } = useIntegrations(); const { githubIntegrations, hasGithubIntegration } = useIntegrationSelectors(); + const [isRefreshingRepos, setIsRefreshingRepos] = useState(false); const { repositoryMap, isPending: reposPending } = useAllGithubRepositories(githubIntegrations); @@ -179,11 +177,40 @@ export function useRepositoryIntegration() { [repositoryMap], ); + const refreshRepositories = useCallback(async () => { + if (!githubIntegrations.length || !client) { + return; + } + + setIsRefreshingRepos(true); + + try { + await Promise.all( + githubIntegrations.map((integration) => + client.refreshGithubRepositories(integration.id), + ), + ); + + await Promise.all( + githubIntegrations.map((integration) => + queryClient.refetchQueries({ + queryKey: integrationKeys.repositories(integration.id), + exact: true, + }), + ), + ); + } finally { + setIsRefreshingRepos(false); + } + }, [client, githubIntegrations, queryClient]); + return { repositories, getIntegrationIdForRepo, isRepoInIntegration, isLoadingRepos: integrationsPending || reposPending, + isRefreshingRepos, + refreshRepositories, hasGithubIntegration, }; }