diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index b571f43cd..314ada72f 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -299,7 +299,10 @@ describe('TerminalHost', () => { describe('tombstones', () => { it('caps tombstones at limit', async () => { - for (let i = 0; i < 1005; i++) { + host.dispose() + host = new TerminalHost({ spawnSubprocess: spawnFn as MockSpawnFn, maxTombstones: 3 }) + + for (let i = 0; i < 5; i++) { await host.createOrAttach({ sessionId: `session-${i}`, cols: 80, @@ -311,7 +314,7 @@ describe('TerminalHost', () => { // Oldest tombstones should be evicted expect(host.isKilled('session-0')).toBe(false) - expect(host.isKilled('session-1004')).toBe(true) + expect(host.isKilled('session-4')).toBe(true) }) }) diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index d8bddb2fe..bfa3c058c 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -4,7 +4,7 @@ import { resolveProcessCwd } from '../providers/process-cwd' import type { SessionInfo, TerminalSnapshot, ShellReadyState } from './types' import { SessionNotFoundError } from './types' -const MAX_TOMBSTONES = 1000 +const DEFAULT_MAX_TOMBSTONES = 1000 export type CreateOrAttachOptions = { sessionId: string @@ -46,6 +46,9 @@ export type TerminalHostOptions = { // sessions before killing them. This bypasses the RPC round-trip — the daemon // writes checkpoints in-process, guaranteeing completion before teardown. onFinalCheckpoint?: (sessionId: string, snapshot: TerminalSnapshot) => void + // Why: production keeps a large cap, but tests need a small deterministic cap + // without spawning thousands of full terminal sessions. + maxTombstones?: number } export class TerminalHost { @@ -53,10 +56,12 @@ export class TerminalHost { private killedTombstones = new Map() private spawnSubprocess: TerminalHostOptions['spawnSubprocess'] private onFinalCheckpoint: TerminalHostOptions['onFinalCheckpoint'] + private maxTombstones: number constructor(opts: TerminalHostOptions) { this.spawnSubprocess = opts.spawnSubprocess this.onFinalCheckpoint = opts.onFinalCheckpoint + this.maxTombstones = opts.maxTombstones ?? DEFAULT_MAX_TOMBSTONES } async createOrAttach(opts: CreateOrAttachOptions): Promise { @@ -266,7 +271,7 @@ export class TerminalHost { this.killedTombstones.delete(sessionId) this.killedTombstones.set(sessionId, Date.now()) - if (this.killedTombstones.size > MAX_TOMBSTONES) { + if (this.killedTombstones.size > this.maxTombstones) { const oldest = this.killedTombstones.keys().next().value if (oldest) { this.killedTombstones.delete(oldest) diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index b4ba830b6..9a0195aa5 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -114,6 +114,7 @@ describe('listWorkItems', () => { author: { login: 'octocat' }, isDraft: false, headRefName: 'feature/add-feature', + headRefOid: 'head-42', baseRefName: 'main', reviewRequests: [ { @@ -156,7 +157,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--assignee', @@ -192,6 +193,8 @@ describe('listWorkItems', () => { author: 'octocat', branchName: 'feature/add-feature', baseRefName: 'main', + headSha: 'head-42', + prRepo: { owner: 'acme', repo: 'widgets' }, reviewRequests: [ { login: 'AmethystLiang', @@ -218,6 +221,7 @@ describe('listWorkItems', () => { author: { login: 'octocat' }, isDraft: true, headRefName: 'draft/work', + headRefOid: 'head-7', baseRefName: 'main' } ]) @@ -231,7 +235,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--state', @@ -252,7 +256,9 @@ describe('listWorkItems', () => { updatedAt: '2026-03-30T00:00:00Z', author: 'octocat', branchName: 'draft/work', - baseRefName: 'main' + baseRefName: 'main', + headSha: 'head-7', + prRepo: { owner: 'acme', repo: 'widgets' } } ]) }) @@ -304,6 +310,7 @@ describe('listWorkItems', () => { author: { login: 'octocat' }, isDraft: false, headRefName: 'feature/open-pr', + headRefOid: 'head-2', baseRefName: 'main' } ]) @@ -331,7 +338,7 @@ describe('listWorkItems', () => { '--limit', '10', '--json', - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests', + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests', '--repo', 'acme/widgets', '--state', @@ -362,7 +369,9 @@ describe('listWorkItems', () => { updatedAt: '2026-03-30T00:00:00Z', author: 'octocat', branchName: 'feature/open-pr', - baseRefName: 'main' + baseRefName: 'main', + headSha: 'head-2', + prRepo: { owner: 'acme', repo: 'widgets' } } ]) }) @@ -381,6 +390,7 @@ describe('listWorkItems', () => { user: { login: 'contributor' }, head: { ref: 'feat/onboarding-model-choice-782', + sha: 'head-1849', repo: null, label: 'contributor:feat/onboarding-model-choice-782' }, @@ -403,6 +413,8 @@ describe('listWorkItems', () => { author: 'contributor', branchName: 'feat/onboarding-model-choice-782', baseRefName: 'main', + headSha: 'head-1849', + prRepo: { owner: 'stablyai', repo: 'orca' }, isCrossRepository: true } ]) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 233cecf16..8428be0cf 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -329,14 +329,14 @@ export async function getAuthenticatedViewer(): Promise { type MainWorkItem = Omit const WORK_ITEM_PR_LIST_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,reviewRequests' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests' // Why: these fields are intentionally excluded from `gh pr list` because // statusCheckRollup/review decision/merge metadata fan out into expensive // GraphQL work across every row. Requested reviewers are kept in the list // payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' function mapIssueWorkItem(item: Record): MainWorkItem { return { @@ -548,10 +548,10 @@ function deriveWorkItemCheckSummary(value: unknown): GitHubWorkItem['checksSumma function mapPullRequestWorkItem( item: Record, - baseOwnerLogin: string | null = null + baseOwnerRepo: OwnerRepo | null = null ): MainWorkItem { // Why: fork PRs are disabled in the Start-from picker. We compare the PR head's - // owner to the selected repo's owner; when baseOwnerLogin is unknown we default + // owner to the selected repo's owner; when the base repo is unknown we default // to false so non-picker call sites see the same shape as before. const headOwnerLogin = extractHeadOwnerLogin(item) // Why: only emit isCrossRepository when we actually know the head owner. If @@ -559,7 +559,9 @@ function mapPullRequestWorkItem( // that fixture, or gh not returning it), leave the field undefined instead // of falsely claiming "not a fork". const isCrossRepository = - headOwnerLogin !== null && baseOwnerLogin !== null ? headOwnerLogin !== baseOwnerLogin : null + headOwnerLogin !== null && baseOwnerRepo !== null + ? headOwnerLogin !== baseOwnerRepo.owner + : null const state = String(item.state ?? '').toLowerCase() const additions = numberFromUnknown(item.additions) const deletions = numberFromUnknown(item.deletions) @@ -569,6 +571,14 @@ function mapPullRequestWorkItem( (item.files as { totalCount?: unknown } | undefined)?.totalCount ) const mergeable = normalizePRMergeable(item.mergeable) + const headSha = + typeof item.headRefOid === 'string' + ? item.headRefOid + : typeof item.head === 'object' && item.head !== null + ? typeof (item.head as { sha?: unknown }).sha === 'string' + ? (item.head as { sha: string }).sha + : undefined + : undefined return { id: `pr:${String(item.number)}`, type: 'pr', @@ -607,6 +617,8 @@ function mapPullRequestWorkItem( typeof item.base === 'object' && item.base !== null && 'ref' in item.base ? String((item.base as { ref?: unknown }).ref ?? '') : String(item.baseRefName ?? ''), + ...(headSha ? { headSha } : {}), + ...(baseOwnerRepo ? { prRepo: { owner: baseOwnerRepo.owner, repo: baseOwnerRepo.repo } } : {}), ...(additions !== undefined ? { additions } : {}), ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), @@ -674,7 +686,7 @@ async function fetchPullRequestWorkItem( ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], ghOptions ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo.owner) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) } const { stdout } = await ghExecFileAsync( @@ -843,7 +855,7 @@ async function listRecentWorkItems( let prs: MainWorkItem[] = [] if (prsSettled.status === 'fulfilled') { prs = (JSON.parse(prsSettled.value.stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo?.owner ?? null) + mapPullRequestWorkItem(item, prOwnerRepo) ) } else { // Why: PR-side failures must preserve the pre-diff behavior of @@ -971,7 +983,7 @@ async function listQueriedWorkItems( try { const { stdout } = await ghExecFileAsync(args, ghOptions) return (JSON.parse(stdout) as Record[]).map((item) => - mapPullRequestWorkItem(item, prOwnerRepo?.owner ?? null) + mapPullRequestWorkItem(item, prOwnerRepo) ) } catch (err) { console.warn('listQueriedWorkItems PRs partial failure:', err) diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 99bcf9cd9..f0e615c0a 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -111,6 +111,7 @@ import { selectTaskPageWorkItemsCacheEntries, type TaskPageRepoSourceState } from '@/components/task-page-cache-selectors' +import { deriveTaskPagePRCheckSummary } from '@/components/task-page-pr-check-summary' import type { GitHubOwnerRepo, GitHubAssignableUser, @@ -223,6 +224,7 @@ const LINEAR_PRESETS: LinearPreset[] = [ const TASK_SEARCH_DEBOUNCE_MS = 300 const LINEAR_ITEM_LIMIT = 36 +const PR_CHECKS_EAGER_PREFETCH_LIMIT = 20 const GITHUB_TASK_GRID_CLASS = 'min-w-[860px] grid-cols-[72px_minmax(260px,2fr)_minmax(130px,0.8fr)_100px_92px_158px]' @@ -881,6 +883,17 @@ function getChecksTone(item: GitHubWorkItem): string { return 'border-border/60 bg-background/70 text-muted-foreground' } +function sameOptionalGitHubOwnerRepo( + left: GitHubOwnerRepo | null | undefined, + right: GitHubOwnerRepo | null | undefined +): boolean { + const leftValue = left ?? null + const rightValue = right ?? null + return leftValue === null && rightValue === null + ? true + : sameGitHubOwnerRepo(leftValue, rightValue) +} + function getMergeLabel(item: GitHubWorkItem): string { if (item.mergeable === undefined && item.mergeStateStatus === undefined) { return 'Merge' @@ -1360,11 +1373,39 @@ function PRReviewCell({ function PRChecksCell({ item, - onOpen + onOpen, + onLoadChecks }: { item: GitHubWorkItem onOpen: () => void + onLoadChecks: () => void }): React.JSX.Element { + const triggerRef = useRef(null) + + useEffect(() => { + if (item.type !== 'pr' || item.checksSummary) { + return + } + const node = triggerRef.current + if (!node || typeof IntersectionObserver === 'undefined') { + return + } + let requested = false + const observer = new IntersectionObserver( + (entries) => { + if (requested || !entries.some((entry) => entry.isIntersecting)) { + return + } + requested = true + onLoadChecks() + observer.disconnect() + }, + { rootMargin: '160px 0px' } + ) + observer.observe(node) + return () => observer.disconnect() + }, [item.checksSummary, item.type, onLoadChecks]) + if (item.type !== 'pr') { return Issue } @@ -1381,9 +1422,13 @@ function PRChecksCell({