feat: server-side GitHub search for issue/PR pickers (#1352)

Replace local-only filtering in GitHub issue/PR picker dialogs with
server-side GitHub Search API queries. Search text is sent as a query
parameter to the server, which uses the GitHub Search API
(issuesAndPullRequests endpoint) with repo: qualifiers including fork
network support. Results are debounced at 350ms to respect API rate
limits.

- Add query parameter to GitHubAPI issuesList/prsList interface
- Server routes use Search API when query is present, standard list
  endpoint when absent
- Fork networks handled via repo:owner/repo OR repo:owner/upstream
- PR search fetches full PR details after Search API for head/base/draft
  fields
- Remove local filter memos from all three picker dialogs
- Add debounced search effect with abort controller cleanup
- Update VS Code backend and webview API for parity
- Update search placeholders in all locales

Closes #1350

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Tom Rochette
2026-06-08 15:37:42 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 5589ca991a
commit 7b1b3167a4
16 changed files with 457 additions and 149 deletions
@@ -26,6 +26,7 @@ import { parseModelIdentifier } from '@/lib/modelIdentifier';
import { useDeviceInfo } from '@/lib/device';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary, GitHubRepoSelector } from '@/lib/api/types';
import { useI18n } from '@/lib/i18n';
@@ -99,6 +100,10 @@ export function GitHubIssuePickerDialog({
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
const debouncedQuery = useDebouncedValue(query, 350);
const isTextSearch = debouncedQuery.trim().length > 0 && !directNumber;
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
@@ -137,6 +142,38 @@ export function GitHubIssuePickerDialog({
}
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory, t]);
React.useEffect(() => {
if (!open || !projectDirectory) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
if (!github?.issuesList) return;
if (!debouncedQuery.trim() || directNumber) {
void refresh();
return;
}
const controller = new AbortController();
setIsLoading(true);
setError(null);
github.issuesList(projectDirectory, { page: 1, query: debouncedQuery.trim() })
.then((next) => {
if (controller.signal.aborted) return;
setResult(next);
setIssues(next.issues ?? []);
setPage(next.page ?? 1);
setHasMore(Boolean(next.hasMore));
})
.catch((e) => {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [open, projectDirectory, github, githubAuthChecked, githubAuthStatus, debouncedQuery, directNumber, refresh, t]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!github?.issuesList) return;
@@ -146,7 +183,9 @@ export function GitHubIssuePickerDialog({
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = await github.issuesList(projectDirectory, { page: nextPage });
const next = isTextSearch
? await github.issuesList(projectDirectory, { page: nextPage, query: debouncedQuery.trim() })
: await github.issuesList(projectDirectory, { page: nextPage });
setResult(next);
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
setPage(next.page ?? nextPage);
@@ -157,7 +196,7 @@ export function GitHubIssuePickerDialog({
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory, t]);
}, [github, hasMore, isLoading, isLoadingMore, isTextSearch, debouncedQuery, page, projectDirectory, t]);
React.useEffect(() => {
if (!open) {
@@ -194,17 +233,6 @@ export function GitHubIssuePickerDialog({
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter((issue) => {
if (String(issue.number) === q.replace(/^#/, '')) return true;
return issue.title.toLowerCase().includes(q);
});
}, [issues, query]);
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
const configState = useConfigStore.getState();
const visibleAgents = configState.getVisibleAgents();
@@ -519,11 +547,11 @@ export function GitHubIssuePickerDialog({
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
{issues.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{debouncedQuery.trim() ? t('session.githubIssuePicker.empty.noIssuesFound') : t('session.githubIssuePicker.empty.noOpenIssuesFound')}</div>
) : null}
{filtered.map((issue) => (
{issues.map((issue) => (
<div
key={`${issue.sourceRepo?.owner ?? ''}-${issue.sourceRepo?.repo ?? ''}-${issue.number}`}
className={cn(