From 4f18c7e79d725db100da9bf3399db416def1a8c5 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:17:20 -0400 Subject: [PATCH] Allow Linear context issue lists to load more (#4512) * Allow Linear context issue lists to load more Co-authored-by: Orca * Simplify Linear load more footer copy Co-authored-by: Orca * Page Linear issue reads past backend cap Co-authored-by: Orca * Align Linear load more footer with GitHub pager Co-authored-by: Orca * Use pager for Linear issue lists Co-authored-by: Orca * Avoid phantom Linear issue pages Co-authored-by: Orca * Fix local Linear issue pagination cap Co-authored-by: Orca * Fix Linear pagination review issues Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/ipc/linear.test.ts | 128 ++++++ src/main/ipc/linear.ts | 7 +- src/main/linear/issues.test.ts | 145 ++++++- src/main/linear/issues.ts | 368 ++++++++++++++---- src/main/linear/projects.test.ts | 104 ++++- src/main/linear/projects.ts | 108 +++-- src/main/runtime/orca-runtime.ts | 8 +- src/renderer/src/components/TaskPage.tsx | 337 ++++++++++++++-- .../linear-project-view-surfaces.tsx | 25 +- src/renderer/src/store/slices/linear.test.ts | 109 ++++++ src/renderer/src/store/slices/linear.ts | 92 ++++- src/shared/linear-issue-list-limits.ts | 5 - src/shared/linear-issue-read-limits.ts | 6 + 13 files changed, 1236 insertions(+), 206 deletions(-) create mode 100644 src/main/ipc/linear.test.ts delete mode 100644 src/shared/linear-issue-list-limits.ts create mode 100644 src/shared/linear-issue-read-limits.ts diff --git a/src/main/ipc/linear.test.ts b/src/main/ipc/linear.test.ts new file mode 100644 index 000000000..1f52cb082 --- /dev/null +++ b/src/main/ipc/linear.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + handleMock, + listIssuesMock, + listProjectIssuesMock, + listCustomViewIssuesMock, + connectMock, + disconnectMock, + getStatusMock, + selectWorkspaceMock, + testConnectionMock +} = vi.hoisted(() => ({ + handleMock: vi.fn(), + listIssuesMock: vi.fn(), + listProjectIssuesMock: vi.fn(), + listCustomViewIssuesMock: vi.fn(), + connectMock: vi.fn(), + disconnectMock: vi.fn(), + getStatusMock: vi.fn(), + selectWorkspaceMock: vi.fn(), + testConnectionMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('../linear/client', () => ({ + connect: connectMock, + disconnect: disconnectMock, + getStatus: getStatusMock, + selectWorkspace: selectWorkspaceMock, + testConnection: testConnectionMock +})) + +vi.mock('../linear/issues', () => ({ + getIssue: vi.fn(), + searchIssues: vi.fn(), + listIssues: listIssuesMock, + createIssue: vi.fn(), + updateIssue: vi.fn(), + addIssueComment: vi.fn(), + getIssueComments: vi.fn() +})) + +vi.mock('../linear/projects', () => ({ + getCustomView: vi.fn(), + getProject: vi.fn(), + listCustomViewIssues: listCustomViewIssuesMock, + listCustomViewProjects: vi.fn(), + listCustomViews: vi.fn(), + listProjectIssues: listProjectIssuesMock, + listProjects: vi.fn() +})) + +vi.mock('../linear/teams', () => ({ + listTeams: vi.fn(), + getTeamStates: vi.fn(), + getTeamLabels: vi.fn(), + getTeamMembers: vi.fn() +})) + +vi.mock('./preflight', () => ({ + _resetPreflightCache: vi.fn() +})) + +import { registerLinearHandlers } from './linear' + +type HandlerMap = Record unknown> + +describe('registerLinearHandlers', () => { + const handlers: HandlerMap = {} + + beforeEach(() => { + handleMock.mockReset() + listIssuesMock.mockReset() + listProjectIssuesMock.mockReset() + listCustomViewIssuesMock.mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + }) + + it('forwards expanded Linear issue list limits through local IPC', async () => { + listIssuesMock.mockResolvedValue({ items: [], hasMore: true }) + + registerLinearHandlers() + await handlers['linear:listIssues'](null, { + filter: 'all', + limit: 216, + workspaceId: 'workspace-1' + }) + + expect(listIssuesMock).toHaveBeenCalledWith('all', 216, 'workspace-1') + }) + + it('forwards expanded Linear project issue limits through local IPC', async () => { + listProjectIssuesMock.mockResolvedValue({ items: [], hasMore: true }) + + registerLinearHandlers() + await handlers['linear:listProjectIssues'](null, { + projectId: 'project-1', + limit: 216, + workspaceId: 'workspace-1' + }) + + expect(listProjectIssuesMock).toHaveBeenCalledWith('project-1', 216, 'workspace-1', false) + }) + + it('forwards expanded Linear custom view issue limits through local IPC', async () => { + listCustomViewIssuesMock.mockResolvedValue({ items: [], hasMore: true }) + + registerLinearHandlers() + await handlers['linear:listCustomViewIssues'](null, { + viewId: 'view-1', + limit: 216, + workspaceId: 'workspace-1' + }) + + expect(listCustomViewIssuesMock).toHaveBeenCalledWith('view-1', 216, 'workspace-1', false) + }) +}) diff --git a/src/main/ipc/linear.ts b/src/main/ipc/linear.ts index 75d9dc60d..fbce759ce 100644 --- a/src/main/ipc/linear.ts +++ b/src/main/ipc/linear.ts @@ -23,6 +23,7 @@ import { } from '../linear/projects' import { listTeams, getTeamStates, getTeamLabels, getTeamMembers } from '../linear/teams' import type { LinearListFilter } from '../linear/issues' +import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import type { LinearCustomViewModel, LinearIssueUpdate, @@ -111,7 +112,7 @@ export function registerLinearHandlers(): void { const filter = VALID_FILTERS.has(args?.filter as LinearListFilter) ? (args!.filter as LinearListFilter) : undefined - const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + const limit = clampLinearIssueListLimit(args?.limit) return listIssues(filter, limit, normalizeWorkspaceSelection(args?.workspaceId)) } ) @@ -306,7 +307,7 @@ export function registerLinearHandlers(): void { if (typeof args?.projectId !== 'string' || !args.projectId.trim()) { throw new Error('Project ID is required') } - const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + const limit = clampLinearIssueListLimit(args?.limit) return listProjectIssues( args.projectId.trim(), limit, @@ -369,7 +370,7 @@ export function registerLinearHandlers(): void { if (typeof args?.viewId !== 'string' || !args.viewId.trim()) { throw new Error('Custom view ID is required') } - const limit = Math.min(Math.max(1, args?.limit ?? 20), 50) + const limit = clampLinearIssueListLimit(args?.limit) return listCustomViewIssues( args.viewId.trim(), limit, diff --git a/src/main/linear/issues.test.ts b/src/main/linear/issues.test.ts index 609375612..9c4e643b2 100644 --- a/src/main/linear/issues.test.ts +++ b/src/main/linear/issues.test.ts @@ -13,17 +13,21 @@ vi.mock('./client', () => ({ clearToken: (...args: unknown[]) => clearToken(...args) })) -function makeEntry(): LinearClientForWorkspace { +function makeEntry(options?: { + workspaceId?: string + organizationName?: string + request?: typeof rawRequest +}): LinearClientForWorkspace { return { workspace: { - id: 'workspace-1', - organizationId: 'workspace-1', - organizationName: 'Workspace', + id: options?.workspaceId ?? 'workspace-1', + organizationId: options?.workspaceId ?? 'workspace-1', + organizationName: options?.organizationName ?? 'Workspace', displayName: 'Ada', email: 'ada@example.com' }, client: { - client: { rawRequest } + client: { rawRequest: options?.request ?? rawRequest } } } as unknown as LinearClientForWorkspace } @@ -46,6 +50,40 @@ function rawIssue(id: string, updatedAt = '2026-01-01T00:00:00.000Z') { } } +function issueConnectionResponse( + ids: string[], + pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false } +) { + return { + data: { + issues: { + nodes: ids.map((id) => rawIssue(id)), + pageInfo + } + } + } +} + +function issueConnectionResponseFromIssues( + issues: ReturnType[], + pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false } +) { + return { + data: { + issues: { + nodes: issues, + pageInfo + } + } + } +} + +function datedIssues(prefix: string, count: number, startMs: number, startIndex = 1) { + return Array.from({ length: count }, (_, index) => + rawIssue(`${prefix}-${startIndex + index}`, new Date(startMs - index * 1000).toISOString()) + ) +} + describe('Linear issue queries', () => { beforeEach(() => { vi.clearAllMocks() @@ -141,17 +179,40 @@ describe('Linear issue queries', () => { }) }) + it('loads plain issue lists past Linear connection page size with cursors', async () => { + rawRequest + .mockResolvedValueOnce( + issueConnectionResponse( + Array.from({ length: 50 }, (_, index) => `LIN-${index + 1}`), + { hasNextPage: true, endCursor: 'cursor-50' } + ) + ) + .mockResolvedValueOnce( + issueConnectionResponse( + Array.from({ length: 22 }, (_, index) => `LIN-${index + 51}`), + { hasNextPage: false, endCursor: null } + ) + ) + const { listIssues } = await import('./issues') + + const result = await listIssues('all', 72, 'workspace-1') + + expect(result.items).toHaveLength(72) + expect(result.hasMore).toBe(false) + expect(rawRequest).toHaveBeenCalledTimes(2) + expect(rawRequest.mock.calls[0][1]).toMatchObject({ first: 50, orderBy: 'updatedAt' }) + expect(rawRequest.mock.calls[0][1]).not.toHaveProperty('after') + expect(rawRequest.mock.calls[1][1]).toMatchObject({ + first: 22, + after: 'cursor-50', + orderBy: 'updatedAt' + }) + }) + it('marks multi-workspace plain lists as having more when the merged result is clipped', async () => { getClients.mockReturnValue([ makeEntry(), - { - ...makeEntry(), - workspace: { - ...makeEntry().workspace, - id: 'workspace-2', - organizationName: 'Second Workspace' - } - } + makeEntry({ workspaceId: 'workspace-2', organizationName: 'Second Workspace' }) ]) rawRequest .mockResolvedValueOnce({ @@ -178,6 +239,64 @@ describe('Linear issue queries', () => { }) }) + it('pages only workspaces that can affect the global multi-workspace cutoff', async () => { + const firstWorkspaceRequest = vi.fn() + const secondWorkspaceRequest = vi.fn() + getClients.mockReturnValue([ + makeEntry({ request: firstWorkspaceRequest }), + makeEntry({ + workspaceId: 'workspace-2', + organizationName: 'Second Workspace', + request: secondWorkspaceRequest + }) + ]) + firstWorkspaceRequest + .mockResolvedValueOnce( + issueConnectionResponseFromIssues(datedIssues('W1', 50, Date.UTC(2026, 3, 1)), { + hasNextPage: true, + endCursor: 'workspace-1-cursor-50' + }) + ) + .mockResolvedValueOnce( + issueConnectionResponseFromIssues( + datedIssues('W1', 22, Date.UTC(2026, 3, 1) - 50_000, 51), + { hasNextPage: true, endCursor: 'workspace-1-cursor-72' } + ) + ) + secondWorkspaceRequest.mockResolvedValueOnce( + issueConnectionResponseFromIssues(datedIssues('W2', 50, Date.UTC(2026, 0, 1)), { + hasNextPage: true, + endCursor: 'workspace-2-cursor-50' + }) + ) + const { listIssues } = await import('./issues') + + const result = await listIssues('all', 72, 'all') + + expect(result.items).toHaveLength(72) + expect(result.items.map((issue) => issue.id)).toEqual( + Array.from({ length: 72 }, (_, index) => `W1-${index + 1}`) + ) + expect(result.hasMore).toBe(true) + expect(firstWorkspaceRequest).toHaveBeenCalledTimes(2) + expect(firstWorkspaceRequest.mock.calls[0][1]).toMatchObject({ + first: 50, + orderBy: 'updatedAt' + }) + expect(firstWorkspaceRequest.mock.calls[0][1]).not.toHaveProperty('after') + expect(firstWorkspaceRequest.mock.calls[1][1]).toMatchObject({ + first: 22, + after: 'workspace-1-cursor-50', + orderBy: 'updatedAt' + }) + expect(secondWorkspaceRequest).toHaveBeenCalledTimes(1) + expect(secondWorkspaceRequest.mock.calls[0][1]).toMatchObject({ + first: 50, + orderBy: 'updatedAt' + }) + expect(secondWorkspaceRequest.mock.calls[0][1]).not.toHaveProperty('after') + }) + it('sends estimate updates through to Linear', async () => { const updateIssue = vi.fn().mockResolvedValue({ success: true }) getClients.mockReturnValue([ diff --git a/src/main/linear/issues.ts b/src/main/linear/issues.ts index a1bacd6c1..ce90bd13f 100644 --- a/src/main/linear/issues.ts +++ b/src/main/linear/issues.ts @@ -8,7 +8,10 @@ import type { LinearCollectionResult, LinearWorkspaceSelection } from '../../shared/types' -import { clampLinearPlainIssueListLimit } from '../../shared/linear-issue-list-limits' +import { + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + clampLinearIssueListLimit +} from '../../shared/linear-issue-read-limits' import { acquire, release, @@ -62,10 +65,18 @@ type LinearIssueConnection = { nodes?: LinearIssueNode[] pageInfo?: { hasNextPage?: boolean + endCursor?: string | null } } type LinearRawVariables = Record +type LinearIssuePageRequest = { + first: number + after?: string +} +type LinearIssueConnectionLoader = ( + page: LinearIssuePageRequest +) => Promise const LINEAR_ISSUE_NODE_FIELDS = ` id @@ -111,13 +122,19 @@ const SEARCH_ISSUES_QUERY = ` ` const ALL_ISSUES_QUERY = ` - query OrcaLinearIssues($first: Int, $filter: IssueFilter, $orderBy: PaginationOrderBy) { - issues(first: $first, filter: $filter, orderBy: $orderBy) { + query OrcaLinearIssues( + $first: Int, + $after: String, + $filter: IssueFilter, + $orderBy: PaginationOrderBy + ) { + issues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { nodes { ${LINEAR_ISSUE_NODE_FIELDS} } pageInfo { hasNextPage + endCursor } } } @@ -126,16 +143,18 @@ const ALL_ISSUES_QUERY = ` const VIEWER_ASSIGNED_ISSUES_QUERY = ` query OrcaLinearViewerAssignedIssues( $first: Int, + $after: String, $filter: IssueFilter, $orderBy: PaginationOrderBy ) { viewer { - assignedIssues(first: $first, filter: $filter, orderBy: $orderBy) { + assignedIssues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { nodes { ${LINEAR_ISSUE_NODE_FIELDS} } pageInfo { hasNextPage + endCursor } } } @@ -145,16 +164,18 @@ const VIEWER_ASSIGNED_ISSUES_QUERY = ` const VIEWER_CREATED_ISSUES_QUERY = ` query OrcaLinearViewerCreatedIssues( $first: Int, + $after: String, $filter: IssueFilter, $orderBy: PaginationOrderBy ) { viewer { - createdIssues(first: $first, filter: $filter, orderBy: $orderBy) { + createdIssues(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { nodes { ${LINEAR_ISSUE_NODE_FIELDS} } pageInfo { hasNextPage + endCursor } } } @@ -233,6 +254,97 @@ function mapRawIssueForWorkspace( } } +async function readIssueConnectionPages( + entry: LinearClientForWorkspace, + limit: number, + loadConnection: LinearIssueConnectionLoader +): Promise<{ items: LinearIssue[]; hasMore: boolean }> { + const items: LinearIssue[] = [] + let after: string | undefined + let hasMore = false + + while (items.length < limit) { + // Why: Linear caps connection pages at 50, so larger Orca reads must walk + // cursors instead of asking for the whole expanded limit in one request. + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) + const connection = await loadConnection(after ? { first, after } : { first }) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapRawIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) + + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + break + } + after = nextCursor + } + + return { items, hasMore } +} + +function getOldestIssueTime(issues: LinearIssue[]): number { + const oldestIssue = issues.at(-1) + return oldestIssue ? new Date(oldestIssue.updatedAt).getTime() : Number.POSITIVE_INFINITY +} + +function getListIssueConnectionLoader( + entry: LinearClientForWorkspace, + filter: LinearListFilter +): LinearIssueConnectionLoader { + const orderBy = 'updatedAt' + const variables = { orderBy } + + if (filter === 'assigned') { + return async (page) => { + const result = await entry.client.client.rawRequest< + LinearIssueConnectionResponse, + LinearRawVariables + >(VIEWER_ASSIGNED_ISSUES_QUERY, { + ...variables, + ...page, + filter: ACTIVE_STATE_FILTER + }) + return result.data?.viewer?.assignedIssues + } + } + + if (filter === 'created') { + return async (page) => { + const result = await entry.client.client.rawRequest< + LinearIssueConnectionResponse, + LinearRawVariables + >(VIEWER_CREATED_ISSUES_QUERY, { + ...variables, + ...page, + filter: ACTIVE_STATE_FILTER + }) + return result.data?.viewer?.createdIssues + } + } + + if (filter === 'completed') { + return async (page) => { + const result = await entry.client.client.rawRequest< + LinearIssueConnectionResponse, + LinearRawVariables + >(VIEWER_ASSIGNED_ISSUES_QUERY, { + ...variables, + ...page, + filter: COMPLETED_STATE_FILTER + }) + return result.data?.viewer?.assignedIssues + } + } + + return async (page) => { + const result = await entry.client.client.rawRequest< + LinearIssueConnectionResponse, + LinearRawVariables + >(ALL_ISSUES_QUERY, { ...variables, ...page, filter: ACTIVE_STATE_FILTER }) + return result.data?.issues + } +} + function shouldThrowAuthError(selection: LinearWorkspaceSelection | null | undefined): boolean { return selection !== 'all' } @@ -319,91 +431,189 @@ export type LinearListFilter = 'assigned' | 'created' | 'all' | 'completed' const ACTIVE_STATE_FILTER = { state: { type: { nin: ['completed', 'canceled'] } } } const COMPLETED_STATE_FILTER = { state: { type: { in: ['completed', 'canceled'] } } } +type LinearIssuePageResult = { + items: LinearIssue[] + hasMore: boolean + endCursor?: string +} + +type LinearIssueWorkspacePageState = { + entry: LinearClientForWorkspace + loadConnection: LinearIssueConnectionLoader + items: LinearIssue[] + hasMore: boolean + canPage: boolean + after?: string +} + +async function readListIssuesForWorkspace( + entry: LinearClientForWorkspace, + filter: LinearListFilter, + limit: number, + workspaceId: LinearWorkspaceSelection | null | undefined +): Promise<{ items: LinearIssue[]; hasMore: boolean }> { + await acquire() + try { + return readIssueConnectionPages(entry, limit, getListIssueConnectionLoader(entry, filter)) + } catch (error) { + if (isAuthError(error)) { + clearToken(entry.workspace.id) + if (shouldThrowAuthError(workspaceId)) { + throw error + } + } else { + console.warn('[linear] listIssues failed:', error) + } + return { items: [], hasMore: false } + } finally { + release() + } +} + +async function readIssueConnectionPage( + entry: LinearClientForWorkspace, + loadConnection: LinearIssueConnectionLoader, + page: LinearIssuePageRequest +): Promise { + const connection = await loadConnection(page) + const nodes = connection?.nodes ?? [] + return { + items: nodes.map((issue) => mapRawIssueForWorkspace(entry, issue)), + hasMore: Boolean(connection?.pageInfo?.hasNextPage), + endCursor: connection?.pageInfo?.endCursor ?? undefined + } +} + +async function readListIssuesPageForState( + state: LinearIssueWorkspacePageState, + first: number, + workspaceId: LinearWorkspaceSelection | null | undefined +): Promise { + const previousCursor = state.after + await acquire() + try { + const page = await readIssueConnectionPage( + state.entry, + state.loadConnection, + previousCursor ? { first, after: previousCursor } : { first } + ) + state.items.push(...page.items) + state.hasMore = page.hasMore + state.after = page.endCursor + state.canPage = Boolean( + page.hasMore && page.endCursor && page.endCursor !== previousCursor && page.items.length > 0 + ) + } catch (error) { + state.items = [] + state.hasMore = false + state.canPage = false + if (isAuthError(error)) { + clearToken(state.entry.workspace.id) + if (shouldThrowAuthError(workspaceId)) { + throw error + } + } else { + console.warn('[linear] listIssues failed:', error) + } + } finally { + release() + } +} + +function findWorkspaceToPageForLimit( + states: LinearIssueWorkspacePageState[], + limit: number +): LinearIssueWorkspacePageState | undefined { + const merged = sortAndLimitIssues( + states.flatMap((state) => state.items), + limit + ) + if (merged.length < limit) { + return states + .filter((state) => state.canPage) + .sort((a, b) => getOldestIssueTime(b.items) - getOldestIssueTime(a.items))[0] + } + + const cutoff = new Date(merged[limit - 1].updatedAt).getTime() + return states + .filter((state) => state.canPage && getOldestIssueTime(state.items) > cutoff) + .sort((a, b) => getOldestIssueTime(b.items) - getOldestIssueTime(a.items))[0] +} + +function countSelectedIssuesOlderThanWorkspaceBoundary( + states: LinearIssueWorkspacePageState[], + stateToPage: LinearIssueWorkspacePageState, + limit: number +): number { + const boundary = getOldestIssueTime(stateToPage.items) + return sortAndLimitIssues( + states.flatMap((state) => state.items), + limit + ).filter((issue) => new Date(issue.updatedAt).getTime() < boundary).length +} + +async function readListIssuesAcrossWorkspaces( + entries: LinearClientForWorkspace[], + filter: LinearListFilter, + limit: number, + workspaceId: LinearWorkspaceSelection | null | undefined +): Promise> { + const states: LinearIssueWorkspacePageState[] = entries.map((entry) => ({ + entry, + loadConnection: getListIssueConnectionLoader(entry, filter), + items: [], + hasMore: false, + canPage: false + })) + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit) + + // Why: "all workspaces" is a global sorted list. Pull one bounded page per + // workspace first, then spend additional API calls only where unseen issues + // can still change the global updatedAt cutoff. + await Promise.all(states.map((state) => readListIssuesPageForState(state, first, workspaceId))) + + for (;;) { + const nextState = findWorkspaceToPageForLimit(states, limit) + if (!nextState) { + break + } + const itemCount = states.reduce((count, state) => count + state.items.length, 0) + const pageSize = + itemCount < limit + ? Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - itemCount) + : Math.min( + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + Math.max(1, countSelectedIssuesOlderThanWorkspaceBoundary(states, nextState, limit)) + ) + await readListIssuesPageForState(nextState, pageSize, workspaceId) + } + + const limited = sortLimitAndDescribeIssues( + states.flatMap((state) => state.items), + limit + ) + return { + items: limited.items, + hasMore: states.some((state) => state.hasMore) || limited.clipped + } +} + export async function listIssues( filter: LinearListFilter = 'assigned', limit = 20, workspaceId?: LinearWorkspaceSelection | null ): Promise> { - const effectiveLimit = clampLinearPlainIssueListLimit(limit) + const effectiveLimit = clampLinearIssueListLimit(limit) const entries = getClients(workspaceId) if (entries.length === 0) { return { items: [] } } - const results = await Promise.all( - entries.map(async (entry) => { - await acquire() - try { - const orderBy = 'updatedAt' - const variables = { first: effectiveLimit, orderBy } - - if (filter === 'assigned') { - const result = await entry.client.client.rawRequest< - LinearIssueConnectionResponse, - LinearRawVariables - >(VIEWER_ASSIGNED_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER }) - const connection = result.data?.viewer?.assignedIssues - return { - items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)), - hasMore: Boolean(connection?.pageInfo?.hasNextPage) - } - } - - if (filter === 'created') { - const result = await entry.client.client.rawRequest< - LinearIssueConnectionResponse, - LinearRawVariables - >(VIEWER_CREATED_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER }) - const connection = result.data?.viewer?.createdIssues - return { - items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)), - hasMore: Boolean(connection?.pageInfo?.hasNextPage) - } - } - - if (filter === 'completed') { - const result = await entry.client.client.rawRequest< - LinearIssueConnectionResponse, - LinearRawVariables - >(VIEWER_ASSIGNED_ISSUES_QUERY, { ...variables, filter: COMPLETED_STATE_FILTER }) - const connection = result.data?.viewer?.assignedIssues - return { - items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)), - hasMore: Boolean(connection?.pageInfo?.hasNextPage) - } - } - - // 'all' — all active issues across the workspace - const result = await entry.client.client.rawRequest< - LinearIssueConnectionResponse, - LinearRawVariables - >(ALL_ISSUES_QUERY, { ...variables, filter: ACTIVE_STATE_FILTER }) - const connection = result.data?.issues - return { - items: (connection?.nodes ?? []).map((issue) => mapRawIssueForWorkspace(entry, issue)), - hasMore: Boolean(connection?.pageInfo?.hasNextPage) - } - } catch (error) { - if (isAuthError(error)) { - clearToken(entry.workspace.id) - if (shouldThrowAuthError(workspaceId)) { - throw error - } - } else { - console.warn('[linear] listIssues failed:', error) - } - return { items: [], hasMore: false } - } finally { - release() - } - }) - ) - const merged = results.flatMap((result) => result.items) - const limited = sortLimitAndDescribeIssues(merged, effectiveLimit) - return { - items: limited.items, - hasMore: results.some((result) => result.hasMore) || limited.clipped + if (entries.length === 1) { + return readListIssuesForWorkspace(entries[0], filter, effectiveLimit, workspaceId) } + + return readListIssuesAcrossWorkspaces(entries, filter, effectiveLimit, workspaceId) } export async function createIssue( diff --git a/src/main/linear/projects.test.ts b/src/main/linear/projects.test.ts index bdf60e7ce..12f4a1ab5 100644 --- a/src/main/linear/projects.test.ts +++ b/src/main/linear/projects.test.ts @@ -59,12 +59,19 @@ function rawCustomView(id: string) { } function projectIssuesResponse(issueId: string) { + return projectIssuesConnectionResponse([issueId]) +} + +function projectIssuesConnectionResponse( + issueIds: string[], + pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false } +) { return { data: { project: { issues: { - nodes: [rawIssue(issueId)], - pageInfo: { hasNextPage: false } + nodes: issueIds.map((issueId) => rawIssue(issueId)), + pageInfo } } } @@ -105,13 +112,20 @@ function customViewProjectsResponse(projectId: string) { } function customViewIssuesResponse(issueId: string) { + return customViewIssuesConnectionResponse([issueId]) +} + +function customViewIssuesConnectionResponse( + issueIds: string[], + pageInfo: { hasNextPage: boolean; endCursor?: string | null } = { hasNextPage: false } +) { return { data: { customView: { modelName: 'Issue', issues: { - nodes: [rawIssue(issueId)], - pageInfo: { hasNextPage: false } + nodes: issueIds.map((issueId) => rawIssue(issueId)), + pageInfo } } } @@ -155,6 +169,47 @@ describe('Linear project queries', () => { }) }) + it('loads project issue reads above Linear connection page size', async () => { + rawRequest + .mockResolvedValueOnce( + projectIssuesConnectionResponse( + Array.from({ length: 50 }, (_, index) => `LIN-${index + 1}`), + { hasNextPage: true, endCursor: 'project-cursor-50' } + ) + ) + .mockResolvedValueOnce( + projectIssuesConnectionResponse( + Array.from({ length: 50 }, (_, index) => `LIN-${index + 51}`), + { hasNextPage: true, endCursor: 'project-cursor-100' } + ) + ) + .mockResolvedValueOnce( + projectIssuesConnectionResponse( + Array.from({ length: 20 }, (_, index) => `LIN-${index + 101}`), + { hasNextPage: false } + ) + ) + const { listProjectIssues } = await import('./projects') + + const result = await listProjectIssues('project-1', 120, 'workspace-1') + + expect(result.items).toHaveLength(120) + expect(result.hasMore).toBe(false) + expect(rawRequest).toHaveBeenCalledTimes(3) + expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'project-1', first: 50 }) + expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after') + expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({ + id: 'project-1', + first: 50, + after: 'project-cursor-50' + }) + expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({ + id: 'project-1', + first: 20, + after: 'project-cursor-100' + }) + }) + it('lets manual custom view list refresh bypass older in-flight reads', async () => { const staleRequest = deferred>() const refreshRequest = deferred>() @@ -238,4 +293,45 @@ describe('Linear project queries', () => { items: [{ id: 'ISSUE-STALE' }] }) }) + + it('loads issue custom view reads above Linear connection page size', async () => { + rawRequest + .mockResolvedValueOnce( + customViewIssuesConnectionResponse( + Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 1}`), + { hasNextPage: true, endCursor: 'view-cursor-50' } + ) + ) + .mockResolvedValueOnce( + customViewIssuesConnectionResponse( + Array.from({ length: 50 }, (_, index) => `ISSUE-${index + 51}`), + { hasNextPage: true, endCursor: 'view-cursor-100' } + ) + ) + .mockResolvedValueOnce( + customViewIssuesConnectionResponse( + Array.from({ length: 20 }, (_, index) => `ISSUE-${index + 101}`), + { hasNextPage: false } + ) + ) + const { listCustomViewIssues } = await import('./projects') + + const result = await listCustomViewIssues('view-1', 120, 'workspace-1') + + expect(result.items).toHaveLength(120) + expect(result.hasMore).toBe(false) + expect(rawRequest).toHaveBeenCalledTimes(3) + expect(rawRequest.mock.calls[0]?.[1]).toMatchObject({ id: 'view-1', first: 50 }) + expect(rawRequest.mock.calls[0]?.[1]).not.toHaveProperty('after') + expect(rawRequest.mock.calls[1]?.[1]).toMatchObject({ + id: 'view-1', + first: 50, + after: 'view-cursor-50' + }) + expect(rawRequest.mock.calls[2]?.[1]).toMatchObject({ + id: 'view-1', + first: 20, + after: 'view-cursor-100' + }) + }) }) diff --git a/src/main/linear/projects.ts b/src/main/linear/projects.ts index d856a1fb6..5b0a25784 100644 --- a/src/main/linear/projects.ts +++ b/src/main/linear/projects.ts @@ -12,6 +12,10 @@ import type { LinearWorkspaceError, LinearWorkspaceSelection } from '../../shared/types' +import { + LINEAR_ISSUE_API_PAGE_SIZE_MAX, + clampLinearIssueListLimit +} from '../../shared/linear-issue-read-limits' import { acquire, clearToken, @@ -25,6 +29,7 @@ type LinearRawVariables = Record type PageInfoNode = { hasNextPage?: boolean | null + endCursor?: string | null } type LinearConnection = { @@ -312,14 +317,20 @@ const PROJECT_QUERY = ` ` const PROJECT_ISSUES_QUERY = ` - query OrcaLinearProjectIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + query OrcaLinearProjectIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { project(id: $id) { - issues(first: $first, orderBy: $orderBy) { + issues(first: $first, after: $after, orderBy: $orderBy) { nodes { ${ORCA_ISSUE_FIELDS} } pageInfo { hasNextPage + endCursor } } } @@ -400,16 +411,22 @@ const CUSTOM_VIEW_QUERY = ` ` const CUSTOM_VIEW_ISSUES_QUERY = ` - query OrcaLinearCustomViewIssues($id: String!, $first: Int, $orderBy: PaginationOrderBy) { + query OrcaLinearCustomViewIssues( + $id: String!, + $first: Int, + $after: String, + $orderBy: PaginationOrderBy + ) { customView(id: $id) { id modelName - issues(first: $first, orderBy: $orderBy) { + issues(first: $first, after: $after, orderBy: $orderBy) { nodes { ${ORCA_ISSUE_FIELDS} } pageInfo { hasNextPage + endCursor } } } @@ -691,6 +708,37 @@ function mapCustomViewForWorkspace( } } +async function readIssueConnectionPages( + entry: LinearClientForWorkspace, + limit: number, + loadConnection: (variables: { + first: number + after?: string + }) => Promise | null | undefined> +): Promise> { + const items: LinearIssue[] = [] + let after: string | undefined + let hasMore = false + + while (items.length < limit) { + // Why: Linear returns issue connections in pages of up to 50; expanded + // Orca reads must follow cursors to show more than one backend page. + const first = Math.min(LINEAR_ISSUE_API_PAGE_SIZE_MAX, limit - items.length) + const connection = await loadConnection(after ? { first, after } : { first }) + const nodes = connection?.nodes ?? [] + items.push(...nodes.map((issue) => mapIssueForWorkspace(entry, issue))) + hasMore = Boolean(connection?.pageInfo?.hasNextPage) + + const nextCursor = connection?.pageInfo?.endCursor ?? undefined + if (!hasMore || !nextCursor || nextCursor === after || nodes.length === 0) { + break + } + after = nextCursor + } + + return { items, hasMore } +} + async function readCollection( key: string, workspaceId: LinearWorkspaceSelection | null | undefined, @@ -827,25 +875,23 @@ export async function listProjectIssues( if (!id) { throw new Error('Project ID is required') } - const first = clampLimit(limit) + const first = clampLinearIssueListLimit(limit) const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) return readConcreteCollection( `listProjectIssues:${concreteWorkspaceId}:${id}:${first}`, concreteWorkspaceId, async (entry) => { - const result = await entry.client.client.rawRequest< - ProjectIssueConnectionResponse, - LinearRawVariables - >(PROJECT_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' }) - const project = result.data?.project - if (!project) { - throw new Error('Project was not found') - } - const connection = project.issues - return { - items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)), - hasMore: !!connection?.pageInfo?.hasNextPage - } + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + ProjectIssueConnectionResponse, + LinearRawVariables + >(PROJECT_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const project = result.data?.project + if (!project) { + throw new Error('Project was not found') + } + return project.issues + }) }, force ) @@ -932,25 +978,23 @@ export async function listCustomViewIssues( if (!id) { throw new Error('Custom view ID is required') } - const first = clampLimit(limit) + const first = clampLinearIssueListLimit(limit) const concreteWorkspaceId = normalizeConcreteWorkspaceId(workspaceId) return readConcreteCollection( `listCustomViewIssues:${concreteWorkspaceId}:${id}:${first}`, concreteWorkspaceId, async (entry) => { - const result = await entry.client.client.rawRequest< - CustomViewConnectionResponse, - LinearRawVariables - >(CUSTOM_VIEW_ISSUES_QUERY, { id, first, orderBy: 'updatedAt' }) - const view = result.data?.customView - if (mapCustomViewModel(view?.modelName) !== 'issue') { - throw new Error('Custom view does not contain issues') - } - const connection = view?.issues - return { - items: (connection?.nodes ?? []).map((issue) => mapIssueForWorkspace(entry, issue)), - hasMore: !!connection?.pageInfo?.hasNextPage - } + return readIssueConnectionPages(entry, first, async (page) => { + const result = await entry.client.client.rawRequest< + CustomViewConnectionResponse, + LinearRawVariables + >(CUSTOM_VIEW_ISSUES_QUERY, { id, ...page, orderBy: 'updatedAt' }) + const view = result.data?.customView + if (mapCustomViewModel(view?.modelName) !== 'issue') { + throw new Error('Custom view does not contain issues') + } + return view?.issues + }) }, force ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b78405b17..26b4e9650 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -73,7 +73,7 @@ import type { import type { FeatureInteractionId } from '../../shared/feature-interactions' import type { TerminalPaneSplitSource } from '../../shared/feature-education-telemetry' import { FOLDER_WORKSPACE_INSTANCE_SEPARATOR, splitWorktreeId } from '../../shared/worktree-id' -import { clampLinearPlainIssueListLimit } from '../../shared/linear-issue-list-limits' +import { clampLinearIssueListLimit } from '../../shared/linear-issue-read-limits' import { isFolderRepo } from '../../shared/repo-kind' import { getNextProjectGroupOrder } from '../../shared/project-groups' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' @@ -12693,7 +12693,7 @@ export class OrcaRuntimeService { limit = 20, workspaceId?: LinearWorkspaceSelection ): ReturnType { - return listLinearIssues(filter, clampLinearPlainIssueListLimit(limit), workspaceId) + return listLinearIssues(filter, clampLinearIssueListLimit(limit), workspaceId) } linearCreateIssue( @@ -12771,7 +12771,7 @@ export class OrcaRuntimeService { workspaceId: string, force?: boolean ): ReturnType { - return listLinearProjectIssues(projectId, Math.min(Math.max(1, limit), 50), workspaceId, force) + return listLinearProjectIssues(projectId, clampLinearIssueListLimit(limit), workspaceId, force) } linearListCustomViews( @@ -12798,7 +12798,7 @@ export class OrcaRuntimeService { workspaceId: string, force?: boolean ): ReturnType { - return listLinearCustomViewIssues(viewId, Math.min(Math.max(1, limit), 50), workspaceId, force) + return listLinearCustomViewIssues(viewId, clampLinearIssueListLimit(limit), workspaceId, force) } linearListCustomViewProjects( diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index eda5d8378..de3e9a67c 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -188,9 +188,9 @@ import type { TaskViewPresetId } from '../../../shared/types' import { - LINEAR_PLAIN_ISSUE_LIST_MAX, - clampLinearPlainIssueListLimit -} from '../../../shared/linear-issue-list-limits' + LINEAR_ISSUE_LIST_MAX, + clampLinearIssueListLimit +} from '../../../shared/linear-issue-read-limits' import { shouldSuppressEnterSubmit } from '@/lib/new-workspace-enter-guard' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -3123,6 +3123,10 @@ export default function TaskPage(): React.JSX.Element { const [linearMode, setLinearMode] = useState('issues') const [linearIssues, setLinearIssues] = useState([]) const [linearIssueLimit, setLinearIssueLimit] = useState(LINEAR_ITEM_LIMIT) + const [linearIssuePage, setLinearIssuePage] = useState(0) + const [linearIssueLoadingTargetPage, setLinearIssueLoadingTargetPage] = useState( + null + ) const [linearIssuesHasMore, setLinearIssuesHasMore] = useState(false) const [linearLoading, setLinearLoading] = useState(false) const [linearError, setLinearError] = useState(null) @@ -3154,6 +3158,11 @@ export default function TaskPage(): React.JSX.Element { const [linearProjectIssuesResult, setLinearProjectIssuesResult] = useState< LinearCollectionResult >({ items: [] }) + const [linearProjectIssueLimit, setLinearProjectIssueLimit] = useState(LINEAR_ITEM_LIMIT) + const [linearProjectIssuePage, setLinearProjectIssuePage] = useState(0) + const [linearProjectIssueLoadingTargetPage, setLinearProjectIssueLoadingTargetPage] = useState< + number | null + >(null) const [linearProjectIssuesLoading, setLinearProjectIssuesLoading] = useState(false) const [linearProjectIssuesError, setLinearProjectIssuesError] = useState(null) const [linearCustomViewModel, setLinearCustomViewModel] = useState('issue') @@ -3169,6 +3178,10 @@ export default function TaskPage(): React.JSX.Element { const [linearCustomViewIssuesResult, setLinearCustomViewIssuesResult] = useState< LinearCollectionResult >({ items: [] }) + const [linearCustomViewIssueLimit, setLinearCustomViewIssueLimit] = useState(LINEAR_ITEM_LIMIT) + const [linearCustomViewIssuePage, setLinearCustomViewIssuePage] = useState(0) + const [linearCustomViewIssueLoadingTargetPage, setLinearCustomViewIssueLoadingTargetPage] = + useState(null) const [linearCustomViewProjectsResult, setLinearCustomViewProjectsResult] = useState< LinearCollectionResult >({ items: [] }) @@ -3202,7 +3215,13 @@ export default function TaskPage(): React.JSX.Element { setSelectedLinearCustomView(null) setLinearProjectParentView(null) setLinearProjectIssuesResult({ items: [] }) + setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT) + setLinearProjectIssuePage(0) + setLinearProjectIssueLoadingTargetPage(null) setLinearCustomViewIssuesResult({ items: [] }) + setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT) + setLinearCustomViewIssuePage(0) + setLinearCustomViewIssueLoadingTargetPage(null) setLinearCustomViewProjectsResult({ items: [] }) setLinearMode(mode) setTaskResumeState({ linearMode: mode, linearContext: undefined }) @@ -3226,7 +3245,13 @@ export default function TaskPage(): React.JSX.Element { setLinearCustomViewProjectsResult({ items: [] }) } setLinearProjectIssuesResult({ items: [] }) + setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT) + setLinearProjectIssuePage(0) + setLinearProjectIssueLoadingTargetPage(null) setLinearCustomViewIssuesResult({ items: [] }) + setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT) + setLinearCustomViewIssuePage(0) + setLinearCustomViewIssueLoadingTargetPage(null) setSelectedLinearProject(project) setLinearProjectTab('overview') setLinearMode('projects') @@ -3249,7 +3274,13 @@ export default function TaskPage(): React.JSX.Element { setSelectedLinearProjectDetail(null) setLinearProjectParentView(null) setLinearProjectIssuesResult({ items: [] }) + setLinearProjectIssueLimit(LINEAR_ITEM_LIMIT) + setLinearProjectIssuePage(0) + setLinearProjectIssueLoadingTargetPage(null) setLinearCustomViewIssuesResult({ items: [] }) + setLinearCustomViewIssueLimit(LINEAR_ITEM_LIMIT) + setLinearCustomViewIssuePage(0) + setLinearCustomViewIssueLoadingTargetPage(null) setLinearCustomViewProjectsResult({ items: [] }) setSelectedLinearCustomView(view) setLinearMode('views') @@ -3701,15 +3732,42 @@ export default function TaskPage(): React.JSX.Element { !activeLinearIssueContextLabel && appliedLinearSearch.trim().length === 0 && linearIssuesHasMore && - linearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX - const handleLoadMoreLinearIssues = useCallback(() => { - setLinearIssueLimit((limit) => - Math.min( - clampLinearPlainIssueListLimit(limit) + LINEAR_ITEM_LIMIT, - LINEAR_PLAIN_ISSUE_LIST_MAX - ) - ) - }, []) + linearIssueLimit < LINEAR_ISSUE_LIST_MAX + const canLoadMoreLinearProjectIssues = + selectedLinearProject !== null && + linearProjectTab === 'issues' && + Boolean(linearProjectIssuesResult.hasMore) && + linearProjectIssueLimit < LINEAR_ISSUE_LIST_MAX + const canLoadMoreLinearCustomViewIssues = + selectedLinearCustomView?.model === 'issue' && + Boolean(linearCustomViewIssuesResult.hasMore) && + linearCustomViewIssueLimit < LINEAR_ISSUE_LIST_MAX + const activeLinearIssuePage = + selectedLinearProject && linearProjectTab === 'issues' + ? linearProjectIssuePage + : selectedLinearCustomView?.model === 'issue' + ? linearCustomViewIssuePage + : linearIssuePage + const activeLinearIssueLoadingTargetPage = + selectedLinearProject && linearProjectTab === 'issues' + ? linearProjectIssueLoadingTargetPage + : selectedLinearCustomView?.model === 'issue' + ? linearCustomViewIssueLoadingTargetPage + : linearIssueLoadingTargetPage + const activeLinearIssueCanLoadMore = + selectedLinearProject && linearProjectTab === 'issues' + ? canLoadMoreLinearProjectIssues + : selectedLinearCustomView?.model === 'issue' + ? canLoadMoreLinearCustomViewIssues + : canLoadMorePlainLinearIssues + const activeLinearIssueCanRequestMore = + activeLinearIssueCanLoadMore && !activeLinearIssueHasCollectionError + const activeLinearIssueLimit = + selectedLinearProject && linearProjectTab === 'issues' + ? linearProjectIssueLimit + : selectedLinearCustomView?.model === 'issue' + ? linearCustomViewIssueLimit + : linearIssueLimit const displayedLinearIssues = useMemo( () => @@ -3795,6 +3853,147 @@ export default function TaskPage(): React.JSX.Element { return displayedLinearIssues.filter((issue) => linearTeamSelection.has(issue.team.id)) }, [activeLinearIssueContextLabel, displayedLinearIssues, linearTeamSelection]) + const orderedLinearIssues = useMemo( + () => [...filteredLinearIssues].sort((a, b) => compareLinearIssues(a, b, linearOrderBy)), + [filteredLinearIssues, linearOrderBy] + ) + const loadedLinearIssuePages = Math.max( + 1, + Math.ceil(orderedLinearIssues.length / LINEAR_ITEM_LIMIT) + ) + const linearIssueTotalPages = + orderedLinearIssues.length === 0 + ? 1 + : loadedLinearIssuePages + (activeLinearIssueCanRequestMore ? 1 : 0) + const visibleLinearIssuePage = Math.min( + activeLinearIssuePage, + Math.max(0, loadedLinearIssuePages - 1) + ) + const pagedLinearIssues = useMemo(() => { + const start = visibleLinearIssuePage * LINEAR_ITEM_LIMIT + return orderedLinearIssues.slice(start, start + LINEAR_ITEM_LIMIT) + }, [orderedLinearIssues, visibleLinearIssuePage]) + const showLinearIssuePagination = + orderedLinearIssues.length > 0 && + !activeLinearIssueError && + linearIssueTotalPages > 1 && + !(activeLinearIssueLoading && activeLinearIssues.length === 0) + + const setActiveLinearIssuePage = useCallback( + (page: number) => { + if (selectedLinearProject && linearProjectTab === 'issues') { + setLinearProjectIssuePage(page) + } else if (selectedLinearCustomView?.model === 'issue') { + setLinearCustomViewIssuePage(page) + } else { + setLinearIssuePage(page) + } + }, + [linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject] + ) + + const setActiveLinearIssueLoadingTargetPage = useCallback( + (page: number | null) => { + if (selectedLinearProject && linearProjectTab === 'issues') { + setLinearProjectIssueLoadingTargetPage(page) + } else if (selectedLinearCustomView?.model === 'issue') { + setLinearCustomViewIssueLoadingTargetPage(page) + } else { + setLinearIssueLoadingTargetPage(page) + } + }, + [linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject] + ) + + const ensureActiveLinearIssueLimit = useCallback( + (targetLimit: number) => { + const nextLimit = Math.min(clampLinearIssueListLimit(targetLimit), LINEAR_ISSUE_LIST_MAX) + if (selectedLinearProject && linearProjectTab === 'issues') { + setLinearProjectIssueLimit((limit) => Math.max(limit, nextLimit)) + } else if (selectedLinearCustomView?.model === 'issue') { + setLinearCustomViewIssueLimit((limit) => Math.max(limit, nextLimit)) + } else { + setLinearIssueLimit((limit) => Math.max(limit, nextLimit)) + } + }, + [linearProjectTab, selectedLinearCustomView?.model, selectedLinearProject] + ) + + const handleLinearIssuePageChange = useCallback( + (page: number) => { + if (page < loadedLinearIssuePages) { + setActiveLinearIssuePage(page) + setActiveLinearIssueLoadingTargetPage(null) + return + } + + // Why: unlike GitHub's cursor pages, Linear reads are cached as an + // expanded prefix. Jumping to a new page first expands the prefix, then + // commits the page when the fetch returns enough rows. + setActiveLinearIssueLoadingTargetPage(page) + ensureActiveLinearIssueLimit((page + 1) * LINEAR_ITEM_LIMIT) + }, + [ + ensureActiveLinearIssueLimit, + loadedLinearIssuePages, + setActiveLinearIssueLoadingTargetPage, + setActiveLinearIssuePage + ] + ) + + const showLinearEmptyFilteredLoadMore = + orderedLinearIssues.length === 0 && !activeLinearIssueError && activeLinearIssueCanRequestMore + const handleLinearEmptyFilteredLoadMore = useCallback(() => { + setActiveLinearIssueLoadingTargetPage(null) + ensureActiveLinearIssueLimit(activeLinearIssueLimit + LINEAR_ITEM_LIMIT) + }, [activeLinearIssueLimit, ensureActiveLinearIssueLimit, setActiveLinearIssueLoadingTargetPage]) + + useEffect(() => { + if (activeLinearIssueLoading || activeLinearIssueLoadingTargetPage === null) { + return + } + + const maxLoadedPage = Math.max(0, loadedLinearIssuePages - 1) + const targetPageLoaded = activeLinearIssueLoadingTargetPage <= maxLoadedPage + const targetPageCannotLoad = + !activeLinearIssueCanRequestMore || activeLinearIssueLimit >= LINEAR_ISSUE_LIST_MAX + if (targetPageLoaded || targetPageCannotLoad) { + setActiveLinearIssuePage(Math.min(activeLinearIssueLoadingTargetPage, maxLoadedPage)) + setActiveLinearIssueLoadingTargetPage(null) + return + } + + // Why: Linear can return more backend rows without immediately filling the + // next visible page after local team filtering. Keep expanding the prefix + // until the requested page exists or Linear reports exhaustion. + ensureActiveLinearIssueLimit(activeLinearIssueLimit + LINEAR_ITEM_LIMIT) + }, [ + activeLinearIssueCanRequestMore, + activeLinearIssueHasCollectionError, + activeLinearIssueLimit, + activeLinearIssueLoading, + activeLinearIssueLoadingTargetPage, + ensureActiveLinearIssueLimit, + loadedLinearIssuePages, + setActiveLinearIssueLoadingTargetPage, + setActiveLinearIssuePage + ]) + + useEffect(() => { + if ( + activeLinearIssueLoadingTargetPage !== null || + activeLinearIssuePage <= visibleLinearIssuePage + ) { + return + } + setActiveLinearIssuePage(visibleLinearIssuePage) + }, [ + activeLinearIssueLoadingTargetPage, + activeLinearIssuePage, + setActiveLinearIssuePage, + visibleLinearIssuePage + ]) + const selectedLinearTeamForExternalLink = useMemo(() => { if (linearTeamSelection.size !== 1) { return null @@ -3836,8 +4035,8 @@ export default function TaskPage(): React.JSX.Element { [linearIssueGridTemplate] ) const linearIssueSections = useMemo( - () => groupLinearIssues(filteredLinearIssues, linearGroupBy, linearOrderBy), - [filteredLinearIssues, linearGroupBy, linearOrderBy] + () => groupLinearIssues(pagedLinearIssues, linearGroupBy, linearOrderBy), + [pagedLinearIssues, linearGroupBy, linearOrderBy] ) const linearIssueListRows = useMemo( () => @@ -3861,11 +4060,11 @@ export default function TaskPage(): React.JSX.Element { const linearBoardSections = useMemo( () => groupLinearIssues( - filteredLinearIssues, + pagedLinearIssues, linearGroupBy === 'none' ? 'status' : linearGroupBy, linearOrderBy ), - [filteredLinearIssues, linearGroupBy, linearOrderBy] + [pagedLinearIssues, linearGroupBy, linearOrderBy] ) const linearStatusBoardEnabled = linearGroupBy === 'none' || linearGroupBy === 'status' @@ -5360,6 +5559,8 @@ export default function TaskPage(): React.JSX.Element { useEffect(() => { setLinearIssueLimit(LINEAR_ITEM_LIMIT) + setLinearIssuePage(0) + setLinearIssueLoadingTargetPage(null) }, [ appliedLinearSearch, linearMode, @@ -5390,7 +5591,7 @@ export default function TaskPage(): React.JSX.Element { setLinearError(null) const trimmed = appliedLinearSearch.trim() - const effectiveLinearIssueLimit = clampLinearPlainIssueListLimit(linearIssueLimit) + const effectiveLinearIssueLimit = clampLinearIssueListLimit(linearIssueLimit) const readArgs = trimmed.length > 0 ? ({ kind: 'search', query: trimmed, limit: LINEAR_ITEM_LIMIT } as const) @@ -5405,7 +5606,7 @@ export default function TaskPage(): React.JSX.Element { const collection = cachedResult as LinearCollectionResult setLinearIssues(collection.items) setLinearIssuesHasMore( - Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX + Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_ISSUE_LIST_MAX ) } @@ -5465,7 +5666,7 @@ export default function TaskPage(): React.JSX.Element { } else { const collection = result as LinearCollectionResult setLinearIssuesHasMore( - Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_PLAIN_ISSUE_LIST_MAX + Boolean(collection.hasMore) && effectiveLinearIssueLimit < LINEAR_ISSUE_LIST_MAX ) setLinearIssues((current) => shouldProbeOnLanding @@ -5608,10 +5809,11 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setLinearProjectIssuesLoading(true) setLinearProjectIssuesError(null) + const effectiveLimit = clampLinearIssueListLimit(linearProjectIssueLimit) void listLinearProjectIssues( selectedLinearProject.id, selectedLinearProject.workspaceId, - LINEAR_ITEM_LIMIT, + effectiveLimit, { force: linearRefreshNonce > 0 } ) .then((result) => { @@ -5631,7 +5833,13 @@ export default function TaskPage(): React.JSX.Element { return () => { cancelled = true } - }, [linearProjectTab, linearRefreshNonce, listLinearProjectIssues, selectedLinearProject]) + }, [ + linearProjectIssueLimit, + linearProjectTab, + linearRefreshNonce, + listLinearProjectIssues, + selectedLinearProject + ]) useEffect(() => { if (!taskResumeApplied || taskSource !== 'linear' || linearMode !== 'views') { @@ -5688,12 +5896,13 @@ export default function TaskPage(): React.JSX.Element { let cancelled = false setLinearCustomViewContentsLoading(true) setLinearCustomViewContentsError(null) + const issueLimit = clampLinearIssueListLimit(linearCustomViewIssueLimit) const request = selectedLinearCustomView.model === 'issue' ? listLinearCustomViewIssues( selectedLinearCustomView.id, selectedLinearCustomView.workspaceId, - LINEAR_ITEM_LIMIT, + issueLimit, { force: linearRefreshNonce > 0 } ) : listLinearCustomViewProjects( @@ -5727,6 +5936,7 @@ export default function TaskPage(): React.JSX.Element { } }, [ linearRefreshNonce, + linearCustomViewIssueLimit, listLinearCustomViewIssues, listLinearCustomViewProjects, selectedLinearCustomView @@ -8228,7 +8438,7 @@ export default function TaskPage(): React.JSX.Element {
- {filteredLinearIssues.length} shown + {pagedLinearIssues.length} shown
@@ -8665,27 +8875,70 @@ export default function TaskPage(): React.JSX.Element { )} {selectedLinearProject && linearProjectTab === 'issues' ? ( - + <> + + {showLinearIssuePagination ? ( +
+ +
+ ) : null} + ) : selectedLinearCustomView?.model === 'issue' ? ( - + <> + + {showLinearIssuePagination ? ( +
+ +
+ ) : null} + ) : ( - + <> + + {showLinearIssuePagination ? ( +
+ +
+ ) : null} + )} )} diff --git a/src/renderer/src/components/linear-project-view-surfaces.tsx b/src/renderer/src/components/linear-project-view-surfaces.tsx index 506170784..917727824 100644 --- a/src/renderer/src/components/linear-project-view-surfaces.tsx +++ b/src/renderer/src/components/linear-project-view-surfaces.tsx @@ -9,6 +9,7 @@ import { FileText, FolderKanban, Layers3, + LoaderCircle, RefreshCw, UserRound } from 'lucide-react' @@ -178,9 +179,9 @@ export function LinearCollectionNotice({ } return ( -
+
{errors && errors.length > 0 ? ( -
+
{errors.map((error) => ( {error.workspaceName ?? error.workspaceId}: {error.message} @@ -189,11 +190,12 @@ export function LinearCollectionNotice({
) : null} {hasMore ? ( -
- - Showing first {count} {label}. - {onLoadMore ? ' Fetch more in Orca.' : ' Search or open Linear for the full set.'} - +
+ {onLoadMore ? null : ( + + Showing first {count} {label}. Search or open Linear for the full set. + + )} {onLoadMore ? ( ) : null} diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index 25a2c984f..cd131d884 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -216,6 +216,60 @@ describe('createLinearSlice caching', () => { expect(linearListProjectIssues.mock.calls[0][4]).toEqual({ force: true }) }) + it('falls back to the largest smaller cached project issue limit when expansion fails', async () => { + const store = createTestStore() + store.setState({ + linearProjectIssueCache: { + 'workspace-1::project-issues::project-1::20': { + data: { items: [issue('LIN-SMALLER')] }, + fetchedAt: 1 + }, + 'workspace-1::project-issues::project-1::36': { + data: { items: [issue('LIN-CACHED-36')] }, + fetchedAt: 1 + }, + 'workspace-1::project-issues::project-2::36': { + data: { items: [issue('LIN-OTHER-PROJECT')] }, + fetchedAt: 1 + }, + 'workspace-2::project-issues::project-1::36': { + data: { items: [issue('LIN-OTHER-WORKSPACE')] }, + fetchedAt: 1 + } + } + }) + linearListProjectIssues.mockRejectedValueOnce(new Error('network down')) + + await expect( + store.getState().listLinearProjectIssues('project-1', 'workspace-1', 72, { + force: true + }) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-CACHED-36' }], + errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }] + }) + expect(linearListProjectIssues.mock.calls[0][2]).toBe(72) + }) + + it('caches project issue reads by the expanded effective limit', async () => { + const store = createTestStore() + linearListProjectIssues.mockResolvedValueOnce({ items: [issue('LIN-120')], hasMore: true }) + + await expect( + store.getState().listLinearProjectIssues('project-1', 'workspace-1', 120) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-120' }], + hasMore: true + }) + + expect(linearListProjectIssues).toHaveBeenCalledWith(null, 'project-1', 120, 'workspace-1', { + force: undefined + }) + expect( + store.getState().linearProjectIssueCache['workspace-1::project-issues::project-1::120']?.data + ).toMatchObject({ items: [{ id: 'LIN-120' }] }) + }) + it('surfaces scoped custom-view project failures alongside cached rows', async () => { const store = createTestStore() const rateLimitError = Object.assign(new Error('slow down'), { status: 429 }) @@ -263,6 +317,61 @@ describe('createLinearSlice caching', () => { expect(linearListCustomViewIssues.mock.calls[0][4]).toEqual({ force: true }) }) + it('falls back to the largest smaller cached custom-view issue limit when expansion fails', async () => { + const store = createTestStore() + store.setState({ + linearCustomViewIssueCache: { + 'workspace-1::custom-view-issues::view-1::20': { + data: { items: [issue('LIN-SMALLER')] }, + fetchedAt: 1 + }, + 'workspace-1::custom-view-issues::view-1::36': { + data: { items: [issue('LIN-CACHED-36')] }, + fetchedAt: 1 + }, + 'workspace-1::custom-view-issues::view-2::36': { + data: { items: [issue('LIN-OTHER-VIEW')] }, + fetchedAt: 1 + }, + 'workspace-2::custom-view-issues::view-1::36': { + data: { items: [issue('LIN-OTHER-WORKSPACE')] }, + fetchedAt: 1 + } + } + }) + linearListCustomViewIssues.mockRejectedValueOnce(new Error('network down')) + + await expect( + store.getState().listLinearCustomViewIssues('view-1', 'workspace-1', 72, { + force: true + }) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-CACHED-36' }], + errors: [{ workspaceId: 'workspace-1', type: 'unknown', message: 'network down' }] + }) + expect(linearListCustomViewIssues.mock.calls[0][2]).toBe(72) + }) + + it('caches issue custom-view reads by the expanded effective limit', async () => { + const store = createTestStore() + linearListCustomViewIssues.mockResolvedValueOnce({ items: [issue('LIN-120')], hasMore: true }) + + await expect( + store.getState().listLinearCustomViewIssues('view-1', 'workspace-1', 120) + ).resolves.toMatchObject({ + items: [{ id: 'LIN-120' }], + hasMore: true + }) + + expect(linearListCustomViewIssues).toHaveBeenCalledWith(null, 'view-1', 120, 'workspace-1', { + force: undefined + }) + expect( + store.getState().linearCustomViewIssueCache['workspace-1::custom-view-issues::view-1::120'] + ?.data + ).toMatchObject({ items: [{ id: 'LIN-120' }] }) + }) + it('surfaces top-level project list failures alongside cached rows', async () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index 10fce306f..35711a031 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -18,7 +18,7 @@ import type { LinearWorkspaceSelection } from '../../../../shared/types' import type { CacheEntry } from './github' -import { clampLinearPlainIssueListLimit } from '../../../../shared/linear-issue-list-limits' +import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read-limits' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' import { linearConnect, @@ -270,6 +270,30 @@ function collectionWithWorkspaceError( } } +function largestCachedCollectionBelowLimit( + cache: Record>>, + workspaceId: LinearWorkspaceSelection | null | undefined, + mode: string, + scopeId: string, + limit: number +): LinearCollectionResult | null { + const keyPrefix = `${linearCollectionCacheKey(workspaceId, mode, scopeId)}::` + let best: { limit: number; data: LinearCollectionResult } | null = null + for (const [key, entry] of Object.entries(cache)) { + if (!entry?.data || !key.startsWith(keyPrefix)) { + continue + } + const cachedLimit = Number(key.slice(keyPrefix.length)) + if (!Number.isFinite(cachedLimit) || cachedLimit >= limit) { + continue + } + if (!best || cachedLimit > best.limit) { + best = { limit: cachedLimit, data: entry.data } + } + } + return best?.data ?? null +} + function patchLinearIssueCollectionCache( cache: Record>>, issueId: string, @@ -708,7 +732,7 @@ export const createLinearSlice: StateCreator = (s const cacheKey = linearSearchCacheKey(workspaceId, args.query, args.limit ?? 20) return get().linearSearchCache[cacheKey]?.data ?? null } - const limit = clampLinearPlainIssueListLimit(args.limit) + const limit = clampLinearIssueListLimit(args.limit) const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) return get().linearListCache[cacheKey]?.data ?? null }, @@ -726,7 +750,7 @@ export const createLinearSlice: StateCreator = (s .catch(() => {}) return } - const limit = clampLinearPlainIssueListLimit(args.limit) + const limit = clampLinearIssueListLimit(args.limit) const cacheKey = linearListCacheKey(workspaceId, args.filter ?? 'assigned', limit) if (isFresh(get().linearListCache[cacheKey]) || inflightListRequests.has(cacheKey)) { return @@ -796,7 +820,7 @@ export const createLinearSlice: StateCreator = (s listLinearIssues: async (filter = 'assigned', limit = 20, options) => { const workspaceId = getSelectedWorkspaceId(get().linearStatus) - const effectiveLimit = clampLinearPlainIssueListLimit(limit) + const effectiveLimit = clampLinearIssueListLimit(limit) const cacheKey = linearListCacheKey(workspaceId, filter, effectiveLimit) const cached = get().linearListCache[cacheKey] if (!options?.force && isFresh(cached)) { @@ -1039,7 +1063,13 @@ export const createLinearSlice: StateCreator = (s }, listLinearProjectIssues: async (projectId, workspaceId, limit = 20, options) => { - const cacheKey = linearCollectionCacheKey(workspaceId, 'project-issues', projectId, limit) + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = linearCollectionCacheKey( + workspaceId, + 'project-issues', + projectId, + effectiveLimit + ) const cached = get().linearProjectIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection() @@ -1052,9 +1082,15 @@ export const createLinearSlice: StateCreator = (s let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration - const promise = linearListProjectIssues(get().settings, projectId, limit, workspaceId, { - force: options?.force - }) + const promise = linearListProjectIssues( + get().settings, + projectId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) .then((result) => { if ( inflightProjectIssueRequests.get(cacheKey) === entry && @@ -1075,7 +1111,15 @@ export const createLinearSlice: StateCreator = (s set({ linearStatus: { connected: false, viewer: null } }) } const fallback = - get().linearProjectIssueCache[cacheKey]?.data ?? emptyLinearCollection() + get().linearProjectIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearProjectIssueCache, + workspaceId, + 'project-issues', + projectId, + effectiveLimit + ) ?? + emptyLinearCollection() return collectionWithWorkspaceError(fallback, workspaceId, error) }) .finally(() => { @@ -1207,7 +1251,13 @@ export const createLinearSlice: StateCreator = (s }, listLinearCustomViewIssues: async (viewId, workspaceId, limit = 20, options) => { - const cacheKey = linearCollectionCacheKey(workspaceId, 'custom-view-issues', viewId, limit) + const effectiveLimit = clampLinearIssueListLimit(limit) + const cacheKey = linearCollectionCacheKey( + workspaceId, + 'custom-view-issues', + viewId, + effectiveLimit + ) const cached = get().linearCustomViewIssueCache[cacheKey] if (!options?.force && isFresh(cached)) { return cached.data ?? emptyLinearCollection() @@ -1220,9 +1270,15 @@ export const createLinearSlice: StateCreator = (s let entry: InflightLinearCollectionRequest const requestCacheGeneration = linearCacheGeneration - const promise = linearListCustomViewIssues(get().settings, viewId, limit, workspaceId, { - force: options?.force - }) + const promise = linearListCustomViewIssues( + get().settings, + viewId, + effectiveLimit, + workspaceId, + { + force: options?.force + } + ) .then((result) => { if ( inflightCustomViewIssueRequests.get(cacheKey) === entry && @@ -1243,7 +1299,15 @@ export const createLinearSlice: StateCreator = (s set({ linearStatus: { connected: false, viewer: null } }) } const fallback = - get().linearCustomViewIssueCache[cacheKey]?.data ?? emptyLinearCollection() + get().linearCustomViewIssueCache[cacheKey]?.data ?? + largestCachedCollectionBelowLimit( + get().linearCustomViewIssueCache, + workspaceId, + 'custom-view-issues', + viewId, + effectiveLimit + ) ?? + emptyLinearCollection() return collectionWithWorkspaceError(fallback, workspaceId, error) }) .finally(() => { diff --git a/src/shared/linear-issue-list-limits.ts b/src/shared/linear-issue-list-limits.ts deleted file mode 100644 index 0af3a14f0..000000000 --- a/src/shared/linear-issue-list-limits.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const LINEAR_PLAIN_ISSUE_LIST_MAX = 216 - -export function clampLinearPlainIssueListLimit(limit: number | null | undefined): number { - return Math.min(Math.max(1, Math.floor(limit ?? 20)), LINEAR_PLAIN_ISSUE_LIST_MAX) -} diff --git a/src/shared/linear-issue-read-limits.ts b/src/shared/linear-issue-read-limits.ts new file mode 100644 index 000000000..26313bd35 --- /dev/null +++ b/src/shared/linear-issue-read-limits.ts @@ -0,0 +1,6 @@ +export const LINEAR_ISSUE_LIST_MAX = 216 +export const LINEAR_ISSUE_API_PAGE_SIZE_MAX = 50 + +export function clampLinearIssueListLimit(limit: number | null | undefined): number { + return Math.min(Math.max(1, Math.floor(limit ?? 20)), LINEAR_ISSUE_LIST_MAX) +}