fix(issues): replace cursor-based pagination with page-number Search API (#8680)

* fix(issues): replace cursor-based pagination with page-number Search API

Problem
=======
Issue pagination (#8649) had two bugs:
1. Pages 6-16 were unreachable — clicking page 16 highlighted page 5;
   clicking 6/7 did nothing. The old cursor-based approach
   (updated:<CURSOR) broke with Search API's relevance sorting —
   pages after the first few returned no items even though more
   issues existed.
2. Issue numbers appeared out of order on loaded pages (e.g. #1082
   between #1308 and #1499), because client-side sort used
   updatedAt instead of issue number.

Root Cause
==========
The pagination used two separate GitHub API strategies:
- Initial page 0 load: REST endpoints (repos/:owner/:repo/issues,
  repos/:owner/:repo/pulls) sorted by updatedAt
- Subsequent pages: Search API with cursor (updated:<DATE)

These two sources returned items in different orders, causing items
to go missing or appear on wrong pages across page boundaries.

Solution
========
1. Unified on GitHub Search API for all pages — initial load and
   pagination both use search/issues?q=...&page=N, eliminating the
   REST-vs-Search inconsistency.
2. Changed from cursor-based (update:<DATE) to page-number-based
   pagination (page=N), which the Search API supports natively.
3. Switched client-side sort from updatedAt to issue number
   (sortWorkItemsByNumber), matching GitHub's default Issues view.
4. Parallelized page fetches in handleLoadNextPage — clicking page
   16 now fetches all intermediate pages concurrently (~2s) instead
   of sequentially (~30s).
5. Cleaned up dead legacy gh issue list / gh pr list code path,
   extracted quoteForSearch helper, shortened overlong comments.

Files changed: 11 files, +140/-127 lines

Closes #8649

* chore: remove unrelated merge formatting

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
fsdwen 2026-07-16 09:52:37 +08:00 committed by GitHub
parent b0def3b130
commit 78d2b958bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 529 additions and 485 deletions

View File

@ -49,6 +49,47 @@ vi.mock('./rate-limit', () => ({
import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client'
const PR_LIST_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
function issueSearchArgs(
ownerRepo: string,
options: { noCache?: boolean; query?: string } = {}
): string[] {
const query = options.query ?? 'is:issue is:open'
return [
'api',
...(options.noCache ? [] : ['--cache', '120s']),
`search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
]
}
function prListArgs(ownerRepo: string, query = 'is:pr is:open'): string[] {
return [
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
PR_LIST_FIELDS,
'--repo',
ownerRepo,
'--search',
`${query} sort:created-desc`
]
}
function decodedIssueSearchPath(callIndex: number): string {
const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined
const apiPath = args?.find((arg) => arg.startsWith('search/issues?'))
expect(apiPath).toBeDefined()
return decodeURIComponent(apiPath ?? '')
}
describe('GitHub issue source split', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
@ -115,26 +156,12 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
cwd: '/repo-root'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
cwd: '/repo-root'
})
})
it('omits gh api cache args for no-cache recent work-item requests', async () => {
@ -148,14 +175,12 @@ describe('GitHub issue source split', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['api', 'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', 'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'],
issueSearchArgs('stablyai/orca', { noCache: true }),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
cwd: '/repo-root'
})
})
it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => {
@ -177,26 +202,8 @@ describe('GitHub issue source split', () => {
{}
)
expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/fork/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {})
})
it('uses upstream for issue-only queries and origin for PR-only queries', async () => {
@ -206,10 +213,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, 'is:issue')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--repo', 'stablyai/orca']),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue')
ghExecFileAsyncMock.mockClear()
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
@ -237,16 +241,9 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/pulls?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), {
cwd: '/repo-root'
})
})
it("uses upstream for queried PRs when preference='upstream'", async () => {
@ -515,16 +512,9 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
cwd: '/repo-root'
})
expect(result.issueSourceFellBack).toBeUndefined()
})
@ -540,16 +530,9 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('solo/orca'), {
cwd: '/repo-root'
})
})
it("preference='upstream' + upstream exists → queries upstream", async () => {
@ -564,13 +547,7 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/stablyai/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue is:open')
expect(result.issueSourceFellBack).toBeUndefined()
})
@ -586,13 +563,7 @@ describe('GitHub issue source split', () => {
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open')
expect(result.issueSourceFellBack).toBe(true)
})
@ -608,13 +579,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'origin')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/fork/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:fork/orca is:issue is:open')
})
it("preference='origin' + no upstream → queries origin", async () => {
@ -629,13 +594,7 @@ describe('GitHub issue source split', () => {
await listWorkItems('/repo-root', 10, undefined, undefined, 'origin')
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
'repos/solo/orca/issues?per_page=10&state=open&sort=updated&direction=desc'
]),
{ cwd: '/repo-root' }
)
expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open')
})
it('surfaces upstreamCandidate in sources regardless of effective preference', async () => {

View File

@ -180,18 +180,12 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,assignees',
'--repo',
'acme/widgets',
'--assignee',
'@me',
'--search',
'sort:updated-desc'
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
@ -202,14 +196,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--assignee',
'@me',
'--search',
'sort:updated-desc'
'is:pr assignee:@me sort:created-desc'
],
{ cwd: '/repo-root' }
)
@ -218,24 +212,6 @@ describe('listWorkItems', () => {
expect(prListFields).toContain('reviewRequests')
expect(prListFields).not.toContain('mergeStateStatus')
expect(items).toEqual([
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat',
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
},
{
id: 'pr:42',
type: 'pr',
@ -257,6 +233,24 @@ describe('listWorkItems', () => {
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
},
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat',
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
}
])
})
@ -404,15 +398,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'open',
'--draft',
'--search',
'sort:updated-desc'
'is:pr is:open draft:true sort:created-desc'
],
{ cwd: '/repo-root' }
)
@ -465,14 +458,14 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'merged',
'--search',
'sort:updated-desc'
'is:pr is:merged sort:created-desc'
],
{ cwd: '/repo-root' }
)
@ -536,7 +529,12 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--state', 'closed', '--search', '-is:merged sort:updated-desc']),
expect.arrayContaining([
'--state',
'all',
'--search',
'is:pr is:closed -is:merged sort:created-desc'
]),
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
@ -605,7 +603,7 @@ describe('listWorkItems', () => {
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'review-requested:@me sort:updated-desc']),
expect.arrayContaining(['--search', 'is:pr is:open review-requested:@me sort:created-desc']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith(
@ -614,32 +612,86 @@ describe('listWorkItems', () => {
)
})
it('pins list ordering to updated-desc so the updatedAt cursor pages consistently', async () => {
it('uses the requested numbered Search API page for issues', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:issue is:open')
await listWorkItems('/repo-root', 10, 'is:issue is:open', 2)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'sort:updated-desc']),
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=2`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
})
it('combines the inclusive updatedAt cursor with updated-desc ordering on later pages', async () => {
it('fetches and slices stable PR results for the requested numbered page', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify(
[1, 2, 3, 4].map((number) => ({
number,
title: `PR ${number}`,
state: 'OPEN',
url: `https://github.com/acme/widgets/pull/${number}`,
labels: [],
updatedAt: `2026-07-0${number}T00:00:00Z`,
author: { login: 'octocat' },
isDraft: false,
headRefName: `feature/${number}`,
headRefOid: `head-${number}`,
baseRefName: 'main'
}))
)
})
await listWorkItems('/repo-root', 10, 'is:issue is:open', '2026-07-01T00:00:00Z')
const { items } = await listWorkItems('/repo-root', 2, 'is:pr is:open', 2)
// Why: the bound is inclusive (`<=`) so boundary items sharing the cursor's
// exact updatedAt aren't skipped; the renderer dedupes the re-fetched rows.
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['--search', 'updated:<=2026-07-01T00:00:00Z sort:updated-desc']),
expect.arrayContaining(['--limit', '4', '--search', 'is:pr is:open sort:created-desc']),
{ cwd: '/repo-root' }
)
expect(items.map((item) => item.number)).toEqual([4, 3])
})
it('filters pull request rows out of issue Search API results', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 2,
title: 'PR-shaped search row',
state: 'open',
html_url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updated_at: '2026-07-02T00:00:00Z',
user: { login: 'octocat' },
pull_request: { url: 'https://api.github.com/repos/acme/widgets/pulls/2' }
},
{
number: 1,
title: 'Issue row',
state: 'open',
html_url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updated_at: '2026-07-01T00:00:00Z',
user: { login: 'octocat' }
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:issue is:open')
expect(items.map((item) => item.id)).toEqual(['issue:1'])
})
it('returns open issues and PRs for the all-open preset query', async () => {
@ -679,18 +731,12 @@ describe('listWorkItems', () => {
const { items } = await listWorkItems('/repo-root', 10, 'is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'list',
'--limit',
'10',
'--json',
'number,title,state,url,labels,updatedAt,author,assignees',
'--repo',
'acme/widgets',
'--state',
'open',
'--search',
'sort:updated-desc'
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
@ -700,29 +746,18 @@ describe('listWorkItems', () => {
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--state',
'open',
'--search',
'sort:updated-desc'
'is:pr is:open sort:created-desc'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
},
{
id: 'pr:2',
type: 'pr',
@ -737,6 +772,17 @@ describe('listWorkItems', () => {
baseRefName: 'main',
headSha: 'head-2',
prRepo: { owner: 'acme', repo: 'widgets' }
},
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
}
])
})

View File

@ -32,7 +32,7 @@ import { isGitHubWorkItemsQueryTooLarge } from '../../shared/github-work-items-q
import { parseTaskQuery, type ParsedTaskQuery } from '../../shared/task-query'
import {
GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE,
sortWorkItemsByUpdatedAt
sortWorkItemsByNumber
} from '../../shared/work-items'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@ -449,17 +449,13 @@ export async function getAuthenticatedViewer(): Promise<GitHubViewer | null> {
}
// Why: main-process maps omit repoId because the IPC handler never receives
// a repo identifier beyond path. The renderer stamps repoId after IPC so
// single-repo and cross-repo items are uniform downstream.
type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
// a repo identifier beyond path. Exported because runtime consumers receive
// listWorkItems results before the renderer stamps repoId.
export type MainWorkItem = Omit<GitHubWorkItem, 'repoId'>
const WORK_ITEM_ISSUE_LIST_JSON_FIELDS = 'number,title,state,url,labels,updatedAt,author,assignees'
// Why: the Tasks pager slices pages on updatedAt, so every list/search fetch
// must return rows newest-updated-first for the cursor to advance correctly.
// This is the search-qualifier spelling of the same contract the REST list
// paths express as `sort=updated&direction=desc`; keep the two in sync (#8649).
const WORK_ITEM_LIST_SORT_QUALIFIER = 'sort:updated-desc'
// Why: issue numbers follow creation order within a repository. Pinning this
// sort keeps gh's rich PR rows aligned with numbered Search API issue pages.
const WORK_ITEM_NUMBER_SORT_QUALIFIER = 'sort:created-desc'
const WORK_ITEM_PR_LIST_JSON_FIELDS =
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
@ -956,78 +952,98 @@ async function fetchPullRequestWorkItem(
return mapPullRequestWorkItem(JSON.parse(stdout) as Record<string, unknown>)
}
function buildWorkItemListArgs(args: {
type WorkItemListRequest = {
args: string[]
offset: number
}
function normalizeWorkItemPage(page: number | undefined): number {
return typeof page === 'number' && Number.isFinite(page) && page >= 1 ? Math.floor(page) : 1
}
function buildWorkItemListRequest(args: {
kind: 'issue' | 'pr'
ownerRepo: OwnerRepo | null
limit: number
query: ParsedTaskQuery
before?: string
}): string[] {
const { kind, ownerRepo, limit, query, before } = args
const fields = kind === 'issue' ? WORK_ITEM_ISSUE_LIST_JSON_FIELDS : WORK_ITEM_PR_LIST_JSON_FIELDS
const command = kind === 'issue' ? ['issue', 'list'] : ['pr', 'list']
const out = [...command, '--limit', String(limit), '--json', fields]
page: number
}): WorkItemListRequest {
const { kind, ownerRepo, limit, query, page } = args
const searchParts: string[] = []
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
if (kind === 'issue' && ownerRepo) {
searchParts.push(`repo:${ownerRepo.owner}/${ownerRepo.repo}`)
}
searchParts.push(kind === 'issue' ? 'is:issue' : 'is:pr')
if (query.state === 'open') {
searchParts.push('is:open')
} else if (query.state === 'closed') {
searchParts.push('is:closed')
if (kind === 'pr') {
searchParts.push('-is:merged')
}
} else if (query.state === 'merged') {
searchParts.push('is:merged')
}
if (query.state) {
out.push('--state', query.state)
if (kind === 'pr' && query.draft) {
searchParts.push('draft:true')
}
// Why: GitHub considers merged PRs as "closed". When the user filters for
// closed-only, exclude merged PRs via the search predicate so the displayed
// and counted results match the user's intent.
const excludeMergedFromClosed = kind === 'pr' && query.state === 'closed'
if (query.assignee) {
out.push('--assignee', query.assignee)
searchParts.push(`assignee:${quoteGitHubSearchValue(query.assignee)}`)
}
if (query.author) {
out.push('--author', query.author)
searchParts.push(`author:${quoteGitHubSearchValue(query.author)}`)
}
if (query.labels.length > 0) {
for (const label of query.labels) {
out.push('--label', label)
searchParts.push(`label:${quoteGitHubSearchValue(label)}`)
}
}
// Why: only add --draft when the user explicitly typed `is:draft`. Previously
// this fired for any PR-scoped open query, which made `is:pr is:open` (the
// "PRs" preset) silently filter to drafts-only.
if (kind === 'pr' && query.draft) {
out.push('--draft')
}
const searchParts: string[] = []
if (excludeMergedFromClosed) {
searchParts.push('-is:merged')
}
// Why: cursor-based pagination. GitHub search supports updated:<=DATE to
// fetch items at or older than the cursor. We use the oldest item's updatedAt
// from the previous page as the cursor. The bound is inclusive (`<=`) so items
// sharing the boundary row's exact updatedAt aren't skipped between pages; the
// renderer dedupes the re-fetched boundary rows by id (#8649).
if (before) {
searchParts.push(`updated:<=${before}`)
}
if (kind === 'pr' && query.reviewRequested) {
searchParts.push(`review-requested:${query.reviewRequested}`)
searchParts.push(`review-requested:${quoteGitHubSearchValue(query.reviewRequested)}`)
}
if (kind === 'pr' && query.reviewedBy) {
searchParts.push(`reviewed-by:${query.reviewedBy}`)
searchParts.push(`reviewed-by:${quoteGitHubSearchValue(query.reviewedBy)}`)
}
if (query.freeText) {
searchParts.push(query.freeText)
}
// Why: pagination cursors slice on updatedAt, but `gh issue list` defaults
// to created-desc and `--search` defaults to best-match. `gh issue/pr list`
// has no --sort flag, so the only lever is the search query — which forces us
// to always emit --search. Without pinning the sort, recently-updated old
// items never appear on any page, so the pager advertises pages the fetch
// chain can never reach (#8649).
searchParts.push(WORK_ITEM_LIST_SORT_QUALIFIER)
if (kind === 'issue') {
return {
args: [
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent(searchParts.join(' '))}&sort=created&order=desc&per_page=${limit}&page=${page}`,
'--jq',
'.items'
],
offset: 0
}
}
// Why: search/issues omits the PR fields used by the Tasks management
// columns. Fetch through gh's rich PR list and slice its stable created sort.
searchParts.push(WORK_ITEM_NUMBER_SORT_QUALIFIER)
const out = [
'pr',
'list',
'--limit',
String(Math.min(page * limit, 1000)),
'--state',
'all',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
]
if (ownerRepo) {
out.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
out.push('--search', searchParts.join(' '))
return out
return { args: out, offset: (page - 1) * limit }
}
// Why: internal shape shared by listRecentWorkItems / listQueriedWorkItems so
@ -1105,6 +1121,7 @@ async function listRecentWorkItems(
issueOwnerRepo: OwnerRepo | null,
prOwnerRepo: OwnerRepo | null,
limit: number,
page: number,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
@ -1112,72 +1129,49 @@ async function listRecentWorkItems(
const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions)
const ghOptions = ghRepoExecOptions(repoContext)
const requiresExplicitRepo = Boolean(connectionId)
const restCacheArgs = noCache ? [] : ['--cache', '120s']
assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo })
const recentQuery = parseTaskQuery('is:open')
const issueRequest = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query: recentQuery,
page
})
const prRequest = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query: recentQuery,
page
})
if (noCache) {
issueRequest.args.splice(1, 2)
}
if (issueOwnerRepo || prOwnerRepo || requiresExplicitRepo) {
// Why: allSettled so a 403 on upstream issues doesn't zero out the origin
// PR half — the UI renders partial results plus a banner for the failing
// side, matching the parent design doc's partial-failure rule (§2).
const [issuesSettled, prsSettled] = await Promise.allSettled([
issueOwnerRepo
? ghExecFileAsync(
[
'api',
...restCacheArgs,
`repos/${issueOwnerRepo.owner}/${issueOwnerRepo.repo}/issues?per_page=${limit}&state=open&sort=updated&direction=desc`
],
ghOptions
)
? ghExecFileAsync(issueRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(
repoContext,
[
'issue',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_ISSUE_LIST_JSON_FIELDS
],
ghOptions
),
: ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
prOwnerRepo
? ghExecFileAsync(
[
'api',
...restCacheArgs,
`repos/${prOwnerRepo.owner}/${prOwnerRepo.repo}/pulls?per_page=${limit}&state=open&sort=updated&direction=desc`
],
ghOptions
)
? ghExecFileAsync(prRequest.args, ghOptions)
: requiresExplicitRepo
? Promise.resolve({ stdout: '[]' })
: ghCwdResolvedExec(
repoContext,
[
'pr',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
],
ghOptions
)
: ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
let issues: MainWorkItem[] = []
let issuesError: ClassifiedError | undefined
if (issuesSettled.status === 'fulfilled') {
issues = (JSON.parse(issuesSettled.value.stdout) as Record<string, unknown>[])
// Why: the GitHub issues REST endpoint also returns pull requests with a
// `pull_request` marker. The new-workspace task picker needs distinct
// issue vs PR buckets, so drop PR-shaped issue rows here before merging.
// Why: the GitHub search/issues endpoint may still return PRs with a
// pull_request marker even when queried with is:issue. Filter them out
// here to keep the issue and PR buckets clean.
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
} else {
@ -1190,9 +1184,9 @@ async function listRecentWorkItems(
let prs: MainWorkItem[] = []
if (prsSettled.status === 'fulfilled') {
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, prOwnerRepo)
)
prs = (JSON.parse(prsSettled.value.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
prs = await hydrateWorkItemRepositoryMergeMetadata(prs, prOwnerRepo, ghOptions)
} else {
// Why: PR-side failures must preserve the pre-diff behavior of
@ -1215,7 +1209,7 @@ async function listRecentWorkItems(
}
return {
items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit),
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit),
issuesError
}
}
@ -1228,45 +1222,19 @@ async function listRecentWorkItems(
// effectively unusable for the feature — reject-all matches reality. If
// non-GitHub remotes ever grow source metadata, revisit this symmetry.
const [issuesResult, prsResult] = await Promise.all([
ghCwdResolvedExec(
repoContext,
[
'issue',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_ISSUE_LIST_JSON_FIELDS
],
ghOptions
),
ghCwdResolvedExec(
repoContext,
[
'pr',
'list',
'--limit',
String(limit),
'--state',
'open',
'--json',
WORK_ITEM_PR_LIST_JSON_FIELDS
],
ghOptions
)
ghCwdResolvedExec(repoContext, issueRequest.args, ghOptions),
ghCwdResolvedExec(repoContext, prRequest.args, ghOptions)
])
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[]).map(
mapIssueWorkItem
)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, null)
)
const issues = (JSON.parse(issuesResult.stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
const prs = (JSON.parse(prsResult.stdout) as Record<string, unknown>[])
.slice(prRequest.offset, prRequest.offset + limit)
.map((item) => mapPullRequestWorkItem(item, null))
return {
items: sortWorkItemsByUpdatedAt([...issues, ...prs]).slice(0, limit)
items: sortWorkItemsByNumber([...issues, ...prs]).slice(0, limit)
}
}
@ -1276,7 +1244,7 @@ async function listQueriedWorkItems(
prOwnerRepo: OwnerRepo | null,
query: ParsedTaskQuery,
limit: number,
before?: string,
page?: number,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PartialWorkItemsResult> {
@ -1302,19 +1270,21 @@ async function listQueriedWorkItems(
if (requiresExplicitRepo && !issueOwnerRepo) {
return { items: [] }
}
const args = buildWorkItemListArgs({
const request = buildWorkItemListRequest({
kind: 'issue',
ownerRepo: issueOwnerRepo,
limit,
query,
before
page: page ?? 1
})
try {
const { stdout } = issueOwnerRepo
? await ghExecFileAsync(args, ghOptions)
: await ghCwdResolvedExec(repoContext, args, ghOptions)
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
return {
items: (JSON.parse(stdout) as Record<string, unknown>[]).map(mapIssueWorkItem)
items: (JSON.parse(stdout) as Record<string, unknown>[])
.filter((item) => !('pull_request' in item))
.map(mapIssueWorkItem)
}
} catch (err) {
const stderr = err instanceof Error ? err.message : String(err)
@ -1329,20 +1299,20 @@ async function listQueriedWorkItems(
if (requiresExplicitRepo && !prOwnerRepo) {
return []
}
const args = buildWorkItemListArgs({
const request = buildWorkItemListRequest({
kind: 'pr',
ownerRepo: prOwnerRepo,
limit,
query,
before
page: page ?? 1
})
try {
const { stdout } = prOwnerRepo
? await ghExecFileAsync(args, ghOptions)
: await ghCwdResolvedExec(repoContext, args, ghOptions)
const mapped = (JSON.parse(stdout) as Record<string, unknown>[]).map((item) =>
mapPullRequestWorkItem(item, prOwnerRepo)
)
? await ghExecFileAsync(request.args, ghOptions)
: await ghCwdResolvedExec(repoContext, request.args, ghOptions)
const mapped = (JSON.parse(stdout) as Record<string, unknown>[])
.slice(request.offset, request.offset + limit)
.map((item) => mapPullRequestWorkItem(item, prOwnerRepo))
const hydrated = await hydrateWorkItemRepositoryMergeMetadata(mapped, prOwnerRepo, ghOptions)
if (query.state === 'closed') {
return hydrated.filter((item) => item.state !== 'merged')
@ -1356,7 +1326,7 @@ async function listQueriedWorkItems(
const [issueResult, prItems] = await Promise.all([issueFetch, prFetch])
return {
items: sortWorkItemsByUpdatedAt([...issueResult.items, ...prItems]).slice(0, limit),
items: sortWorkItemsByNumber([...issueResult.items, ...prItems]).slice(0, limit),
issuesError: issueResult.issuesError
}
}
@ -1365,13 +1335,14 @@ export async function listWorkItems(
repoPath: string,
limit = 24,
query?: string,
before?: string,
page?: number,
preference?: IssueSourcePreference,
connectionId?: string | null,
noCache?: boolean,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListWorkItemsResult<MainWorkItem>> {
const trimmedQuery = query?.trim() ?? ''
const requestedPage = normalizeWorkItemPage(page)
if (isGitHubWorkItemsQueryTooLarge(trimmedQuery)) {
return {
items: [],
@ -1401,6 +1372,7 @@ export async function listWorkItems(
issueOwnerRepo,
prOwnerRepo,
limit,
requestedPage,
connectionId,
noCache,
localGitOptions
@ -1411,7 +1383,7 @@ export async function listWorkItems(
prOwnerRepo,
parseTaskQuery(trimmedQuery),
limit,
before,
requestedPage,
connectionId,
localGitOptions
)
@ -1480,7 +1452,7 @@ function buildSearchQueryString(
}
function quoteGitHubSearchValue(value: string): string {
return /\s/.test(value) ? `"${value.replaceAll('"', '\\"')}"` : value
return /[\s"]/.test(value) ? `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` : value
}
async function countWorkItemsForQuery(

View File

@ -668,7 +668,7 @@ describe('registerGitHubHandlers', () => {
repoPath: '/workspace/repo',
limit: 10,
query: 'is:open',
before: 'cursor-1',
page: 2,
noCache: true
})
@ -676,7 +676,7 @@ describe('registerGitHubHandlers', () => {
'/workspace/repo',
10,
'is:open',
'cursor-1',
2,
'origin',
null,
true
@ -732,7 +732,7 @@ describe('registerGitHubHandlers', () => {
repoPath: '/workspace/repo',
limit: 10,
query: 'is:open',
before: 'cursor-1',
page: 2,
noCache: true
})
await handlers['gh:countWorkItems'](null, {
@ -789,7 +789,7 @@ describe('registerGitHubHandlers', () => {
'/workspace/repo',
10,
'is:open',
'cursor-1',
2,
undefined,
null,
true,

View File

@ -433,7 +433,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
repoId?: string
limit?: number
query?: string
before?: string
page?: number
noCache?: boolean
}
) => {
@ -442,7 +442,7 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi
repo.path,
args.limit,
args.query,
args.before,
args.page,
repo.issueSourcePreference,
repoConnectionId(repo),
args.noCache,

View File

@ -5185,7 +5185,7 @@ describe('OrcaRuntimeService', () => {
listGitHubLabelsMock.mockResolvedValueOnce([])
listGitHubAssignableUsersMock.mockResolvedValueOnce([])
await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 'cursor', true)
await runtime.listRepoWorkItems('id:repo-1', 7, 'is:open', 1, true)
await runtime.countRepoWorkItems('id:repo-1', 'is:issue')
await runtime.listRepoIssues('id:repo-1', 5)
await runtime.getRepoIssue('id:repo-1', 12)
@ -5199,7 +5199,7 @@ describe('OrcaRuntimeService', () => {
TEST_REPO_PATH,
7,
'is:open',
'cursor',
1,
undefined,
null,
true,

View File

@ -403,7 +403,8 @@ import {
addPRReviewComment,
addPRReviewCommentReply,
listLabels,
listAssignableUsers
listAssignableUsers,
type MainWorkItem
} from '../github/client'
import type { GitHubPRBranchLookupOptions } from '../github/client'
import { resolveGitHubPrStartPoint } from '../github/pr-start-point'
@ -452,6 +453,7 @@ import type {
GitLabMRInlineCommentInput,
GitLabProjectRef,
GitLabWorkItem,
ListWorkItemsResult,
MRListState
} from '../../shared/types'
import { inspectSetupScriptImportCandidates } from '../../shared/setup-script-imports'
@ -13185,15 +13187,15 @@ export class OrcaRuntimeService {
repoSelector: string,
limit?: number,
query?: string,
before?: string,
page?: number,
noCache?: boolean
): Promise<Awaited<ReturnType<typeof listWorkItems>>> {
): Promise<ListWorkItemsResult<MainWorkItem>> {
const repo = await this.resolveRepoSelector(repoSelector)
return listWorkItems(
repo.path,
limit,
query,
before,
page,
repo.issueSourcePreference,
repo.connectionId ?? null,
noCache,

View File

@ -10,7 +10,7 @@ const RepoSelector = z.object({
const WorkItemsList = RepoSelector.extend({
limit: OptionalFiniteNumber,
query: OptionalString,
before: OptionalString,
page: z.number().int().positive().optional(),
noCache: z.boolean().optional()
})
@ -306,7 +306,7 @@ export const GITHUB_METHODS: RpcMethod[] = [
params.repo,
params.limit,
params.query,
params.before,
params.page,
params.noCache
)
}),

View File

@ -1533,7 +1533,7 @@ export type PreloadApi = {
repoId?: string
limit?: number
query?: string
before?: string
page?: number
noCache?: boolean
}) => Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>>
prChecks: (

View File

@ -1319,7 +1319,7 @@ const api = {
repoId?: string
limit?: number
query?: string
before?: string
page?: number
noCache?: boolean
}): Promise<ListWorkItemsResult<Omit<GitHubWorkItem, 'repoId'>>> =>
ipcRenderer.invoke('gh:listWorkItems', args),

View File

@ -205,7 +205,11 @@ import {
type TaskPageRepoSourceState
} from '@/components/task-page-cache-selectors'
import { shouldHideTaskPageListChrome } from '@/components/task-page-list-chrome-visibility'
import { accumulateWorkItemPages } from '@/components/task-page-work-item-pagination'
import {
getTaskPagePerRepoLimit,
taskPageToGitHubApiPage
} from '@/components/task-page-work-item-pagination'
import { sortWorkItemsByNumber } from '../../../shared/work-items'
import LinearIssueAttributeFilterDropdowns from '@/components/linear-issue-attribute-filter-dropdowns'
import { resolveLinearIssueAttributeFilterPrimaryTeam } from '@/components/linear-issue-attribute-filter-primary-team'
import {
@ -3801,15 +3805,23 @@ export default function TaskPage(): React.JSX.Element {
// once, but the result is reconciled into existing rows to avoid a full
// table shuffle when only status/key fields changed.
const landingGitHubRefreshKeysRef = useRef<ReadonlySet<string>>(new Set())
// Why: pages holds all fetched pages of work items. Page 0 is seeded from
// cache for instant first paint; subsequent pages are loaded via date cursors.
const [pages, setPages] = useState<GitHubWorkItem[][]>(() => {
// Why: divide the display budget between repos so one provider page maps to
// one UI page without truncating rows that later provider pages cannot return.
const githubPerRepoPageLimit = getTaskPagePerRepoLimit(
selectedRepos.length,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT
)
const githubPageSize = githubPerRepoPageLimit * Math.max(1, selectedRepos.length)
// Why: null entries are pages not fetched yet. Numbered provider pages let a
// high-page click load that page directly without rate-limiting intermediate reads.
const [pages, setPages] = useState<(GitHubWorkItem[] | null)[]>(() => {
const trimmed = initialTaskQuery.trim()
const merged: GitHubWorkItem[] = []
for (const r of selectedRepos) {
const cached = getCachedWorkItems(
r.id,
PER_REPO_FETCH_LIMIT,
githubPerRepoPageLimit,
trimmed,
r.path,
getTaskPageRepoSourceContext(r, 'github')
@ -3821,15 +3833,13 @@ export default function TaskPage(): React.JSX.Element {
if (merged.length === 0) {
return [[]]
}
const page0 = [...merged]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, CROSS_REPO_DISPLAY_LIMIT)
const page0 = sortWorkItemsByNumber(merged).slice(0, githubPageSize)
return [page0]
})
const [currentPage, setCurrentPage] = useState(0)
const [paginationLoading, setPaginationLoading] = useState(false)
const [loadingTargetPage, setLoadingTargetPage] = useState<number | null>(null)
const [totalItemCount, setTotalItemCount] = useState<number | null>(null)
const [countedTotalPages, setCountedTotalPages] = useState<number | null>(null)
const fetchWorkItemsNextPage = useAppStore((s) => s.fetchWorkItemsNextPage)
const countWorkItemsAcrossRepos = useAppStore((s) => s.countWorkItemsAcrossRepos)
@ -3859,7 +3869,7 @@ export default function TaskPage(): React.JSX.Element {
selectTaskPageWorkItemsCacheEntries(
s.workItemsCache,
selectedRepos.map(getTaskPageRepoCacheInput),
PER_REPO_FETCH_LIMIT,
githubPerRepoPageLimit,
appliedWorkItemsCacheQuery
)
)
@ -3982,6 +3992,9 @@ export default function TaskPage(): React.JSX.Element {
setPages((current) => {
let changed = false
const nextPages = current.map((page) => {
if (!page) {
return page
}
let pageChanged = false
const nextPage = page.map((item) => {
if (item.id !== itemKey.id || item.repoId !== itemKey.repoId) {
@ -6156,6 +6169,9 @@ export default function TaskPage(): React.JSX.Element {
const seen = new Set<string>()
const logins: string[] = []
for (const page of pages) {
if (!page) {
continue
}
for (const item of page) {
if (
!item.author ||
@ -6229,45 +6245,31 @@ export default function TaskPage(): React.JSX.Element {
}
}, [ensurePRChecksLoaded, filteredWorkItems, githubMode, showPRManagementColumns, taskSource])
// Why: each page is bounded by both the per-repo fetch budget (so a single
// repo can yield at most PER_REPO_FETCH_LIMIT rows per page) and the
// post-merge display cap. Dividing the total by CROSS_REPO_DISPLAY_LIMIT
// alone under-counts pages when the per-repo budget is the tighter bound —
// e.g. one repo with 60 open PRs yielded 36 rows on page 0 and ceil(60/100)
// = 1 total pages, hiding the pager entirely.
const effectivePageSize = Math.max(
1,
Math.min(PER_REPO_FETCH_LIMIT * Math.max(1, selectedRepos.length), CROSS_REPO_DISPLAY_LIMIT)
)
// Why: if the search-API count is unavailable (rate-limited, transient
// failure — `countWorkItemsAcrossRepos` swallows errors and returns 0),
// infer there's at least one more page whenever the last loaded page
// filled to the per-page capacity. Otherwise pagination would silently
// hide and trap the user on page 0 with no way to advance.
const lastPageFull = (pages.at(-1)?.length ?? 0) >= effectivePageSize
let lastLoadedPageIndex = 0
for (let index = 0; index < pages.length; index += 1) {
if (pages[index] !== null) {
lastLoadedPageIndex = index
}
}
// Why: when counts fail, a full loaded page is enough evidence to expose one
// more page without pretending unfetched sparse entries are empty results.
const lastLoadedPageFull =
(pages[lastLoadedPageIndex]?.length ?? 0) >= Math.max(1, githubPageSize)
const fallbackTotalPages = lastLoadedPageFull
? Math.max(pages.length, lastLoadedPageIndex + 2)
: Math.max(1, pages.length)
const totalPages =
totalItemCount && totalItemCount > 0
? Math.max(pages.length, Math.ceil(totalItemCount / effectivePageSize))
: lastPageFull
? pages.length + 1
: pages.length
countedTotalPages && countedTotalPages > 0
? Math.max(pages.length, countedTotalPages)
: fallbackTotalPages
// Why: loads the next page using the oldest item's updatedAt as a cursor.
// When targetPage is provided (from clicking a numbered page beyond loaded
// pages), it chains fetches until that page is loaded.
// Why: numbered provider pages support random access. Load only the clicked
// page so a high-page jump does not exhaust GitHub's Search API rate bucket.
const handleLoadNextPage = useCallback(
async (targetPage?: number) => {
if (paginationLoading || selectedRepos.length === 0) {
return
}
const lastPage = pages.at(-1)
if (!lastPage || lastPage.length === 0) {
return
}
const oldestItem = lastPage.at(-1)
if (!oldestItem?.updatedAt) {
return
}
const q = stripRepoQualifiers(appliedTaskSearch.trim())
const repoArgs = selectedRepos.map((r) => ({
repoId: r.id,
@ -6277,37 +6279,32 @@ export default function TaskPage(): React.JSX.Element {
}))
const requestGeneration = paginationGenerationRef.current
const target = targetPage ?? pages.length
const target = targetPage ?? currentPage + 1
setPaginationLoading(true)
setLoadingTargetPage(target)
try {
const result = await accumulateWorkItemPages({
existingPages: pages,
initialCursor: oldestItem.updatedAt,
targetPage: target,
// Why: uniform page size keeps deduped pages from shrinking below the
// size `totalPages` (count ÷ effectivePageSize) assumes — otherwise
// the per-page overlap loss would strand the tail items.
pageSize: effectivePageSize,
isCancelled: () => paginationGenerationRef.current !== requestGeneration,
fetchPage: async (cursor) => {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
PER_REPO_FETCH_LIMIT,
CROSS_REPO_DISPLAY_LIMIT,
q,
cursor
)
return { items }
}
})
if (result.cancelled) {
const { items } = await fetchWorkItemsNextPage(
repoArgs,
githubPerRepoPageLimit,
githubPageSize,
q,
taskPageToGitHubApiPage(target)
)
if (paginationGenerationRef.current !== requestGeneration) {
return
}
if (result.newPages.length > 0) {
setPages((prev) => [...prev, ...result.newPages])
setCurrentPage(target < result.loadedPages ? target : result.loadedPages - 1)
if (items.length === 0) {
return
}
setPages((previous) => {
const next = [...previous]
while (next.length <= target) {
next.push(null)
}
next[target] = items
return next
})
setCurrentPage(target)
} catch (err) {
console.error('Failed to load next page:', err)
} finally {
@ -6320,10 +6317,11 @@ export default function TaskPage(): React.JSX.Element {
[
paginationLoading,
selectedRepos,
pages,
currentPage,
appliedTaskSearch,
fetchWorkItemsNextPage,
effectivePageSize
githubPageSize,
githubPerRepoPageLimit
]
)
@ -6399,7 +6397,7 @@ export default function TaskPage(): React.JSX.Element {
for (const r of selectedRepos) {
const cached = getCachedWorkItems(
r.id,
PER_REPO_FETCH_LIMIT,
githubPerRepoPageLimit,
q,
r.path,
getTaskPageRepoSourceContext(r, 'github')
@ -6415,14 +6413,10 @@ export default function TaskPage(): React.JSX.Element {
// no repo has a cache entry for it), we clear the previous query's rows
// rather than leaving them on screen under the spinner.
const page0 =
preMerged.length > 0
? [...preMerged]
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, CROSS_REPO_DISPLAY_LIMIT)
: []
preMerged.length > 0 ? sortWorkItemsByNumber(preMerged).slice(0, githubPageSize) : []
setPages([page0])
setCurrentPage(0)
setTotalItemCount(null)
setCountedTotalPages(null)
setTasksError(null)
setFailedCount(0) // reset so a prior failure banner doesn't linger
setTasksLoading(anyUncached)
@ -6464,7 +6458,7 @@ export default function TaskPage(): React.JSX.Element {
// newer retry's source from the set. Clearing only the keys captured
// when this effect dispatched preserves later additions.
const dispatchedRetrySourceKeys = retryingSourceKeys
void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, {
void fetchWorkItemsAcrossRepos(repoArgs, githubPerRepoPageLimit, githubPageSize, q, {
...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding)
})
.then(({ items, failedCount: failed }) => {
@ -6542,10 +6536,11 @@ export default function TaskPage(): React.JSX.Element {
executionHostId: r.executionHostId,
sourceContext: getTaskPageRepoSourceContext(r, 'github')
})),
q
).then((count) => {
q,
githubPerRepoPageLimit
).then(({ totalPages: countedPages }) => {
if (!cancelled) {
setTotalItemCount(count)
setCountedTotalPages(countedPages)
}
})
@ -9834,7 +9829,7 @@ export default function TaskPage(): React.JSX.Element {
totalPages={totalPages}
loadingTarget={loadingTargetPage}
onPageChange={(page) => {
if (page < pages.length) {
if (pages[page] !== null && pages[page] !== undefined) {
setCurrentPage(page)
} else {
void handleLoadNextPage(page)

View File

@ -146,8 +146,8 @@ describe('task page cache selectors', () => {
entry<GitHubWorkItem[]>([patched])
])
expect(nextPages[0][0]).toBe(patched)
expect(nextPages[0][1]).toBe(otherRepoSameId)
expect(nextPages[0]?.[0]).toBe(patched)
expect(nextPages[0]?.[1]).toBe(otherRepoSameId)
})
it('merges landing refresh status changes without reordering GitHub rows', () => {

View File

@ -37,6 +37,7 @@ type WorkItemsCache = Record<string, CacheEntry<GitHubWorkItem[]>>
type LinearIssueCache = Record<string, CacheEntry<LinearIssue>>
type LinearSearchCache = Record<string, CacheEntry<LinearIssue[]>>
type LinearListCache = Record<string, CacheEntry<LinearCollectionResult<LinearIssue>>>
export type TaskPageWorkItemPages = readonly (GitHubWorkItem[] | null)[]
export function deriveTaskPageGitHubWorkItemsFetchOptions(
forcedFetch: boolean,
@ -83,9 +84,9 @@ function taskPageWorkItemCacheKey(item: GitHubWorkItem): string {
}
export function reconcileTaskPagePagesWithWorkItemsCache(
pages: readonly GitHubWorkItem[][],
pages: TaskPageWorkItemPages,
entries: readonly (CacheEntry<GitHubWorkItem[]> | undefined)[]
): GitHubWorkItem[][] {
): (GitHubWorkItem[] | null)[] {
const cachedItems = new Map<string, GitHubWorkItem>()
for (const entry of entries) {
for (const item of entry?.data ?? []) {
@ -95,6 +96,9 @@ export function reconcileTaskPagePagesWithWorkItemsCache(
let changed = false
const nextPages = pages.map((page) => {
if (!page) {
return null
}
let pageChanged = false
const nextPage = page.map((item) => {
const cached = cachedItems.get(taskPageWorkItemCacheKey(item))
@ -108,7 +112,7 @@ export function reconcileTaskPagePagesWithWorkItemsCache(
return pageChanged ? nextPage : page
})
return changed ? nextPages : (pages as GitHubWorkItem[][])
return changed ? nextPages : (pages as (GitHubWorkItem[] | null)[])
}
function taskPageWorkItemKey(item: GitHubWorkItem): string {
@ -221,16 +225,16 @@ export function shouldResetTaskPagePaginationAfterLandingRefresh(
}
export function reconcileTaskPagePagesAfterLandingRefresh(
pages: readonly GitHubWorkItem[][],
pages: TaskPageWorkItemPages,
refreshedItems: readonly GitHubWorkItem[]
): GitHubWorkItem[][] {
): (GitHubWorkItem[] | null)[] {
const firstPage = pages[0] ?? []
if (shouldResetTaskPagePaginationAfterLandingRefresh(firstPage, refreshedItems)) {
return [[...refreshedItems]]
}
const nextFirstPage = reconcileTaskPageItemsAfterLandingRefresh(firstPage, refreshedItems)
if (nextFirstPage === firstPage) {
return pages as GitHubWorkItem[][]
return pages as (GitHubWorkItem[] | null)[]
}
return [nextFirstPage, ...pages.slice(1)]
}

View File

@ -1,6 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItem } from '../../../shared/types'
import { accumulateWorkItemPages, workItemIdentity } from './task-page-work-item-pagination'
import {
accumulateWorkItemPages,
getTaskPagePerRepoLimit,
taskPageToGitHubApiPage,
workItemIdentity
} from './task-page-work-item-pagination'
function item(repoId: string, id: string, updatedAt: string): GitHubWorkItem {
return {
@ -39,6 +44,21 @@ describe('workItemIdentity', () => {
})
})
describe('numbered GitHub pagination', () => {
it('converts zero-indexed task pages to one-indexed API pages', () => {
expect(taskPageToGitHubApiPage(0)).toBe(1)
expect(taskPageToGitHubApiPage(1)).toBe(2)
expect(taskPageToGitHubApiPage(15)).toBe(16)
})
it('divides the display budget before per-repo fetches can overflow it', () => {
expect(getTaskPagePerRepoLimit(1, 36, 100)).toBe(36)
expect(getTaskPagePerRepoLimit(2, 36, 100)).toBe(36)
expect(getTaskPagePerRepoLimit(3, 36, 100)).toBe(33)
expect(getTaskPagePerRepoLimit(90, 36, 100)).toBe(1)
})
})
describe('accumulateWorkItemPages', () => {
it('drops the re-fetched boundary row that shares the previous page cursor', async () => {
const boundary = item('r', 'issue:2', '2026-07-02')

View File

@ -31,6 +31,21 @@ export function workItemIdentity(item: Pick<GitHubWorkItem, 'id' | 'repoId'>): s
return `${item.repoId}:${item.id}`
}
export function taskPageToGitHubApiPage(taskPage: number): number {
return Math.max(0, Math.floor(taskPage)) + 1
}
// Why: provider pages cannot spill truncated rows into the next page. Divide
// the display budget up front so every fetched row remains reachable.
export function getTaskPagePerRepoLimit(
repoCount: number,
maxPerRepo: number,
displayLimit: number
): number {
const normalizedRepoCount = Math.max(1, Math.floor(repoCount))
return Math.max(1, Math.min(maxPerRepo, Math.floor(displayLimit / normalizedRepoCount)))
}
export type WorkItemPageFetchResult = { items: GitHubWorkItem[] }
export type AccumulateWorkItemPagesArgs = {

View File

@ -6331,7 +6331,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
24,
100,
'',
'2026-05-21T00:00:00Z'
1
)
expect(result.failedCount).toBe(0)
@ -6377,7 +6377,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
24,
100,
'is:open',
'2026-05-22T00:00:00Z'
1
)
expect(mockApi.gh.listWorkItems).not.toHaveBeenCalled()
@ -6388,7 +6388,7 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
repo: 'runtime-repo-id',
limit: 24,
query: 'is:open',
before: '2026-05-22T00:00:00Z'
page: 1
},
timeoutMs: 30_000
})
@ -6413,9 +6413,13 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
const result = await store
.getState()
.countWorkItemsAcrossRepos([{ repoId: 'caller-repo-id', path: '/server/repo' }], 'is:open')
.countWorkItemsAcrossRepos(
[{ repoId: 'caller-repo-id', path: '/server/repo' }],
'is:open',
10
)
expect(result).toBe(12)
expect(result).toEqual({ totalCount: 12, totalPages: 2 })
expect(mockApi.gh.countWorkItems).not.toHaveBeenCalled()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
@ -6434,9 +6438,9 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
const result = await store
.getState()
.countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '')
.countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], '', 10)
expect(result).toBe(7)
expect(result).toEqual({ totalCount: 7, totalPages: 1 })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(mockApi.gh.countWorkItems).toHaveBeenCalledWith({
repoPath: '/local/repo',
@ -6445,6 +6449,22 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
})
})
it('derives page count from the repo with the most results', async () => {
const store = createTestStore()
mockApi.gh.countWorkItems.mockResolvedValueOnce(100).mockResolvedValueOnce(1)
const result = await store.getState().countWorkItemsAcrossRepos(
[
{ repoId: 'large-repo', path: '/local/large' },
{ repoId: 'small-repo', path: '/local/small' }
],
'is:issue',
36
)
expect(result).toEqual({ totalCount: 101, totalPages: 3 })
})
it('rejects oversized work-item queries before cache keys or provider calls', async () => {
const store = createTestStore()
const secret = 'github-work-items-secret'
@ -6471,14 +6491,14 @@ describe('createGitHubSlice.fetchWorkItems source/error envelope', () => {
24,
24,
oversizedQuery,
'cursor'
1
)
).resolves.toEqual({ items: [], failedCount: 0 })
await expect(
store
.getState()
.countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery)
).resolves.toBe(0)
.countWorkItemsAcrossRepos([{ repoId: 'repo-id', path: '/local/repo' }], oversizedQuery, 24)
).resolves.toEqual({ totalCount: 0, totalPages: 0 })
store.getState().prefetchWorkItems('repo-id', '/local/repo', 24, oversizedQuery)
expect(store.getState().getCachedWorkItems('repo-id', 24, oversizedQuery, '/local/repo')).toBe(

View File

@ -34,7 +34,7 @@ import type {
} from '../../../../shared/github-project-types'
import {
isGitHubWorkItemsSshRemoteRequiredError,
sortWorkItemsByUpdatedAt,
sortWorkItemsByNumber,
PER_REPO_FETCH_LIMIT
} from '../../../../shared/work-items'
import { deriveCheckStatusFromChecks, syncPRChecksStatus } from './github-checks'
@ -206,7 +206,7 @@ type GitHubWorkItemRequestTarget =
type GitHubWorkItemsListArgs = {
limit: number
query?: string
before?: string
page?: number
noCache?: true
}
@ -2043,10 +2043,7 @@ export type GitHubSlice = {
query: string,
options?: FetchOptions
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
/**
* Fetch the next page of work items using a date cursor. Does not cache
* pagination pages are ephemeral and managed by TaskPage state.
*/
/** Fetch one numbered provider page. Pagination pages remain renderer-local. */
fetchWorkItemsNextPage: (
repos: {
repoId: string
@ -2057,12 +2054,9 @@ export type GitHubSlice = {
perRepoLimit: number,
displayLimit: number,
query: string,
before: string
page: number
) => Promise<{ items: GitHubWorkItem[]; failedCount: number }>
/**
* Count total work items across repos using GitHub's search API.
* Returns the sum of per-repo counts for the given query.
*/
/** Count items and derive pages from the largest per-repo result set. */
countWorkItemsAcrossRepos: (
repos: {
repoId: string
@ -2070,8 +2064,9 @@ export type GitHubSlice = {
executionHostId?: string | null
sourceContext?: TaskSourceContext | null
}[],
query: string
) => Promise<number>
query: string,
perRepoLimit: number
) => Promise<{ totalCount: number; totalPages: number }>
/**
* Fire-and-forget prefetch used by UI entry points (hover/focus of the
* "new workspace" buttons) to warm the cache before the page mounts.
@ -2896,11 +2891,11 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})
)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, before) => {
fetchWorkItemsNextPage: async (repos, perRepoLimit, displayLimit, query, page) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return { items: [], failedCount: 0 }
}
@ -2926,7 +2921,7 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
const envelope = await listGitHubWorkItemsForRepo(requestContext, {
limit: perRepoLimit,
query: query || undefined,
before
page
})
// Why: page-N partial failures don't participate in the cache's per-repo
// error banner (which is keyed on the initial-fetch cache entry). Log the
@ -2954,14 +2949,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})
)
const merged = sortWorkItemsByUpdatedAt(perProjectResults.flat()).slice(0, displayLimit)
const merged = sortWorkItemsByNumber(perProjectResults.flat()).slice(0, displayLimit)
return { items: merged, failedCount }
},
countWorkItemsAcrossRepos: async (repos, query) => {
countWorkItemsAcrossRepos: async (repos, query, perRepoLimit) => {
if (isGitHubWorkItemsQueryTooLarge(query)) {
return 0
return { totalCount: 0, totalPages: 0 }
}
const normalizedLimit = Math.max(1, Math.floor(perRepoLimit))
const counts = await Promise.all(
repos.map(async (r) => {
// Why: same stampede cap as the item-fetch paths — without a slot,
@ -2991,7 +2987,15 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
}
})
)
return counts.reduce((sum, c) => sum + c, 0)
return {
totalCount: counts.reduce((sum, count) => sum + count, 0),
// Why: each repo advances independently by the same numbered page. A sum
// divided by page width undercounts when one repo owns most results.
totalPages: counts.reduce(
(maxPages, count) => Math.max(maxPages, Math.ceil(count / normalizedLimit)),
0
)
}
},
prefetchWorkItems: (repoId, repoPath, limit = PER_REPO_FETCH_LIMIT, query = '', options) => {

View File

@ -2521,13 +2521,13 @@ describe('web GitHub preload API', () => {
},
{
key: 'listWorkItems',
args: { repoPath, limit: 20, query: 'is:pr', before: 'cursor', noCache: true },
args: { repoPath, limit: 20, query: 'is:pr', page: 2, noCache: true },
expectedMethod: 'github.listWorkItems',
expectedParams: withRepo({
repoPath,
limit: 20,
query: 'is:pr',
before: 'cursor',
page: 2,
noCache: true
})
},

View File

@ -26,6 +26,13 @@ export function isGitHubWorkItemsSshRemoteRequiredError(error: unknown): boolean
return message.includes(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
}
// Why: generic over item shape for the same cross-caller reasons as
// sortWorkItemsByUpdatedAt. Sorting by number descending matches GitHub's
// default Issues view (newest issue number first).
export function sortWorkItemsByNumber<T extends { number: number }>(items: T[]): T[] {
return [...items].sort((left, right) => right.number - left.number)
}
// Why: generic over the item shape because main-process callers emit items
// without repoId (stamped by the renderer after IPC), while renderer callers
// carry the full GitHubWorkItem. Both share only the updatedAt field needed