diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 28ca08d8d98..169fa8cc7d4 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -58,6 +58,44 @@ type OpenNewResult = | { canceled: false; needsGitInit: true; selectedPath: string } | OpenNewError; +/** + * Parses and transforms raw GitHub PR data from CLI output. + * Filters valid PR objects and maps them to our internal format. + */ +function isRawPullRequest(item: unknown): item is { + number: number; + title: string; + url: string; + state: string; + isDraft: boolean; +} { + if (typeof item !== "object" || item === null) return false; + + const value = item as Record; + return ( + typeof value.number === "number" && + typeof value.title === "string" && + typeof value.url === "string" && + typeof value.state === "string" && + typeof value.isDraft === "boolean" + ); +} + +function parsePullRequests(raw: unknown) { + if (!Array.isArray(raw)) return []; + + return raw.filter(isRawPullRequest).map((pr) => ({ + prNumber: pr.number, + title: pr.title, + url: pr.url, + state: pr.isDraft + ? "draft" + : pr.state === "OPEN" + ? "open" + : pr.state.toLowerCase(), + })); +} + type FolderOutcome = | { status: "success"; project: Project } | { status: "needsGitInit"; selectedPath: string } @@ -326,40 +364,53 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { { cwd: project.mainRepoPath }, ); const raw: unknown = JSON.parse(stdout.trim() || "[]"); - if (!Array.isArray(raw)) return []; - return raw - .filter( - ( - item: unknown, - ): item is { - number: number; - title: string; - url: string; - state: string; - isDraft: boolean; - } => - typeof item === "object" && - item !== null && - "number" in item && - "title" in item && - "url" in item, - ) - .map((pr) => ({ - prNumber: pr.number, - title: pr.title, - url: pr.url, - state: pr.isDraft - ? "draft" - : pr.state === "OPEN" - ? "open" - : pr.state.toLowerCase(), - })); + return parsePullRequests(raw); } catch (err) { console.warn("[listPullRequests] Failed to list PRs:", err); return []; } }), + searchPullRequests: publicProcedure + .input( + z.object({ + projectId: z.string(), + query: z.string(), + }), + ) + .query(async ({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); + if (!project) return []; + + try { + const { stdout } = await execWithShellEnv( + "gh", + [ + "pr", + "list", + "--state", + "all", + "--search", + input.query, + "--limit", + "100", + "--json", + "number,title,url,state,isDraft", + ], + { cwd: project.mainRepoPath, timeout: 10_000 }, + ); + const raw: unknown = JSON.parse(stdout.trim() || "[]"); + return parsePullRequests(raw); + } catch (err) { + console.warn("[searchPullRequests] Failed to search PRs:", err); + return []; + } + }), + listIssues: publicProcedure .input(z.object({ projectId: z.string() })) .query(async ({ input }) => { diff --git a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx index 1909bacffee..960318e55fe 100644 --- a/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx +++ b/apps/desktop/src/renderer/components/NewWorkspaceModal/components/PromptGroup/PromptGroup.tsx @@ -1338,6 +1338,8 @@ ${sanitizeText(truncatedBody)}`; onOpenChange={setPRLinkOpen} onSelect={setLinkedPR} projectId={projectId} + githubOwner={project?.githubOwner ?? null} + repoName={project?.mainRepoPath.split("/").pop() ?? null} anchorRef={plusMenuRef} /> void; onSelect: (pr: SelectedPR) => void; projectId: string | null; + githubOwner: string | null; + repoName: string | null; anchorRef: RefObject; } +function parseGitHubPullRequestUrl(query: string): { + owner: string; + repo: string; + prNumber: string; +} | null { + const match = query.match( + /^https?:\/\/(?:www\.)?github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)(?:[/?#].*)?$/i, + ); + + if (!match) return null; + + return { + owner: match[1], + repo: match[2], + prNumber: match[3], + }; +} + export function PRLinkCommand({ open, onOpenChange, onSelect, projectId, + githubOwner, + repoName, anchorRef, }: PRLinkCommandProps) { const [searchQuery, setSearchQuery] = useState(""); + const debouncedQuery = useDebouncedValue(searchQuery, 300); + const trimmedQuery = searchQuery.trim(); // Immediate trim for UI decisions + const debouncedTrimmed = debouncedQuery.trim(); // Debounced trim for RPC calls + + // Detect if we're in the pending debounce state + const isPendingDebounce = trimmedQuery !== debouncedTrimmed; + + const parsedPullRequestUrl = useMemo(() => { + return parseGitHubPullRequestUrl(debouncedTrimmed); + }, [debouncedTrimmed]); + + const selectedRepositoryLabel = useMemo(() => { + if (!githubOwner || !repoName) return null; + return `${githubOwner}/${repoName}`; + }, [githubOwner, repoName]); - const { data: pullRequests, isLoading } = + const pastedRepository = useMemo(() => { + if (!parsedPullRequestUrl) return null; + return `${parsedPullRequestUrl.owner}/${parsedPullRequestUrl.repo}`.toLowerCase(); + }, [parsedPullRequestUrl]); + + const isCrossRepositoryUrl = Boolean( + selectedRepositoryLabel && + pastedRepository && + pastedRepository !== selectedRepositoryLabel.toLowerCase(), + ); + + // Search by PR number when the pasted URL matches the selected repository. + const effectiveQuery = parsedPullRequestUrl + ? isCrossRepositoryUrl + ? "" + : parsedPullRequestUrl.prNumber + : debouncedTrimmed; + + // Fetch recent PRs for browsing (only when no search query) + const { data: recentPRs, isLoading: isLoadingRecent } = electronTrpc.projects.listPullRequests.useQuery( { projectId: projectId ?? "" }, - { enabled: !!projectId && open }, + { enabled: !!projectId && open && !debouncedTrimmed }, ); - const prsWithSearchField = useMemo( - () => - (pullRequests ?? []).map((pr) => ({ - ...pr, - prNumberStr: String(pr.prNumber), - })), - [pullRequests], - ); + // Server-side search when user types (use debounced for RPC) + const { data: searchResults, isLoading: isSearching } = + electronTrpc.projects.searchPullRequests.useQuery( + { projectId: projectId ?? "", query: effectiveQuery }, + { + enabled: + !!projectId && open && !!effectiveQuery && !isCrossRepositoryUrl, + }, + ); - const prFuse = useMemo( - () => - new Fuse(prsWithSearchField, { - keys: [ - { name: "prNumberStr", weight: 3 }, - { name: "title", weight: 2 }, - ], - threshold: 0.4, - ignoreLocation: true, - }), - [prsWithSearchField], - ); + const pullRequests = useMemo(() => { + if (isCrossRepositoryUrl) { + return []; + } - const searchResults = useMemo(() => { - if (!prsWithSearchField.length) return []; - if (!searchQuery) { - return prsWithSearchField.slice(0, MAX_RESULTS); + // Use debounced value for mode decision to avoid empty gap + if (debouncedTrimmed) { + return searchResults ?? []; } - const urlMatch = prsWithSearchField.find((pr) => pr.url === searchQuery); - if (urlMatch) return [urlMatch]; - return prFuse - .search(searchQuery, { limit: MAX_RESULTS }) - .map((r) => r.item); - }, [prsWithSearchField, searchQuery, prFuse]); + return recentPRs ?? []; + }, [debouncedTrimmed, isCrossRepositoryUrl, searchResults, recentPRs]); + + const isLoading = isCrossRepositoryUrl + ? false + : debouncedTrimmed + ? isSearching || isPendingDebounce + : isLoadingRecent; const handleClose = () => { setSearchQuery(""); onOpenChange(false); }; - const handleSelect = (pr: (typeof searchResults)[number]) => { + const handleSelect = (pr: (typeof pullRequests)[number]) => { onSelect({ prNumber: pr.prNumber, title: pr.title, @@ -117,18 +165,28 @@ export function PRLinkCommand({ onValueChange={setSearchQuery} /> - {searchResults.length === 0 && ( + {pullRequests.length === 0 && ( {isLoading - ? "Loading pull requests..." - : "No open pull requests found."} + ? debouncedTrimmed + ? "Searching..." + : "Loading pull requests..." + : isCrossRepositoryUrl + ? `PR URL must match ${selectedRepositoryLabel}.` + : debouncedTrimmed + ? "No pull requests found." + : "No open pull requests."} )} - {searchResults.length > 0 && ( + {pullRequests.length > 0 && ( - {searchResults.map((pr) => ( + {pullRequests.map((pr) => ( >>>>>> origin "dependencies": { "@ai-sdk/anthropic": "^3.0.43", "@ai-sdk/openai": "3.0.36",