From f2393c95552c00846116c6e36e663a69e081b509 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 08:22:48 +0000 Subject: [PATCH 001/170] release: v1.4.124 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8faad00f187..7ed80ddd68d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124-rc.9", + "version": "1.4.124", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From eb8435950a971e4c4935b4725d2e1c8ef049ef1b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:26:10 -0700 Subject: [PATCH 002/170] Clear a worktree's merged pull request after it switches to a different branch (#7460) Co-authored-by: Orca --- src/main/github/client.test.ts | 205 +++++- src/main/github/client.ts | 62 +- .../github/merged-pr-commit-membership.ts | 99 ++- .../github/pr-refresh-coordinator.test.ts | 116 ++++ src/main/github/pr-refresh-coordinator.ts | 23 +- src/renderer/src/store/slices/github.test.ts | 644 ++++++++++++++++++ src/renderer/src/store/slices/github.ts | 141 +++- src/renderer/src/web/web-preload-api.test.ts | 2 + src/renderer/src/web/web-preload-api.ts | 1 + src/shared/types.ts | 15 +- 10 files changed, 1261 insertions(+), 47 deletions(-) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index eb794325774..2138ba0aad0 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -801,7 +801,7 @@ describe('getPRForBranch', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100'], + ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], expect.anything() ) expect(pr).toMatchObject({ @@ -929,6 +929,209 @@ describe('getPRForBranch', () => { }) }) + function mockMergedLinkedPRLookup(prNumber = 7447) { + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ + candidates: [{ owner: 'acme', repo: 'widgets' }], + headRepo: { owner: 'acme', repo: 'widgets' } + }) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: prNumber, + title: 'Merged linked PR', + state: 'MERGED', + url: `https://github.com/acme/widgets/pull/${prNumber}`, + statusCheckRollup: [], + updatedAt: '2026-07-03T21:27:36Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'old-linked-branch', + baseRefOid: 'base-oid', + headRefOid: 'aaaa1111aaaa1111' + }) + }) + } + + it('stamps confirmedContainedHeadOid for a linked merged PR when HEAD is its commit', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([{ number: 7447 }]) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { + number: 7447, + state: 'merged', + confirmedContainedHeadOid: 'bbbb2222bbbb2222' + } + }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('stamps headDivergedFromMergedPRAtOid for a linked merged PR with a definite miss', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify([{ number: 42 }]) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { + number: 7447, + state: 'merged', + headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' + } + }) + }) + + it('stamps linked merged divergence when a later membership page proves absence', async () => { + mockMergedLinkedPRLookup() + // Page 1 is full and omits the linked PR (truncated), but page 2 is short and + // still omits it — that pair definitively proves the head is not contained. + const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } + }) + }) + + it('leaves linked merged divergence unset when membership pages stay full to the cap', async () => { + mockMergedLinkedPRLookup() + // Every page up to the cap is full and omits the linked PR, so absence can + // never be proven — the probe must stay unknown rather than clear the link. + const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) + for (let page = 0; page < 5; page += 1) { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) + } + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => { + // Fallback path: no resolved candidates, so `gh pr view` returns the PR with + // dataRepo=null. The membership probe must still run against the repo derived + // from the PR's own URL so a diverged merged linked PR can clear. + resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7447, + title: 'Merged linked PR', + state: 'MERGED', + url: 'https://github.com/acme/widgets/pull/7447', + statusCheckRollup: [], + updatedAt: '2026-07-03T21:27:36Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'old-linked-branch', + baseRefOid: 'base-oid', + headRefOid: 'aaaa1111aaaa1111' + }) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ + kind: 'found', + pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], + expect.anything() + ) + }) + + it('leaves linked merged divergence unset when the membership probe is rate-limited', async () => { + mockMergedLinkedPRLookup() + rateLimitGuardMock.mockImplementation((bucket?: string) => + bucket === 'core' + ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } + : { blocked: false } + ) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + + it('leaves linked merged divergence unset when the membership probe throws', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('leaves linked merged divergence unset when the membership probe returns a non-array payload', async () => { + mockMergedLinkedPRLookup() + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ message: 'Server Error' }) + }) + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { + currentHeadOid: 'bbbb2222bbbb2222' + }) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + }) + + it('leaves linked merged divergence unset without a current head oid', async () => { + mockMergedLinkedPRLookup() + + const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null) + + expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) + expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( + undefined + ) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 0c86d547e3f..8d0030dfe65 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -62,7 +62,10 @@ import { type LocalGitExecOptions, type OwnerRepo } from './gh-utils' -import { isCommitPartOfMergedPR } from './merged-pr-commit-membership' +import { + isCommitPartOfMergedPR, + type MergedPRCommitMembership +} from './merged-pr-commit-membership' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { hasHostedReviewLocalGitOptions, @@ -2771,6 +2774,15 @@ export async function getPRForBranch( return outcome.kind === 'found' ? outcome.pr : null } +// Why: the exact-linked fallback (`gh pr view` with no resolved repo candidates) +// returns dataRepo=null, which would leave the merged-PR membership probe unable +// to run. Derive the PR's own repo from its web URL so a diverged merged linked +// PR can still be confirmed and cleared. Host-agnostic to cover GitHub Enterprise. +function ownerRepoFromPullRequestUrl(url: string): OwnerRepo | null { + const match = url.match(/^https?:\/\/[^/\s]+\/([^/\s]+)\/([^/\s]+)\/pull\/\d+/) + return match ? { owner: match[1], repo: match[2] } : null +} + export async function getPRForBranchOutcome( repoPath: string, branch: string, @@ -2810,24 +2822,49 @@ export async function getPRForBranchOutcome( ? options.currentHeadOid.trim() : null let confirmedContainedHeadOid: string | null = null + let headDivergedFromMergedPRAtOid: string | null = null const mergedPRContainsHead = async ( candidate: PullRequestLookupData, candidateRepo: OwnerRepo | null, headOid: string | null - ): Promise => { + ): Promise => { if (!candidateRepo || !headOid) { - return false + return 'unknown' } - const contained = await isCommitPartOfMergedPR({ + const membership = await isCommitPartOfMergedPR({ ownerRepo: candidateRepo, prNumber: candidate.number, commitOid: headOid, ghOptions }) - if (contained) { + if (membership === 'contained') { confirmedContainedHeadOid = headOid } - return contained + return membership + } + const recordLinkedMergedPRDivergence = async ( + candidate: PullRequestLookupData | null, + candidateRepo: OwnerRepo | null + ): Promise => { + if ( + typeof linkedPRNumber !== 'number' || + !candidate || + mapPRState(candidate.state, candidate.isDraft) !== 'merged' || + explicitCurrentHeadOid === null || + candidate.headRefOid === explicitCurrentHeadOid + ) { + return + } + const membership = await mergedPRContainsHead( + candidate, + candidateRepo ?? ownerRepoFromPullRequestUrl(candidate.url), + explicitCurrentHeadOid + ) + if (membership === 'not-contained') { + // explicitCurrentHeadOid is non-null here (guarded above); record the + // exact head so consumers only clear the worktree that actually diverged. + headDivergedFromMergedPRAtOid = explicitCurrentHeadOid + } } const hideMergedImplicitPR = async ( candidate: PullRequestLookupData | null, @@ -2850,11 +2887,10 @@ export async function getPRForBranchOutcome( // merges, web-committed suggestions). A head that is one of the PR's own // commits is the same line of work, not a reused branch name — keep the // merged PR visible instead of offering "create a pull request". - return !(await mergedPRContainsHead( - candidate, - candidateRepo, - currentHeadOidForMergedImplicit - )) + return ( + (await mergedPRContainsHead(candidate, candidateRepo, currentHeadOidForMergedImplicit)) !== + 'contained' + ) } if (typeof linkedPRNumber === 'number') { @@ -2942,6 +2978,7 @@ export async function getPRForBranchOutcome( } return { kind: 'no-pr', fetchedAt: Date.now() } } + await recordLinkedMergedPRDivergence(data, dataRepo) const fallbackConfirmedMergedBranch = typeof fallbackPRNumber === 'number' && mergedBranchLookupNumber === fallbackPRNumber && @@ -2949,7 +2986,7 @@ export async function getPRForBranchOutcome( const explicitHeadHidesMergedImplicitPR = explicitCurrentHeadOid !== null && shouldHideMergedImplicitPR(data, linkedPRNumber, explicitCurrentHeadOid) && - !(await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) + (await mergedPRContainsHead(data, dataRepo, explicitCurrentHeadOid)) !== 'contained' // Why no lazy-HEAD re-check on preservation: fallback numbers come from // callers that already gated them on head equality or confirmed // containment; re-hiding against the main-repo HEAD would blank @@ -3003,6 +3040,7 @@ export async function getPRForBranchOutcome( ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), + ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), ...(data.baseRefName ? { baseRefName: data.baseRefName } : {}), prRepo: dataRepo ?? undefined, headRepo: dataHeadRepo ?? undefined, diff --git a/src/main/github/merged-pr-commit-membership.ts b/src/main/github/merged-pr-commit-membership.ts index a09f43ef01e..0bb390f6495 100644 --- a/src/main/github/merged-pr-commit-membership.ts +++ b/src/main/github/merged-pr-commit-membership.ts @@ -11,8 +11,15 @@ type GhExecOptions = Parameters[1] const MEMBERSHIP_CACHE_MAX_ENTRIES = 200 const MEMBERSHIP_DEFINITIVE_TTL_MS = 6 * 60 * 60 * 1000 const MEMBERSHIP_ERROR_TTL_MS = 5 * 60 * 1000 +const COMMIT_PULLS_PAGE_SIZE = 100 +// Why: a worktree HEAD is associated with ~1 PR, so page 1 is short in practice +// and this cap is never reached; it only bounds the pathological case of a commit +// linked to hundreds of PRs, where staying 'unknown' is the safe answer. +const COMMIT_PULLS_MAX_PAGES = 5 -const membershipCache = new Map() +export type MergedPRCommitMembership = 'contained' | 'not-contained' | 'unknown' + +const membershipCache = new Map() function pruneMergedPRCommitMembershipCache(now = Date.now()): void { for (const [cacheKey, cached] of membershipCache) { @@ -38,17 +45,17 @@ export function resetMergedPRCommitMembershipCacheForTest(): void { * commit belongs to that PR's history rather than merely sharing a branch name. * A worktree sitting on such a commit is on the PR's own line of work (for * example behind web-committed suggestions or an update-branch merge), not a - * reused branch name. Conservative on any failure: returns false. + * reused branch name. Conservative on any failure: returns unknown. */ export async function isCommitPartOfMergedPR(args: { ownerRepo: OwnerRepo prNumber: number commitOid: string ghOptions: GhExecOptions -}): Promise { +}): Promise { const oid = args.commitOid.trim().toLowerCase() if (!/^[0-9a-f]{4,64}$/.test(oid) || !Number.isInteger(args.prNumber)) { - return false + return 'unknown' } const owner = args.ownerRepo.owner const repo = args.ownerRepo.repo @@ -59,38 +66,78 @@ export async function isCommitPartOfMergedPR(args: { if (cached && cached.expiresAt > now) { return cached.value } - // Why blocked → uncached false: keep the merged PR hidden without burning - // budget; the next poll re-asks once the rate-limit window recovers. + // Why blocked stays unknown: hiding a transient branch match is safe, but + // callers must not clear a durable linked PR when the probe never ran. if (rateLimitGuard('core').blocked) { - return false + return 'unknown' } try { - noteRateLimitSpend('core') - const { stdout } = await ghExecFileAsync( - ['api', `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=100`], - args.ghOptions - ) - const parsed = JSON.parse(stdout) as unknown - const value = - Array.isArray(parsed) && - parsed.some( + // Why paginate: a full page that omits the target PR may just be truncated (a + // commit can belong to many PRs). Reading only page 1 and calling it + // 'not-contained' would wrongly clear a durable link; calling it 'unknown' + // would never clear one that genuinely diverged. Walk pages until the PR is + // found (contained) or a short page proves absence (not-contained); only the + // pathological all-full case up to the cap stays 'unknown'. + for (let page = 1; page <= COMMIT_PULLS_MAX_PAGES; page += 1) { + if (page > 1 && rateLimitGuard('core').blocked) { + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' + } + noteRateLimitSpend('core') + const { stdout } = await ghExecFileAsync( + [ + 'api', + `repos/${owner}/${repo}/commits/${oid}/pulls?per_page=${COMMIT_PULLS_PAGE_SIZE}&page=${page}` + ], + args.ghOptions + ) + const parsed = JSON.parse(stdout) as unknown + // Why: a non-array success payload is a shape mismatch, not an empty page; + // caching it as definitive not-contained could wrongly clear a durable link. + if (!Array.isArray(parsed)) { + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' + } + const entries = parsed + const contained = entries.some( (entry) => typeof entry === 'object' && entry !== null && (entry as { number?: unknown }).number === args.prNumber ) + if (contained) { + membershipCache.set(cacheKey, { + value: 'contained', + expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS + }) + return 'contained' + } + if (entries.length < COMMIT_PULLS_PAGE_SIZE) { + membershipCache.set(cacheKey, { + value: 'not-contained', + expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS + }) + return 'not-contained' + } + } membershipCache.set(cacheKey, { - value, - expiresAt: now + MEMBERSHIP_DEFINITIVE_TTL_MS - }) - return value - } catch { - // Why: the common failure is 422 for a commit never pushed to GitHub — - // definitive "new local work" today, but a push can change it; retry later. - membershipCache.set(cacheKey, { - value: false, + value: 'unknown', expiresAt: now + MEMBERSHIP_ERROR_TTL_MS }) - return false + return 'unknown' + } catch { + // Why: 422 often means "new local work" today, but a later push can make + // the answer knowable; preserve durable links until a probe succeeds. + membershipCache.set(cacheKey, { + value: 'unknown', + expiresAt: now + MEMBERSHIP_ERROR_TTL_MS + }) + return 'unknown' } } diff --git a/src/main/github/pr-refresh-coordinator.test.ts b/src/main/github/pr-refresh-coordinator.test.ts index 766a734f26b..76bd2460307 100644 --- a/src/main/github/pr-refresh-coordinator.test.ts +++ b/src/main/github/pr-refresh-coordinator.test.ts @@ -116,6 +116,25 @@ describe('pr-refresh-coordinator', () => { ) }) + it('copies the candidate worktree head onto broadcast aliases', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + reportVisiblePRRefreshCandidates([makeCandidate({ currentHeadOid: 'worktree-head-oid' })], 1, 1) + await vi.runOnlyPendingTimersAsync() + + // Why: the renderer clear of a diverged merged linked PR is head-scoped, so + // the broadcast alias must carry the request-time head it was probed against. + const outcomeEvent = sendMock.mock.calls + .map(([, event]) => event) + .find((event) => event.outcome) + expect(outcomeEvent?.aliases[0]?.currentHeadOid).toBe('worktree-head-oid') + }) + it('does not show visible background refreshes as queued', async () => { const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ @@ -633,6 +652,103 @@ describe('pr-refresh-coordinator', () => { expect(getPRForBranchOutcomeMock).toHaveBeenCalledTimes(2) }) + it('probes with the survivor head after the representative alias is invalidated', async () => { + const { enqueuePRRefresh } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + const survivor = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b', + currentHeadOid: 'head-b' + }) + const representative = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a', + currentHeadOid: 'head-a' + }) + + // Enqueue the survivor first, then the representative (active coalescing + // promotes the latter to representative), then invalidate the representative + // so the still-queued entry rebinds to the survivor before draining. + enqueuePRRefresh(survivor, 'active', 80, 1) + enqueuePRRefresh(representative, 'active', 80, 1) + enqueuePRRefresh({ ...representative, isArchived: true }, 'active', 80, 1) + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + + it('probes with the survivor head after the representative worktree is pruned', async () => { + const { enqueuePRRefresh, pruneWorktreePRRefreshAliases } = + await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + const survivor = makeCandidate({ + cacheKey: '/repo::feature/b', + branch: 'feature/b', + linkedPRNumber: 12, + worktreeId: 'wt-b', + currentHeadOid: 'head-b' + }) + const representative = makeCandidate({ + cacheKey: '/repo::feature/a', + branch: 'feature/a', + linkedPRNumber: 12, + worktreeId: 'wt-a', + currentHeadOid: 'head-a' + }) + + enqueuePRRefresh(survivor, 'active', 80, 1) + enqueuePRRefresh(representative, 'active', 80, 1) + pruneWorktreePRRefreshAliases('wt-a') + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + + it('refreshes the representative head when the same worktree re-reports a moved head', async () => { + const { reportVisiblePRRefreshCandidates } = await import('./pr-refresh-coordinator') + getPRForBranchOutcomeMock.mockResolvedValue({ + kind: 'found', + pr: makePR({ state: 'merged' }), + fetchedAt: Date.now() + }) + + // Same worktree/branch, moved head, coalescing visible→visible (no promote): + // the representative head must track the newest report before the drain. + reportVisiblePRRefreshCandidates( + [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-a' })], + 1, + 1 + ) + reportVisiblePRRefreshCandidates( + [makeCandidate({ linkedPRNumber: 12, worktreeId: 'wt-a', currentHeadOid: 'head-b' })], + 2, + 1 + ) + await vi.runOnlyPendingTimersAsync() + + const probedHeads = getPRForBranchOutcomeMock.mock.calls.map((call) => call[5]?.currentHeadOid) + expect(probedHeads).toContain('head-b') + expect(probedHeads).not.toContain('head-a') + }) + it('includes request start time on manual refresh events', async () => { const { refreshPRNow } = await import('./pr-refresh-coordinator') getPRForBranchOutcomeMock.mockResolvedValueOnce({ diff --git a/src/main/github/pr-refresh-coordinator.ts b/src/main/github/pr-refresh-coordinator.ts index 5bb70a970cb..a2576e4805e 100644 --- a/src/main/github/pr-refresh-coordinator.ts +++ b/src/main/github/pr-refresh-coordinator.ts @@ -188,7 +188,11 @@ export function pruneWorktreePRRefreshAliases(worktreeId: string): void { ...entry.candidate, cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, - worktreeId: replacementAlias.worktreeId + worktreeId: replacementAlias.worktreeId, + // Why: the probe now represents the replacement worktree, so it must + // use that worktree's head — otherwise divergence is stamped for the + // removed worktree's head and the survivor's link is never cleared. + currentHeadOid: replacementAlias.currentHeadOid ?? null } } } @@ -321,6 +325,7 @@ function aliasFromCandidate(candidate: GitHubPRRefreshCandidate): GitHubPRRefres branch: candidate.branch, worktreeId: candidate.worktreeId, connectionId: candidate.connectionId ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.linkedPRNumber == null ? (candidate.fallbackPRNumber ?? null) : null, @@ -393,6 +398,10 @@ function removeQueuedAliasForInvalidCandidate(key: string, alias: GitHubPRRefres cacheKey: replacementAlias.cacheKey, branch: replacementAlias.branch, worktreeId: replacementAlias.worktreeId, + // Why: the probe now represents the replacement worktree, so it must use + // that worktree's head — otherwise divergence is stamped for the pruned + // candidate's head and the survivor's link is never cleared. + currentHeadOid: replacementAlias.currentHeadOid ?? null, isArchived: false, isBare: false } @@ -809,6 +818,18 @@ export function enqueuePRRefresh( existing.activeDelayNotified = false existing.candidate = candidate existing.windowId = windowId ?? existing.windowId + } else if (existing.candidate.worktreeId === candidate.worktreeId) { + // Why: a non-promoting coalesce (e.g. visible→visible) keeps the existing + // representative, but the representative drives the probe head. If its own + // worktree moved head/branch, refresh those probe inputs so divergence is + // stamped for the current head — otherwise the head-scoped clear never + // matches and a merged linked PR lingers after a branch switch. + existing.candidate = { + ...existing.candidate, + cacheKey: candidate.cacheKey, + branch: candidate.branch, + currentHeadOid: candidate.currentHeadOid ?? null + } } } else { diagnosticsCounters.enqueued += 1 diff --git a/src/renderer/src/store/slices/github.test.ts b/src/renderer/src/store/slices/github.test.ts index e17be50508a..8cbe73b4f0e 100644 --- a/src/renderer/src/store/slices/github.test.ts +++ b/src/renderer/src/store/slices/github.test.ts @@ -127,6 +127,49 @@ function makePRRefreshWorktree(overrides: Partial = {}): Worktree { } } +function installLinkedPRClearStub( + store: ReturnType, + args: { + repoId: string + repoPath: string + branch: string + worktree: Worktree + } +) { + const cacheKey = `${args.repoId}::${args.branch}` + const updateWorktreeMeta = vi.fn( + async ( + worktreeId: string, + updates: Parameters[1], + options?: Parameters[2] + ) => { + const currentWorktree = store + .getState() + .worktreesByRepo[args.repoId]?.find((worktree) => worktree.id === worktreeId) + if (options?.shouldApply && !options.shouldApply(currentWorktree)) { + return + } + store.setState((state) => { + const nextWorktrees = { + ...state.worktreesByRepo, + [args.repoId]: (state.worktreesByRepo[args.repoId] ?? []).map((worktree) => + worktree.id === worktreeId ? { ...worktree, ...updates } : worktree + ) + } + const nextPRCache = { ...state.prCache } + delete nextPRCache[cacheKey] + return { worktreesByRepo: nextWorktrees, prCache: nextPRCache } as Partial + }) + } + ) + store.setState({ + repos: [{ id: args.repoId, path: args.repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { [args.repoId]: [args.worktree] }, + updateWorktreeMeta + } as unknown as Partial) + return updateWorktreeMeta +} + function githubSourceContext( hostId: TaskSourceContext['hostId'], repoId = 'source-repo-id' @@ -2133,6 +2176,421 @@ describe('createGitHubSlice.fetchPRForBranch', () => { expect(store.getState().hostedReviewCache[hostedReviewCacheKey]).toBeUndefined() }) + it('clears a linked merged PR when the resolved PR definitively diverged from the request head', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/new-work' + const worktreeId = 'wt-diverged-linked-pr' + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: 2 + }) + + await expect( + store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + ).resolves.toMatchObject({ number: 12 }) + + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { shouldApply: expect.any(Function) } + ) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + expect(store.getState().prCache[`${repoId}::${branch}`]).toBeUndefined() + }) + + it('clears a linked merged PR on a fresh cache hit that already carries a head-scoped divergence signal', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/cached-diverged' + const worktreeId = 'wt-cached-diverged' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + store.setState({ + prCache: { + [`${repoId}::${branch}`]: { + data: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: Date.now() + } + } + } as unknown as Partial) + + const result = await store.getState().fetchPRForBranch(repoPath, branch, { + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(result).toMatchObject({ number: 12 }) + expect(mockApi.gh.refreshPRNow).not.toHaveBeenCalled() + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { shouldApply: expect.any(Function) } + ) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + }) + + it('does not clear a linked merged PR when the request head equals the PR head', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/at-pr-head' + const worktreeId = 'wt-at-pr-head' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'same-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'same-head', + headDivergedFromMergedPRAtOid: 'same-head' + }), + fetchedAt: 2 + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + }) + + it('does not clear a linked merged PR when the request head is confirmed contained', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/contained' + const worktreeId = 'wt-contained' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'contained-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + confirmedContainedHeadOid: 'contained-head', + headDivergedFromMergedPRAtOid: 'contained-head' + }), + fetchedAt: 2 + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + }) + + it('does not clear a linked open PR even when a divergence bit is present', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/open-pr' + const worktreeId = 'wt-open-pr' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'open', + headSha: 'pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: 2 + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + }) + + it('does not clear a linked PR on a null PR result', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/null-pr' + const worktreeId = 'wt-null-pr' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ kind: 'no-pr', fetchedAt: 2 }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + }) + + it('does not clear when divergence is unset even if containment does not match the head', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/unknown-probe' + const worktreeId = 'wt-unknown-probe' + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockResolvedValueOnce({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + confirmedContainedHeadOid: 'other-head' + }), + fetchedAt: 2 + }) + + await store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + }) + + it('does not clear when the linked PR number changed before the lookup completed', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/relinked' + const worktreeId = 'wt-relinked' + let resolveRefresh: ( + value: Awaited> + ) => void = () => {} + const refresh = new Promise>>((resolve) => { + resolveRefresh = resolve + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockReturnValueOnce(refresh) + + const request = store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + store.setState({ + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 13 + }) + ] + } + } as unknown as Partial) + resolveRefresh({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: 2 + }) + + await expect(request).resolves.toBeNull() + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(13) + }) + + it('does not clear when the worktree head moved after the lookup started', async () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/head-moved' + const worktreeId = 'wt-head-moved' + let resolveRefresh: ( + value: Awaited> + ) => void = () => {} + const refresh = new Promise>>((resolve) => { + resolveRefresh = resolve + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'request-head', + linkedPR: 12 + }) + }) + mockApi.gh.refreshPRNow.mockReturnValueOnce(refresh) + + const request = store.getState().fetchPRForBranch(repoPath, branch, { + force: true, + repoId, + worktreeId, + linkedPRNumber: 12 + }) + store.setState({ + worktreesByRepo: { + [repoId]: [ + makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'new-head', + linkedPR: 12 + }) + ] + } + } as unknown as Partial) + resolveRefresh({ + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'request-head' + }), + fetchedAt: 2 + }) + + await expect(request).resolves.toMatchObject({ number: 12 }) + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { shouldApply: expect.any(Function) } + ) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + expect(store.getState().prCache[`${repoId}::${branch}`]).toMatchObject({ + data: expect.objectContaining({ number: 12 }) + }) + }) + it('preserves cached PR data when a forced coordinator refresh errors', async () => { const store = createTestStore() const repoPath = '/repo' @@ -2547,6 +3005,192 @@ describe('createGitHubSlice.fetchPRForBranch', () => { }) }) + it('clears a linked merged PR from a coordinator refresh event when the request head diverged', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/coordinator-diverged' + const worktreeId = 'wt-coordinator-diverged' + const cacheKey = `${repoId}::${branch}` + const worktree = makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree + }) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey, + repoPath, + repoId, + branch, + worktreeId, + linkedPRNumber: 12, + currentHeadOid: 'current-head' + } + ], + outcome: { + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: 2 + } + }) + + expect(updateWorktreeMeta).toHaveBeenCalledWith( + worktreeId, + { linkedPR: null }, + { shouldApply: expect.any(Function) } + ) + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBeNull() + }) + + it('does not clear a linked merged PR from a coordinator refresh event without a request head', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const branch = 'feature/coordinator-no-head' + const worktreeId = 'wt-coordinator-no-head' + const cacheKey = `${repoId}::${branch}` + const updateWorktreeMeta = installLinkedPRClearStub(store, { + repoId, + repoPath, + branch, + worktree: makePRRefreshWorktree({ + id: worktreeId, + repoId, + branch, + head: 'current-head', + linkedPR: 12 + }) + }) + + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { cacheKey, repoPath, repoId, branch, worktreeId, linkedPRNumber: 12, currentHeadOid: null } + ], + outcome: { + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'current-head' + }), + fetchedAt: 2 + } + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(store.getState().worktreesByRepo[repoId]?.[0]?.linkedPR).toBe(12) + }) + + it('clears only the diverged worktree when a PR-number-coalesced event fans out to sibling aliases', () => { + const store = createTestStore() + const repoPath = '/repo' + const repoId = 'repo-1' + const worktreeA = makePRRefreshWorktree({ + id: 'wt-a', + repoId, + branch: 'feature/a', + head: 'head-a', + linkedPR: 12 + }) + const worktreeB = makePRRefreshWorktree({ + id: 'wt-b', + repoId, + branch: 'feature/b', + head: 'head-b', + linkedPR: 12 + }) + const updateWorktreeMeta = vi.fn( + async ( + worktreeId: string, + updates: Parameters[1], + options?: Parameters[2] + ) => { + const current = store + .getState() + .worktreesByRepo[repoId]?.find((worktree) => worktree.id === worktreeId) + if (options?.shouldApply && !options.shouldApply(current)) { + return + } + store.setState((state) => ({ + worktreesByRepo: { + ...state.worktreesByRepo, + [repoId]: (state.worktreesByRepo[repoId] ?? []).map((worktree) => + worktree.id === worktreeId ? { ...worktree, ...updates } : worktree + ) + } + })) + } + ) + store.setState({ + repos: [{ id: repoId, path: repoPath, name: 'repo', kind: 'git' }], + worktreesByRepo: { [repoId]: [worktreeA, worktreeB] }, + updateWorktreeMeta + } as unknown as Partial) + + // The coordinator coalesces linked PR refreshes by PR number, so one probe + // (worktree A's head) is broadcast to both aliases. Only A actually diverged; + // B is still on a contained commit and must keep its link. + store.getState().applyGitHubPRRefreshEvent({ + sequence: 1, + reason: 'swr', + aliases: [ + { + cacheKey: `${repoId}::feature/a`, + repoPath, + repoId, + branch: 'feature/a', + worktreeId: 'wt-a', + linkedPRNumber: 12, + currentHeadOid: 'head-a' + }, + { + cacheKey: `${repoId}::feature/b`, + repoPath, + repoId, + branch: 'feature/b', + worktreeId: 'wt-b', + linkedPRNumber: 12, + currentHeadOid: 'head-b' + } + ], + outcome: { + kind: 'found', + pr: makePR({ + number: 12, + state: 'merged', + headSha: 'merged-pr-head', + headDivergedFromMergedPRAtOid: 'head-a' + }), + fetchedAt: 2 + } + }) + + const worktrees = store.getState().worktreesByRepo[repoId] ?? [] + expect(worktrees.find((worktree) => worktree.id === 'wt-a')?.linkedPR).toBeNull() + expect(worktrees.find((worktree) => worktree.id === 'wt-b')?.linkedPR).toBe(12) + }) + it('preserves visible cached PR data when a fallback refresh event misses', () => { const store = createTestStore() const repoPath = '/repo' diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index 8478c519877..bc935dcba9b 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -1005,6 +1005,44 @@ function isStaleExactLinkedPRLookup( return findWorktreeById(state, worktreeId)?.linkedPR !== linkedPRNumber } +function shouldClearDivergedLinkedMergedPR(args: { + pr: PRInfo | null + linkedPRNumber: number | null + requestHeadOid: string | null +}): boolean { + const { pr, linkedPRNumber, requestHeadOid } = args + return ( + linkedPRNumber != null && + requestHeadOid !== null && + pr?.number === linkedPRNumber && + pr.state === 'merged' && + // Head-scoped: only clear the worktree whose exact head diverged, so a + // PR-number-coalesced refresh broadcast cannot clear a sibling worktree that + // is still on the PR's line of work. + pr.headDivergedFromMergedPRAtOid === requestHeadOid && + pr.headSha !== requestHeadOid && + pr.confirmedContainedHeadOid !== requestHeadOid + ) +} + +function shouldApplyDivergedLinkedPRClear(args: { + worktree: Pick | undefined + linkedPRNumber: number + branch: string + requestHeadOid: string | null +}): boolean { + const { worktree, linkedPRNumber, branch, requestHeadOid } = args + return ( + Boolean(worktree) && + requestHeadOid !== null && + worktree?.linkedPR === linkedPRNumber && + worktree.branch.replace(/^refs\/heads\//, '') === branch && + worktree.head === requestHeadOid && + worktree.isBare !== true && + worktree.isArchived !== true + ) +} + function buildPRRefreshCandidate( state: AppState, worktree: Worktree, @@ -2923,6 +2961,38 @@ export const createGitHubSlice: StateCreator = (s const linkedRefetch = cached?.data === null && (linkedPRNumber !== null || fallbackPRNumber !== null) if (!options?.force && !linkedRefetch && isFresh(cached)) { + // Why: a fresh cache hit still carries the head-scoped divergence signal. + // If a prior clear was declined because the head moved mid-request and the + // worktree is now back on that diverged head, clear the durable link the + // cache would otherwise keep serving until it expires. + if ( + options?.worktreeId && + linkedPRNumber != null && + cached?.data?.headDivergedFromMergedPRAtOid != null + ) { + const currentHeadOid = findWorktreeById(get(), options.worktreeId)?.head ?? null + if ( + shouldClearDivergedLinkedMergedPR({ + pr: cached.data, + linkedPRNumber, + requestHeadOid: currentHeadOid + }) + ) { + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: (worktree) => + shouldApplyDivergedLinkedPRClear({ + worktree, + linkedPRNumber, + branch, + requestHeadOid: currentHeadOid + }) + } + ) + } + } return cached.data } @@ -2953,6 +3023,7 @@ export const createGitHubSlice: StateCreator = (s const candidateWorktree = options?.worktreeId ? findWorktreeById(get(), options.worktreeId) : null + const requestHeadOid = candidateWorktree?.head ?? null const outcome = runtimeRepo ? await callRuntimeRpc( runtimeRepo.target, @@ -2961,7 +3032,7 @@ export const createGitHubSlice: StateCreator = (s repo: runtimeRepo.repo.id, branch, linkedPRNumber, - currentHeadOid: candidateWorktree?.head ?? null, + currentHeadOid: requestHeadOid, ...(fallbackPRNumber !== null ? { fallbackPRNumber, acceptMergedFallbackPR: fallbackPRSource !== null } : {}) @@ -2980,7 +3051,7 @@ export const createGitHubSlice: StateCreator = (s branch, cacheKey, worktreeId: options?.worktreeId, - currentHeadOid: candidateWorktree?.head ?? null, + currentHeadOid: requestHeadOid, linkedPRNumber, fallbackPRNumber, fallbackPRSource, @@ -3004,7 +3075,7 @@ export const createGitHubSlice: StateCreator = (s fallbackPRNumber, acceptMergedFallbackPR: fallbackPRNumber !== null && fallbackPRSource !== null, - currentHeadOid: candidateWorktree?.head ?? null + currentHeadOid: requestHeadOid }) .then((pr) => pr @@ -3054,6 +3125,27 @@ export const createGitHubSlice: StateCreator = (s if (didUpdatePRCache) { debouncedSaveCache(get()) } + if ( + options?.worktreeId && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) + ) { + // Why: only clear the durable link that produced this exact probe; + // branch/head drift means the stale result no longer owns the worktree. + void get().updateWorktreeMeta( + options.worktreeId, + { linkedPR: null }, + { + shouldApply: (worktree) => + shouldApplyDivergedLinkedPRClear({ + worktree, + linkedPRNumber, + branch, + requestHeadOid + }) + } + ) + } } if ( shouldPreserveExistingPRForFallbackMiss({ @@ -3773,6 +3865,34 @@ export const createGitHubSlice: StateCreator = (s }, applyGitHubPRRefreshEvent: (event) => { + // Why: the sidebar/left-list refresh for local repos flows through the main + // PR coordinator (not fetchPRForBranch), so it must run the same guarded + // clear when main stamps a merged linked PR whose head has diverged. + const divergedLinkedPRClears: { + worktreeId: string + linkedPRNumber: number + branch: string + requestHeadOid: string | null + }[] = [] + if (event.outcome?.kind === 'found') { + const pr = event.outcome.pr + for (const alias of event.aliases) { + const linkedPRNumber = alias.linkedPRNumber ?? null + const requestHeadOid = alias.currentHeadOid ?? null + if ( + alias.worktreeId && + linkedPRNumber != null && + shouldClearDivergedLinkedMergedPR({ pr, linkedPRNumber, requestHeadOid }) + ) { + divergedLinkedPRClears.push({ + worktreeId: alias.worktreeId, + linkedPRNumber, + branch: alias.branch, + requestHeadOid + }) + } + } + } let didUpdatePRCache = false set((s) => { const nextSequences = { ...s.prRefreshSequences } @@ -3969,6 +4089,21 @@ export const createGitHubSlice: StateCreator = (s if (didUpdatePRCache && event.outcome && event.outcome.kind !== 'upstream-error') { debouncedSaveCache(get()) } + for (const clear of divergedLinkedPRClears) { + void get().updateWorktreeMeta( + clear.worktreeId, + { linkedPR: null }, + { + shouldApply: (worktree) => + shouldApplyDivergedLinkedPRClear({ + worktree, + linkedPRNumber: clear.linkedPRNumber, + branch: clear.branch, + requestHeadOid: clear.requestHeadOid + }) + } + ) + } }, refreshAllGitHub: () => { diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index 0dd6b240f40..4a1d7b36ff6 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -2490,6 +2490,7 @@ describe('web GitHub preload API', () => { repoId: 'repo-1', repoPath, branch: 'feature', + currentHeadOid: 'head-oid', linkedPRNumber: null, fallbackPRNumber: 9, fallbackPRSource: 'pr-cache' @@ -2510,6 +2511,7 @@ describe('web GitHub preload API', () => { branch: 'feature', linkedPRNumber: null, fallbackPRNumber: 9, + currentHeadOid: 'head-oid', acceptMergedFallbackPR: true } } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 8647c9b0d86..5cf3a3c8ea8 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1805,6 +1805,7 @@ function createGitHubApi(): WebGitHubApi { branch: candidate.branch, linkedPRNumber: candidate.linkedPRNumber ?? null, fallbackPRNumber: candidate.fallbackPRNumber ?? null, + currentHeadOid: candidate.currentHeadOid ?? null, ...(acceptMergedFallbackPR ? { acceptMergedFallbackPR: true } : {}) }) return pr diff --git a/src/shared/types.ts b/src/shared/types.ts index 7e35a920803..5b84818af79 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1137,6 +1137,13 @@ export type PRInfo = { // of the PR's own commits (behind update-branch/web commits). Cache staleness // checks must honor that confirmation without re-querying GitHub. confirmedContainedHeadOid?: string + // Why: the worktree HEAD OID this merged linked PR was confirmed to have + // diverged from (a definite not-contained probe). Head-scoped, not a bare + // boolean, so a PR-number-coalesced refresh broadcast cannot clear a sibling + // worktree whose own head is still on the PR's line of work. Clearing a + // durable linked PR requires this positive signal for that exact head, never + // the mere absence of a containment confirmation after a rate-limit/error. + headDivergedFromMergedPRAtOid?: string /** Target branch name for PR-created worktree compare-base repair. */ baseRefName?: string prRepo?: GitHubRepositoryIdentity @@ -1179,6 +1186,10 @@ export type GitHubPRRefreshAlias = { linkedPRNumber?: number | null fallbackPRNumber?: number | null fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null + // Why: request-time worktree HEAD. Merged branch-matched PRs are only visible + // for heads that belong to the PR, and refresh consumers need this snapshot to + // clear a durable linked PR once main confirms the head diverged. + currentHeadOid?: string | null } export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { @@ -1196,10 +1207,6 @@ export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & { cachedMergeable?: PRMergeableState | null cachedMergeStateStatus?: string | null localGitOptions?: { wslDistro?: string } - // Why: merged branch-matched PRs are only visible for heads that belong to - // the PR; without the worktree head, a panel-supplied fallback number would - // keep a merged PR alive head-blind after the branch moves on. - currentHeadOid?: string | null } export type GitHubPRRefreshSkippedReason = From 2e5057f341629a4b18f1ce1042e4afab0d5effb8 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:26:36 -0700 Subject: [PATCH 003/170] Fix mobile emulator device rotation (#6326) --- .../components/emulator-pane/EmulatorPane.tsx | 4 +- .../emulator-device-frame-layout.test.ts | 54 ++++++++- .../emulator-device-frame-layout.ts | 40 +++++++ .../emulator-device-frame-visibility.test.tsx | 1 + .../emulator-device-frame.input.test.tsx | 1 + .../emulator-pane/emulator-device-frame.tsx | 105 ++++++++---------- .../emulator-device-row-mapping.ts | 20 ++++ .../emulator-screen-gesture.test.ts | 19 ++++ .../emulator-screen-stream-content.test.tsx | 25 ++++- .../emulator-screen-stream-content.tsx | 36 +++++- .../emulator-pane/emulator-screen-surface.tsx | 103 +++++++++++++++++ .../use-emulator-pane-controls.ts | 26 ++++- .../use-emulator-pane-session.test.tsx | 47 ++++++++ .../use-emulator-pane-session.ts | 42 ++++--- 14 files changed, 435 insertions(+), 88 deletions(-) create mode 100644 src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts create mode 100644 src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx diff --git a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx index 742ff08df29..476c79e232d 100644 --- a/src/renderer/src/components/emulator-pane/EmulatorPane.tsx +++ b/src/renderer/src/components/emulator-pane/EmulatorPane.tsx @@ -29,7 +29,8 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula previewUrl, wsUrl, streamKey, - isLive + isLive, + visualOrientation } = useEmulatorPaneSession({ worktreeId, tabId: tab?.id, @@ -80,6 +81,7 @@ export default function EmulatorPane({ tab, worktreeId, isActive = true }: Emula deviceName={displayName} loading={loading} isLive={isLive} + visualOrientation={visualOrientation} isActive={isActive} onTap={(x, y) => void sendTap(x, y)} onGesture={(points) => void sendGesture(points)} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts index 9cfd4d2651b..f1c567e9f55 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { fitDeviceFrameToPane, resolveDeviceFrameKind } from './emulator-device-frame-layout' +import { + fitDeviceFrameToPane, + resolveDeviceFrameKind, + resolveVisualScreenAspectRatio, + resolveVisualStreamGeometry +} from './emulator-device-frame-layout' describe('resolveDeviceFrameKind', () => { it('prefers device names over aspect-ratio fallback', () => { @@ -13,6 +18,42 @@ describe('resolveDeviceFrameKind', () => { }) }) +describe('resolveVisualScreenAspectRatio', () => { + it('uses the requested orientation even when the stream canvas has not swapped yet', () => { + const portraitStream = { width: 390, height: 844 } + + expect(resolveVisualScreenAspectRatio(portraitStream, 'portrait')).toBeCloseTo(390 / 844) + expect(resolveVisualScreenAspectRatio(portraitStream, 'landscape')).toBeCloseTo(844 / 390) + }) + + it('uses the requested orientation only before a stream frame arrives', () => { + expect(resolveVisualScreenAspectRatio(null, 'portrait')).toBeCloseTo(9 / 19) + expect(resolveVisualScreenAspectRatio(null, 'landscape')).toBeCloseTo(19 / 9) + }) +}) + +describe('resolveVisualStreamGeometry', () => { + it('uses visual orientation for the interactive screen rectangle', () => { + const geometry = resolveVisualStreamGeometry({ width: 390, height: 844 }, 'landscape') + + expect(geometry.size).toEqual({ + width: 844, + height: 390 + }) + expect(geometry.streamRotation).toBe(90) + }) + + it('rotates a stale landscape stream back into portrait geometry', () => { + const geometry = resolveVisualStreamGeometry({ width: 844, height: 390 }, 'portrait') + + expect(geometry.size).toEqual({ + width: 390, + height: 844 + }) + expect(geometry.streamRotation).toBe(-90) + }) +}) + describe('fitDeviceFrameToPane', () => { it('fits a phone shell and hardware controls inside the pane', () => { const pane = { width: 600, height: 1000 } @@ -50,4 +91,15 @@ describe('fitDeviceFrameToPane', () => { expect(layout?.shellWidth).toBeGreaterThan(1) expect(layout?.shellHeight).toBeGreaterThan(1) }) + + it('fits the entire phone frame as landscape after a device rotation', () => { + const pane = { width: 1000, height: 600 } + const aspectRatio = resolveVisualScreenAspectRatio({ width: 844, height: 390 }, 'landscape') + const layout = fitDeviceFrameToPane(pane, aspectRatio, 'phone') + + expect(layout).not.toBeNull() + expect(layout?.width).toBeLessThanOrEqual(pane.width) + expect(layout?.height).toBeLessThanOrEqual(pane.height) + expect(layout ? layout.shellWidth / layout.shellHeight : 0).toBeGreaterThan(1) + }) }) diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts index 62aac8367fd..0948ad3dba0 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-layout.ts @@ -5,6 +5,8 @@ export type StreamSize = { height: number } +export type EmulatorDeviceVisualOrientation = 'portrait' | 'landscape' + export type PaneSize = { width: number height: number @@ -23,6 +25,12 @@ export type DeviceFrameLayout = { sideButtonThickness: number } +export type VisualStreamGeometry = { + aspectRatio: number + size: StreamSize | null + streamRotation: -90 | 0 | 90 +} + const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value)) const FIT_MARGIN_PX = 0.5 @@ -40,6 +48,38 @@ export function resolveDeviceFrameKind( return screenAspectRatio > 0.62 && screenAspectRatio < 1.62 ? 'tablet' : 'phone' } +export function resolveVisualScreenAspectRatio( + streamSize: StreamSize | null, + visualOrientation: EmulatorDeviceVisualOrientation +): number { + return resolveVisualStreamGeometry(streamSize, visualOrientation).aspectRatio +} + +export function resolveVisualStreamGeometry( + streamSize: StreamSize | null, + visualOrientation: EmulatorDeviceVisualOrientation +): VisualStreamGeometry { + // Why: serve-sim can keep the same canvas dimensions after rotate; the pane's + // physical frame and input rect still need to follow the successful request. + const width = streamSize?.width ?? 9 + const height = streamSize?.height ?? 19 + const shortSide = Math.min(width, height) + const longSide = Math.max(width, height) + const visualSize = + visualOrientation === 'landscape' + ? { width: longSide, height: shortSide } + : { width: shortSide, height: longSide } + const streamIsLandscape = streamSize ? streamSize.width > streamSize.height : false + const visualIsLandscape = visualOrientation === 'landscape' + const streamRotation = + streamSize && streamIsLandscape !== visualIsLandscape ? (visualIsLandscape ? 90 : -90) : 0 + return { + aspectRatio: visualSize.width / visualSize.height, + size: streamSize ? visualSize : null, + streamRotation + } +} + function fitScreenToPane(paneSize: PaneSize | null, aspectRatio: number): PaneSize | null { if (!paneSize || paneSize.width <= 0 || paneSize.height <= 0 || aspectRatio <= 0) { return null diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx index 12409234ff4..b2a9621bae1 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame-visibility.test.tsx @@ -67,6 +67,7 @@ async function renderFrame(isActive: boolean): Promise { wsUrl="ws://127.0.0.1:3100/ws" loading={false} isLive={true} + visualOrientation="portrait" isActive={isActive} onTap={vi.fn()} onGesture={vi.fn()} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx index b32824bb9fc..dd198c82b98 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.input.test.tsx @@ -99,6 +99,7 @@ function renderFrame(props?: { wsUrl="ws://127.0.0.1:3100/ws" loading={false} isLive={true} + visualOrientation="portrait" isActive={true} onTap={props?.onTap ?? vi.fn()} onGesture={props?.onGesture ?? vi.fn()} diff --git a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx index 7f9e2cda8ca..9b278241d17 100644 --- a/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-device-frame.tsx @@ -9,7 +9,9 @@ import { } from 'react' import { fitDeviceFrameToPane, + resolveVisualStreamGeometry, resolveDeviceFrameKind, + type EmulatorDeviceVisualOrientation, type StreamSize } from './emulator-device-frame-layout' import { @@ -25,12 +27,10 @@ import { type PointerSample } from './emulator-screen-gesture' import { PhoneHardwareButtons } from './emulator-phone-hardware-buttons' -import { EmulatorScreenStreamContent } from './emulator-screen-stream-content' +import { EmulatorScreenSurface } from './emulator-screen-surface' import { useEmulatorControlStream } from './use-emulator-control-stream' import { useEmulatorPaneSize } from './use-emulator-pane-size' import { useEmulatorScreenKeyboard } from './use-emulator-screen-keyboard' -import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label' -import { cn } from '@/lib/utils' type EmulatorDeviceFrameProps = { previewUrl?: string @@ -39,8 +39,8 @@ type EmulatorDeviceFrameProps = { deviceName?: string loading: boolean isLive: boolean - /** False when the pane is backgrounded (hidden tab/worktree). Gates the frame - * stream so a parked emulator stops decoding, matching the pane's visibility. */ + visualOrientation: EmulatorDeviceVisualOrientation + /** False when backgrounded; parks the stream with the pane's visibility. */ isActive: boolean onTap: (x: number, y: number) => void onGesture: (points: EmulatorGesturePoint[]) => void @@ -55,11 +55,10 @@ type PendingWheelGesture = { timerId: number | null } -type ScreenCoordinateEvent = { - clientX: number - clientY: number - currentTarget: HTMLDivElement -} +type ScreenCoordinateEvent = Pick< + PointerEvent, + 'clientX' | 'clientY' | 'currentTarget' +> export function EmulatorDeviceFrame({ previewUrl, @@ -68,6 +67,7 @@ export function EmulatorDeviceFrame({ deviceName, loading, isLive, + visualOrientation, isActive, onTap, onGesture @@ -81,6 +81,10 @@ export function EmulatorDeviceFrame({ const wheelGestureRef = useRef(null) const [streamError, setStreamError] = useState(false) const [streamSize, setStreamSize] = useState(null) + const visualStreamGeometry = useMemo( + () => resolveVisualStreamGeometry(streamSize, visualOrientation), + [streamSize, visualOrientation] + ) const canInteract = isLive && !loading && !streamError const { cancelKeyboardFrames, sendKeyboardFrames, sendTouch } = useEmulatorControlStream( wsUrl, @@ -103,9 +107,9 @@ export function EmulatorDeviceFrame({ mapClientPointToSimulatorScreen( { clientX: event.clientX, clientY: event.clientY }, event.currentTarget.getBoundingClientRect(), - streamSize + visualStreamGeometry.size ), - [streamSize] + [visualStreamGeometry.size] ) const sendGesturePoints = useCallback( @@ -262,7 +266,7 @@ export function EmulatorDeviceFrame({ const action = resolveEmulatorPointerAction( samples, event.currentTarget.getBoundingClientRect(), - streamSize + visualStreamGeometry.size ) if (!action) { return @@ -273,7 +277,7 @@ export function EmulatorDeviceFrame({ sendGesturePoints(action.points) } }, - [canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, streamSize] + [canInteract, mapEventToScreenPoint, onTap, sendGesturePoints, sendTouch, visualStreamGeometry] ) const handleWheel = useCallback( @@ -290,7 +294,7 @@ export function EmulatorDeviceFrame({ deltaY: event.deltaY }, event.currentTarget.getBoundingClientRect(), - streamSize + visualStreamGeometry.size ) if (!delta) { return @@ -317,7 +321,7 @@ export function EmulatorDeviceFrame({ timerId: window.setTimeout(flushWheelGesture, WHEEL_GESTURE_IDLE_MS) } }, - [canInteract, flushWheelGesture, sendTouch, streamSize] + [canInteract, flushWheelGesture, sendTouch, visualStreamGeometry] ) const handleStreamSize = useCallback((size: NonNullable) => { @@ -331,20 +335,16 @@ export function EmulatorDeviceFrame({ setStreamError(true) }, []) - // Why: only decode frames while the pane is visible. A backgrounded but still - // attached emulator otherwise keeps running the WebCodecs H.264 decode / MJPEG - // blob churn against a hidden canvas, with frames still flowing over IPC (and - // the network for paired SSH/remote clients). The session stays attached, so - // the stream re-fires within a frame on re-show. Mirrors the browser pane's - // park-when-hidden behavior. + // Why: hidden panes still receive emulator frames, including over SSH, so + // parking the stream avoids background decode/IPC churn while staying attached. const showStream = isActive && isLive && Boolean(previewUrl) - const screenAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 - const screenAspectRatioStyle = streamSize - ? `${streamSize.width} / ${streamSize.height}` - : '9 / 19' + const streamAspectRatio = streamSize ? streamSize.width / streamSize.height : 9 / 19 + // Why: serve-sim may keep portrait-sized pixels for portrait-locked apps; the + // physical frame still follows the last successful rotate request. + const screenAspectRatio = visualStreamGeometry.aspectRatio const frameKind = useMemo( - () => resolveDeviceFrameKind(deviceName, screenAspectRatio), - [deviceName, screenAspectRatio] + () => resolveDeviceFrameKind(deviceName, streamAspectRatio), + [deviceName, streamAspectRatio] ) const frameLayout = useMemo( () => fitDeviceFrameToPane(paneSize, screenAspectRatio, frameKind), @@ -376,43 +376,28 @@ export function EmulatorDeviceFrame({ borderRadius: frameLayout ? `${frameLayout.outerRadius}px` : '54px' }} > -
- {/* Why: the stream is the actual emulator screen; fake in-screen - chrome doubles up with iOS's real status bar and makes bezels lie. */} - -
+ previewUrl={previewUrl} + screenAspectRatio={screenAspectRatio} + showStream={Boolean(showStream)} + streamError={streamError} + streamKey={streamKey} + streamRotation={visualStreamGeometry.streamRotation} + /> diff --git a/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts b/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts new file mode 100644 index 00000000000..b778034f12e --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-device-row-mapping.ts @@ -0,0 +1,20 @@ +import type { SimulatorDeviceRow } from './emulator-pane-types' + +// Raw shape returned by the unified `emulator.listDevices` RPC (iOS simulators + Android AVDs). +export type RawEmulatorDevice = { + id: string + name: string + state: string + detail?: string + isAvailable?: boolean +} + +export function toSimulatorDeviceRows(raw: RawEmulatorDevice[]): SimulatorDeviceRow[] { + return raw.map((device) => ({ + name: device.name, + udid: device.id, + state: device.state === 'booted' ? 'Booted' : 'Shutdown', + runtime: device.detail, + isAvailable: device.isAvailable + })) +} diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts b/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts index b199863bd77..3d22461c5b2 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts +++ b/src/renderer/src/components/emulator-pane/emulator-screen-gesture.test.ts @@ -5,6 +5,7 @@ import { resolveEmulatorWheelDelta, resolveEmulatorPointerAction } from './emulator-screen-gesture' +import { resolveVisualStreamGeometry } from './emulator-device-frame-layout' const rect = { left: 10, @@ -114,4 +115,22 @@ describe('emulator screen gestures', () => { }) ).toBeNull() }) + + it('maps the full landscape screen while the stream canvas shape settles', () => { + const landscapeRect = { + left: 0, + top: 0, + width: 400, + height: 200 + } + const stalePortraitStream = { width: 390, height: 844 } + + expect( + mapClientPointToSimulatorScreen( + { clientX: 380, clientY: 100 }, + landscapeRect, + resolveVisualStreamGeometry(stalePortraitStream, 'landscape').size + ) + ).toEqual({ x: 0.95, y: 0.5 }) + }) }) diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx index 14104843014..af19c21e41e 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.test.tsx @@ -90,7 +90,10 @@ afterEach(() => { vi.restoreAllMocks() }) -async function renderStream(streamKey = 'abc'): Promise { +async function renderStream( + streamKey = 'abc', + props?: { screenAspectRatio?: number; streamRotation?: -90 | 0 | 90 } +): Promise { await act(async () => { root.render( { onStreamError={vi.fn()} onStreamSize={vi.fn()} previewUrl="http://127.0.0.1:3100/stream.mjpeg" + screenAspectRatio={props?.screenAspectRatio} showStream={true} streamError={false} streamKey={streamKey} + streamRotation={props?.streamRotation} /> ) }) @@ -122,6 +127,24 @@ describe('EmulatorScreenStreamContent', () => { const img = container.querySelector('img') expect(img?.getAttribute('src')).toBe('blob:emulator-frame-1') + expect(img?.className).toContain('object-contain') + expect(img?.className).not.toContain('object-fill') + }) + + it('rotates mismatched stream media without stretching it', async () => { + await renderStream('abc', { screenAspectRatio: 844 / 390, streamRotation: 90 }) + + await act(async () => { + frameListeners[0]?.({ streamId: 'stream-1', bytes: new Uint8Array([1, 2, 3]).buffer }) + }) + + const img = container.querySelector('img') + expect(img?.className).toContain('object-contain') + expect(img?.className).toContain('absolute') + expect(img?.className).not.toContain('object-fill') + expect(img?.style.transform).toBe('translate(-50%, -50%) rotate(90deg)') + expect(img?.style.width).toBe(`${100 / (844 / 390)}%`) + expect(img?.style.height).toBe(`${100 * (844 / 390)}%`) }) it('clears the previous frame while a new stream key is connecting', async () => { diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx index 3e86ed14562..4b83495e084 100644 --- a/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx +++ b/src/renderer/src/components/emulator-pane/emulator-screen-stream-content.tsx @@ -1,8 +1,9 @@ import { Loader2 } from 'lucide-react' -import { useEffect } from 'react' +import { useEffect, type CSSProperties } from 'react' import { useEmulatorFrameStream } from './use-emulator-frame-stream' import { useEmulatorVideoStream } from './use-emulator-video-stream' import { translate } from '@/i18n/i18n' +import type { VisualStreamGeometry } from './emulator-device-frame-layout' type StreamSize = { height: number @@ -14,9 +15,11 @@ type EmulatorScreenStreamContentProps = { onStreamError: () => void onStreamSize: (size: StreamSize) => void previewUrl?: string + screenAspectRatio?: number showStream: boolean streamError: boolean streamKey?: string + streamRotation?: VisualStreamGeometry['streamRotation'] } // Android sessions stream H.264 over scrcpy://; iOS uses an MJPEG http URL. @@ -27,9 +30,11 @@ export function EmulatorScreenStreamContent({ onStreamError, onStreamSize, previewUrl, + screenAspectRatio = 9 / 19, showStream, streamError, - streamKey + streamKey, + streamRotation = 0 }: EmulatorScreenStreamContentProps) { const androidDeviceId = previewUrl && previewUrl.startsWith(SCRCPY_PREFIX) @@ -54,11 +59,18 @@ export function EmulatorScreenStreamContent({ } }, [frameStream.error, video.error, onStreamError]) + const mediaStyle = resolveStreamMediaStyle(streamRotation, screenAspectRatio) + const mediaClassName = + streamRotation === 0 + ? 'block h-full w-full bg-black object-contain' + : 'absolute left-1/2 top-1/2 block max-w-none bg-black object-contain' + if (androidDeviceId && showStream && !video.error) { return ( { const { naturalWidth, naturalHeight } = event.currentTarget @@ -123,3 +136,18 @@ export function EmulatorScreenStreamContent({ ) } + +function resolveStreamMediaStyle( + streamRotation: VisualStreamGeometry['streamRotation'], + screenAspectRatio: number +): CSSProperties | undefined { + if (streamRotation === 0 || screenAspectRatio <= 0) { + return undefined + } + return { + height: `${100 * screenAspectRatio}%`, + transform: `translate(-50%, -50%) rotate(${streamRotation}deg)`, + transformOrigin: 'center', + width: `${100 / screenAspectRatio}%` + } +} diff --git a/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx b/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx new file mode 100644 index 00000000000..325116ff56b --- /dev/null +++ b/src/renderer/src/components/emulator-pane/emulator-screen-surface.tsx @@ -0,0 +1,103 @@ +import type { + ClipboardEventHandler, + FocusEventHandler, + KeyboardEventHandler, + PointerEventHandler, + WheelEventHandler +} from 'react' +import { cn } from '@/lib/utils' +import { getEmulatorScreenAriaLabel } from './emulator-screen-aria-label' +import { EmulatorScreenStreamContent } from './emulator-screen-stream-content' +import type { + DeviceFrameLayout, + StreamSize, + VisualStreamGeometry +} from './emulator-device-frame-layout' + +type EmulatorScreenSurfaceProps = { + frameLayout: DeviceFrameLayout | null + isLive: boolean + keyboardCaptureActive: boolean + loading: boolean + onBlur: FocusEventHandler + onKeyDown: KeyboardEventHandler + onPaste: ClipboardEventHandler + onPointerCancel: PointerEventHandler + onPointerDown: PointerEventHandler + onPointerMove: PointerEventHandler + onPointerUp: PointerEventHandler + onStreamError: () => void + onStreamSize: (size: StreamSize) => void + onWheel: WheelEventHandler + previewUrl?: string + screenAspectRatio: number + showStream: boolean + streamError: boolean + streamKey?: string + streamRotation: VisualStreamGeometry['streamRotation'] +} + +export function EmulatorScreenSurface({ + frameLayout, + isLive, + keyboardCaptureActive, + loading, + onBlur, + onKeyDown, + onPaste, + onPointerCancel, + onPointerDown, + onPointerMove, + onPointerUp, + onStreamError, + onStreamSize, + onWheel, + previewUrl, + screenAspectRatio, + showStream, + streamError, + streamKey, + streamRotation +}: EmulatorScreenSurfaceProps) { + return ( +
+ {/* Why: the stream is the actual emulator screen; fake in-screen chrome + doubles up with iOS's real status bar and makes bezels lie. */} + +
+ ) +} diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts index c760c4f5152..42f9d9cab70 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-controls.ts @@ -1,9 +1,13 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useRef, useState } from 'react' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' +import type { EmulatorDeviceVisualOrientation } from './emulator-device-frame-layout' import type { EmulatorGesturePoint } from './emulator-screen-gesture' -export function useEmulatorPaneControls(worktreeId: string) { +export function useEmulatorPaneControls(worktreeId: string, onRotateSettled?: () => void) { const nextRotateOrientationRef = useRef<'landscape_left' | 'portrait'>('landscape_left') + const visualOrientationEpochRef = useRef(0) + const [visualOrientation, setVisualOrientation] = + useState('portrait') const sendTap = useCallback( async (x: number, y: number) => { @@ -28,13 +32,27 @@ export function useEmulatorPaneControls(worktreeId: string) { const sendRotate = useCallback(async () => { const orientation = nextRotateOrientationRef.current + const epoch = visualOrientationEpochRef.current await callRuntimeRpc({ kind: 'local' }, 'emulator.rotate', { orientation, worktree: worktreeId }) + if (visualOrientationEpochRef.current !== epoch) { + return null + } + const nextVisualOrientation = orientation === 'landscape_left' ? 'landscape' : 'portrait' + setVisualOrientation(nextVisualOrientation) nextRotateOrientationRef.current = orientation === 'landscape_left' ? 'portrait' : 'landscape_left' - }, [worktreeId]) + onRotateSettled?.() + return nextVisualOrientation + }, [onRotateSettled, worktreeId]) - return { sendTap, sendButton, sendGesture, sendRotate } + const resetVisualOrientation = useCallback(() => { + visualOrientationEpochRef.current += 1 + nextRotateOrientationRef.current = 'landscape_left' + setVisualOrientation('portrait') + }, []) + + return { sendTap, sendButton, sendGesture, sendRotate, visualOrientation, resetVisualOrientation } } diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx index a3342c94c6a..a9a67bd11d4 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.test.tsx @@ -49,6 +49,7 @@ const deviceList = devices.map((device) => ({ })) let attachDeferred: Deferred +let rotateDeferred: Deferred let container: HTMLDivElement let root: Root let latest: ReturnType | null = null @@ -113,6 +114,7 @@ describe('useEmulatorPaneSession', () => { globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true attachDeferred = createDeferred() + rotateDeferred = createDeferred() latest = null consumePrelaunchedSimulatorSession(WORKTREE_ID) rememberPrelaunchedSimulatorSession(WORKTREE_ID, { @@ -133,6 +135,9 @@ describe('useEmulatorPaneSession', () => { if (method === 'emulator.attach') { return runtimeSuccess(await attachDeferred.promise) } + if (method === 'emulator.rotate') { + return runtimeSuccess(await rotateDeferred.promise) + } if (method === 'emulator.shutdown') { return runtimeSuccess({ deviceUdid: 'device-b' }) } @@ -196,6 +201,48 @@ describe('useEmulatorPaneSession', () => { expect(latest?.previewUrl).toBe('http://127.0.0.1:3200/stream.mjpeg') }) + it('ignores a rotate response from the previous session after switching devices', async () => { + await act(async () => { + root.render() + }) + await flushEffects() + + let rotatePromise: Promise = Promise.resolve() + await act(async () => { + rotatePromise = latest?.sendRotate() ?? Promise.resolve() + await Promise.resolve() + }) + + await act(async () => { + container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await Promise.resolve() + }) + + await act(async () => { + rotateDeferred.resolve() + await rotatePromise + await Promise.resolve() + }) + + expect(latest?.visualOrientation).toBe('portrait') + + await act(async () => { + attachDeferred.resolve({ + attached: true, + info: { + deviceUdid: 'device-b', + displayName: 'iPhone B', + streamUrl: 'http://127.0.0.1:3200/stream.mjpeg', + wsUrl: 'ws://127.0.0.1:3200/ws' + } + }) + await attachDeferred.promise + await Promise.resolve() + }) + + expect(latest?.visualOrientation).toBe('portrait') + }) + it('keeps simulator discovery setup errors during auto attach', async () => { const message = 'Xcode Simulator tools are unavailable. Install full Xcode, open it once, then select it with `sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`.' diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts index 0a1dac53c64..2ac04a9ca99 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts @@ -8,6 +8,7 @@ import { type SimulatorDeviceRow } from './emulator-pane-types' import { markSimulatorDeviceBooted, markSimulatorDeviceShutdown } from './emulator-device-state' +import { toSimulatorDeviceRows, type RawEmulatorDevice } from './emulator-device-row-mapping' import { useEmulatorPaneControls } from './use-emulator-pane-controls' import { useEmulatorPaneSessionEvents } from './use-emulator-pane-session-events' import { @@ -56,25 +57,25 @@ export function useEmulatorPaneSession({ const liveTargetRef = useRef(prelaunchedState.liveTarget) const deviceRefreshErrorRef = useRef(null) const suppressAutoAttachRef = useRef(false) - const { sendTap, sendButton, sendGesture, sendRotate } = useEmulatorPaneControls(worktreeId) + const refreshStreamKey = useCallback(() => setStreamKey(String(Date.now())), []) + const { + sendTap, + sendButton, + sendGesture, + sendRotate, + visualOrientation, + resetVisualOrientation + } = useEmulatorPaneControls(worktreeId, refreshStreamKey) const refreshDevices = useCallback(async (bootedTarget?: string | null) => { try { // Unified list so Android devices/AVDs appear alongside iOS simulators. - const raw = (await callRuntimeRpc({ kind: 'local' }, 'emulator.listDevices', {})) as { - id: string - name: string - state: string - detail?: string - isAvailable?: boolean - }[] - const list: SimulatorDeviceRow[] = raw.map((device) => ({ - name: device.name, - udid: device.id, - state: device.state === 'booted' ? 'Booted' : 'Shutdown', - runtime: device.detail, - isAvailable: device.isAvailable - })) + const raw = (await callRuntimeRpc( + { kind: 'local' }, + 'emulator.listDevices', + {} + )) as RawEmulatorDevice[] + const list = toSimulatorDeviceRows(raw) const next = markSimulatorDeviceBooted(list, bootedTarget) if (!mountedRef.current) { return next @@ -106,6 +107,9 @@ export function useEmulatorPaneSession({ if (attached && rows !== deviceRows) { setDevices(rows) } + if (attached && target && target !== liveTargetRef.current) { + resetVisualOrientation() + } const row = rows.find((d) => d.udid === target || d.name === target) const displayName = row?.name || deviceLabel(info) const enriched = { ...info, displayName, state: attached ? 'Booted' : info?.state } @@ -126,7 +130,7 @@ export function useEmulatorPaneSession({ useAppStore.getState().setTabLabel(tabId, displayName) } }, - [devices, tabId] + [devices, resetVisualOrientation, tabId] ) const clearSessionAfterShutdown = useCallback( @@ -141,13 +145,14 @@ export function useEmulatorPaneSession({ liveTargetRef.current = null suppressAutoAttachRef.current = true setStreamKey(null) + resetVisualOrientation() setError(null) if (tabId) { const row = devices.find((device) => device.udid === target || device.name === target) useAppStore.getState().setTabLabel(tabId, row?.name || 'Mobile Emulator') } }, - [devices, selectedUdid, session, tabId] + [devices, resetVisualOrientation, selectedUdid, session, tabId] ) const attach = useCallback( @@ -189,6 +194,7 @@ export function useEmulatorPaneSession({ setSession(null) setStreamKey(null) liveTargetRef.current = null + resetVisualOrientation() } const res = (await callRuntimeRpc({ kind: 'local' }, 'emulator.attach', { device: target, @@ -238,6 +244,7 @@ export function useEmulatorPaneSession({ devices, loading, refreshDevices, + resetVisualOrientation, selectedUdid, tabId, worktreeId @@ -301,6 +308,7 @@ export function useEmulatorPaneSession({ sendButton, sendGesture, sendRotate, + visualOrientation, displayName: view.displayName, previewUrl: view.previewUrl, wsUrl: view.wsUrl, From 28fe4df81740dc43fcbeff88fa756bed9a759fe3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:33:05 -0700 Subject: [PATCH 004/170] Preserve terminal scrollbar position after tab switches (#7472) Co-authored-by: Orca --- config/reliability-gates.jsonc | 91 +++++++++++++++++++ .../SourceControlActionRepoOverrideNote.tsx | 5 - .../terminal-scroll-intent.test.ts | 65 +++++++++++-- .../pane-manager/terminal-scroll-intent.ts | 22 ++++- 4 files changed, 167 insertions(+), 16 deletions(-) diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index bd90a3370b8..bdabd2a7b79 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -458,6 +458,97 @@ ], "demotionRule": "Cannot promote while it only checks visual rendering." }, + { + "id": "terminal-scroll.intent-preservation", + "title": "Scrollbar drag intent survives tab and visibility resume", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "terminal lifecycle", + "hidden-to-visible resume", + "tab switching", + "xterm scrollbar DOM", + "scrollback" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [], + "coverageNotes": "Renderer-unit coverage proves the shared xterm DOM intent path. Live Electron evidence is PR validation evidence for local macOS only until the flow has stable automation; live SSH, WSL, Linux, and Windows paths remain unproved.", + "motivatingLinks": [ + "STA-1341" + ], + "invariant": "A user-driven xterm scrollbar thumb or track scroll updates the live terminal scroll intent before tab, visibility, or layout resume enforces intent, so resume preserves the latest dragged viewport instead of an older pinned line.", + "oracle": "Pointerdown on .xterm-scrollbar or .xterm-slider followed by xterm viewport movement records the new pinned viewport, and enforcing current intent restores that dragged line instead of stale top intent.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "assertions": [ + "pointer-driven .xterm-scrollbar and .xterm-slider scrolls update terminal scroll intent", + "a scrollbar-dragged viewport is restored instead of stale top intent", + "terminal body pointer activity is not treated as scrollbar intent" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-05", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts", + "result": "passed", + "durationSeconds": 0.3, + "summary": "1 test file(s) passed, 17 tests passed in this worktree." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "focused renderer unit test" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New experimental gate; needs soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "The new scrollbar DOM target assertions fail when only .xterm-viewport pointerdown is recognized. Needs saved CI/intentional-break artifact before blocking promotion." + }, + "performanceBudget": { + "required": true, + "evidence": "Runtime fix is limited to constant-time class/closest checks on pointerdown. It must not add polling, output parsing, PTY listing, hidden-pane wakeups, resize calls, or startup awaits." + }, + "promotionCriteria": [ + "Collect stable CI soak history for the renderer-unit gate.", + "Add stable live Electron automation for the scrollbar drag plus tab-switch repro.", + "Attach red/green evidence for stale intent restoring the wrong viewport." + ], + "knownGaps": [ + "Manifest command is deterministic renderer-unit coverage, not a live Electron scrollbar drag.", + "Live daemon, SSH, WSL, remote-runtime, Linux, and Windows validation is not covered by this gate.", + "Future xterm scrollbar DOM class changes are only caught if they break the currently modeled class contract." + ], + "demotionRule": "Demote or quarantine if the unit gate flakes without a product bug or harness bug filed to the owner." + }, { "id": "startup-upgrade.persisted-session-corpus", "title": "Current Orca preserves or recovers old production persisted sessions", diff --git a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx index ec389927e42..b3ca7aa3e20 100644 --- a/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx +++ b/src/renderer/src/components/settings/SourceControlActionRepoOverrideNote.tsx @@ -32,11 +32,6 @@ function getRecipeOverrideFieldLabel(field: SourceControlActionRecipeOverrideFie 'auto.components.settings.SourceControlActionRepoOverrideNote.commandTemplate', 'Command template' ) - default: { - // Fail at compile time if the override-field union grows a new variant. - const _exhaustive: never = field - return _exhaustive - } } } diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index f132e786daf..bc728ce7dd2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -53,12 +53,15 @@ class TestElement extends EventTarget { } closest(selector: string): TestElement | null { - if (!selector.startsWith('.')) { - return null - } - const className = selector.slice(1) - if (this.classList.contains(className)) { - return this + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (!trimmed.startsWith('.')) { + continue + } + const className = trimmed.slice(1) + if (this.classList.contains(className)) { + return this + } } return this.parentElement?.closest(selector) ?? null } @@ -270,6 +273,56 @@ describe('terminal scroll intent', () => { disposable.dispose() }) + it.each(['xterm-scrollbar', 'xterm-slider'])( + 'tracks pointer-driven xterm %s scrolls as user intent', + (scrollbarClassName) => { + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 100, baseY: 100 }) + const hostElement = new TestElement() + const scrollbarTarget = new TestElement(scrollbarClassName) + const scrollbarChild = new TestElement('xterm-scrollbar-child') + hostElement.append(scrollbarTarget) + scrollbarTarget.append(scrollbarChild) + const host = hostElement as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + terminal.buffer.active.viewportY = 50 + host.dispatchEvent(new Event('scroll')) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + + scrollbarChild.dispatchEvent(new Event('pointerdown', { bubbles: true })) + scrollbarChild.dispatchEvent(new Event('scroll', { bubbles: true })) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + disposable.dispose() + } + ) + + it('restores a scrollbar-dragged viewport instead of stale top intent', () => { + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 0, baseY: 600 }) + const hostElement = new TestElement() + const scrollbar = new TestElement('xterm-scrollbar') + const slider = new TestElement('xterm-slider') + hostElement.append(scrollbar) + scrollbar.append(slider) + const host = hostElement as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host, 'terminal-1') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + + terminal.buffer.active.viewportY = 572 + slider.dispatchEvent(new Event('pointerdown', { bubbles: true })) + slider.dispatchEvent(new Event('scroll', { bubbles: true })) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(572) + expect(terminal.buffer.active.viewportY).toBe(572) + disposable.dispose() + }) + it('does not treat terminal body pointer activity as scrollbar intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 35537c11f24..c0ab35f654c 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -42,6 +42,14 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< const terminalScrollIntentByKey = new Map() const BOTTOM_TOLERANCE_ROWS = 1 +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') function readBufferSnapshot( terminal: TerminalScrollIntentTarget @@ -120,6 +128,14 @@ function safeScrollCall(fn: () => void): boolean { } } +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { writeIntent(terminal, 'followOutput') } @@ -255,11 +271,7 @@ export function attachTerminalScrollIntentTracking( } const onPointerDown = (event: PointerEvent): void => { - const target = event.target - pointerScrollActive = - typeof Element !== 'undefined' && - target instanceof Element && - (target.classList.contains('xterm-viewport') || target.closest('.xterm-viewport') !== null) + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) } const onPointerDone = (): void => { From 3c963dd285783609ab0367cfd95a6b45de2b1603 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:39:40 -0700 Subject: [PATCH 005/170] Prepare mobile 0.0.24 (#7546) Bump marketing version 0.0.22 -> 0.0.24 and Android versionCode 4 -> 5. The 0.0.22 base was never committed after prior releases, so the Jul 6 builds carrying the show-all-worktrees fix (#7500) regressed below the 0.0.23 already on TestFlight (iOS) and collided with the existing 0.0.22/versionCode 4 APK (Android, no upgrade signal). Committing the bump makes app.json authoritative again so 0.0.24 supersedes both. Co-authored-by: Orca --- mobile/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/app.json b/mobile/app.json index 27d3b7d88c6..b50147f0497 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.22", + "version": "0.0.24", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 4 + "versionCode": 5 }, "plugins": [ "expo-router", From 82a59f8822b0e82b47baf296270560ad883d185a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:58:32 -0700 Subject: [PATCH 006/170] perf(renderer): purge pane-expand maps on worktree removal (#7534) Co-authored-by: Orca --- .../src/store/slices/worktrees.test.ts | 27 +++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 13 +++++++++ 2 files changed, 40 insertions(+) diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 58dfa5cb7e2..cdaf8a1010c 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -3241,6 +3241,29 @@ describe('removeWorktree state cleanup', () => { expect(getHostedReviewLinkMutationGenerationForTests(surviving.id)).toBeGreaterThan(0) }) + it('cleans up expandedPaneByTabId/canExpandPaneByTabId for removed worktree tabs', async () => { + const store = createTestStore() + const removed = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) + const surviving = makeWorktree({ id: 'repo1::/path/wt2', repoId: 'repo1', path: '/path/wt2' }) + + store.setState({ + worktreesByRepo: { repo1: [removed, surviving] }, + tabsByWorktree: { + [removed.id]: [makeTerminalTab({ id: 'removed-tab', worktreeId: removed.id })], + [surviving.id]: [makeTerminalTab({ id: 'surviving-tab', worktreeId: surviving.id })] + }, + // Split panes write these even when false; only closeTab deleted them + // before, so removeWorktree used to strand them for the whole session. + expandedPaneByTabId: { 'removed-tab': true, 'surviving-tab': false }, + canExpandPaneByTabId: { 'removed-tab': false, 'surviving-tab': true } + } as unknown as Partial) + + await store.getState().removeWorktree(removed.id) + + expect(store.getState().expandedPaneByTabId).toEqual({ 'surviving-tab': false }) + expect(store.getState().canExpandPaneByTabId).toEqual({ 'surviving-tab': true }) + }) + it('cleans up automatic agent resume claims for removed worktree tabs', async () => { const store = createTestStore() const removed = makeWorktree({ @@ -5849,6 +5872,8 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { 'tab-3': { panes: [] } }, ptyIdsByTabId: { 'tab-1': ['pty-1'], 'tab-2': ['pty-2'], 'tab-3': ['pty-3'] }, + expandedPaneByTabId: { 'tab-1': true, 'tab-2': false, 'tab-3': true }, + canExpandPaneByTabId: { 'tab-1': true, 'tab-2': true, 'tab-3': false }, runtimePaneTitlesByTabId: { 'tab-1': 'claude', 'tab-3': 'bash' }, automaticAgentResumeClaimsByTabId: { 'tab-1': { @@ -5914,6 +5939,8 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => { }) expect(s.terminalLayoutsByTabId).toEqual({ 'tab-3': { panes: [] } }) expect(s.ptyIdsByTabId).toEqual({ 'tab-3': ['pty-3'] }) + expect(s.expandedPaneByTabId).toEqual({ 'tab-3': true }) + expect(s.canExpandPaneByTabId).toEqual({ 'tab-3': false }) expect(s.runtimePaneTitlesByTabId).toEqual({ 'tab-3': 'bash' }) expect(s.automaticAgentResumeClaimsByTabId).toEqual({ 'tab-3': { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 5966530cc2d..a3236500d64 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1992,6 +1992,10 @@ function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial ...s.automaticAgentResumeClaimsByTabId } const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + // Why: closeTab deletes these two per-tab maps but removeWorktree missed + // them, so a split pane's expand flags outlived its deleted worktree for + // the renderer's whole session. Purge them here to match closeTab. + const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } + const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } for (const tabId of tabIds) { delete nextLayouts[tabId] delete nextPtyIdsByTabId[tabId] delete nextRuntimePaneTitlesByTabId[tabId] delete nextAutomaticAgentResumeClaimsByTabId[tabId] delete nextNativeChatLaunchPromptByTabId[tabId] + delete nextExpandedPaneByTabId[tabId] + delete nextCanExpandPaneByTabId[tabId] } const nextDeleteState = { ...s.deleteStateByWorktreeId } delete nextDeleteState[worktreeId] @@ -3251,6 +3262,8 @@ export const createWorktreeSlice: StateCreator automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, terminalLayoutsByTabId: nextLayouts, + expandedPaneByTabId: nextExpandedPaneByTabId, + canExpandPaneByTabId: nextCanExpandPaneByTabId, deleteStateByWorktreeId: nextDeleteState, baseStatusByWorktreeId: (() => { const nextStatus = { ...s.baseStatusByWorktreeId } From b38f5babb461142c7aaf3f0429b8fb51fb35f0a3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:59:41 -0700 Subject: [PATCH 007/170] perf(renderer): pause the shared useNow clock while the window is hidden (#7537) Co-authored-by: Orca --- .../src/components/dashboard/useNow.test.ts | 57 ++++++++++++++++++- .../src/components/dashboard/useNow.ts | 34 +++++++---- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/dashboard/useNow.test.ts b/src/renderer/src/components/dashboard/useNow.test.ts index b36d6dc19a0..304eb190aab 100644 --- a/src/renderer/src/components/dashboard/useNow.test.ts +++ b/src/renderer/src/components/dashboard/useNow.test.ts @@ -1,7 +1,12 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createSharedNowClock } from './useNow' describe('createSharedNowClock', () => { + afterEach(() => { + // Only the hidden-window test stubs `document`; unstub so it can't leak. + vi.unstubAllGlobals() + }) + it('shares one timer across subscribers and clears it when idle', () => { let now = 1_000 const intervalCallbacks: (() => void)[] = [] @@ -65,4 +70,54 @@ describe('createSharedNowClock', () => { expect(second).toHaveBeenCalledTimes(1) expect(setIntervalMock).toHaveBeenCalledTimes(2) }) + + it('does not arm the timer while hidden and catches up when the window becomes visible', () => { + let visibilityState: DocumentVisibilityState = 'hidden' + const documentListeners = new Map void>() + vi.stubGlobal('document', { + get visibilityState() { + return visibilityState + }, + addEventListener: vi.fn((event: string, listener: () => void) => { + documentListeners.set(event, listener) + }), + removeEventListener: vi.fn() + }) + + let now = 1_000 + const handle = {} as ReturnType + const setIntervalMock = vi.fn(() => handle) + const clearIntervalMock = vi.fn() + const listener = vi.fn() + const clock = createSharedNowClock(30_000, { + now: () => now, + setInterval: setIntervalMock, + clearInterval: clearIntervalMock + }) + + const unsubscribe = clock.subscribe(listener) + // Hidden on mount: no timer armed, no forced render, snapshot untouched. + expect(setIntervalMock).not.toHaveBeenCalled() + expect(listener).not.toHaveBeenCalled() + expect(clock.getSnapshot()).toBe(1_000) + + // Becoming visible catches the stale label up and arms the shared timer. + now = 90_000 + visibilityState = 'visible' + documentListeners.get('visibilitychange')?.() + expect(listener).toHaveBeenCalledTimes(1) + expect(clock.getSnapshot()).toBe(90_000) + expect(setIntervalMock).toHaveBeenCalledTimes(1) + + // Hiding again stops the shared timer so it stops ticking in the background. + visibilityState = 'hidden' + documentListeners.get('visibilitychange')?.() + expect(clearIntervalMock).toHaveBeenCalledWith(handle) + + unsubscribe() + expect(document.removeEventListener).toHaveBeenCalledWith( + 'visibilitychange', + documentListeners.get('visibilitychange') + ) + }) }) diff --git a/src/renderer/src/components/dashboard/useNow.ts b/src/renderer/src/components/dashboard/useNow.ts index bb7c1ad4221..aa965e95f2e 100644 --- a/src/renderer/src/components/dashboard/useNow.ts +++ b/src/renderer/src/components/dashboard/useNow.ts @@ -1,9 +1,13 @@ import { useSyncExternalStore } from 'react' +import { + installWindowVisibilityInterval, + type WindowVisibilityIntervalTimer +} from '@/lib/window-visibility-interval' type ClockDeps = { now: () => number - setInterval: (callback: () => void, intervalMs: number) => ReturnType - clearInterval: (handle: ReturnType) => void + setInterval: (callback: () => void, intervalMs: number) => WindowVisibilityIntervalTimer + clearInterval: (handle: WindowVisibilityIntervalTimer) => void } type SharedNowClock = { @@ -22,7 +26,7 @@ export function createSharedNowClock( } ): SharedNowClock { let now = deps.now() - let timer: ReturnType | null = null + let stopInterval: (() => void) | null = null const listeners = new Set<() => void>() const tick = (): void => { @@ -36,18 +40,24 @@ export function createSharedNowClock( getSnapshot: () => now, subscribe: (listener) => { listeners.add(listener) - if (!timer) { - // Why: all mounted relative-time labels at the same cadence can share - // one timer. Refresh immediately on restart so remounted labels don't - // display the stale timestamp left from the previous subscriber set. - tick() - timer = deps.setInterval(tick, intervalMs) + if (!stopInterval) { + // Why: all mounted relative-time labels at this cadence share one + // visibility-gated timer. installWindowVisibilityInterval runs tick + // immediately on (re)start — so remounted or newly-visible labels catch + // up at once — and pauses the interval while the window is hidden, so + // backgrounded agent rows stop re-rendering for ticks no one can see. + stopInterval = installWindowVisibilityInterval({ + run: tick, + intervalMs, + setIntervalFn: deps.setInterval, + clearIntervalFn: deps.clearInterval + }) } return () => { listeners.delete(listener) - if (listeners.size === 0 && timer) { - deps.clearInterval(timer) - timer = null + if (listeners.size === 0 && stopInterval) { + stopInterval() + stopInterval = null } } } From 2e44f727555ceba6234b5757980620b6870a0d86 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:00:29 -0700 Subject: [PATCH 008/170] perf(main): stop allocating a debug snapshot on every PTY delivery event (#7541) Co-authored-by: Orca --- src/main/ipc/pty.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 088f0d1d130..7ceb7551698 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -1435,13 +1435,24 @@ export function registerPtyHandlers( } function recordPtyRendererDeliveryPressure(): void { - const current = readCurrentPtyRendererDeliveryDebugSnapshot() - peakPendingChars = Math.max(peakPendingChars, current.pendingChars) - peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, current.maxPendingCharsByPty) - peakRendererInFlightChars = Math.max(peakRendererInFlightChars, current.rendererInFlightChars) + // Why: this fires on every PTY delivery event (per send, per flush, per + // onData append). Update the four diagnostic peaks directly instead of + // allocating a full 13-field debug snapshot object per call — that object + // is only needed when the debug getter is actually read. Peak values are + // computed identically to readCurrentPtyRendererDeliveryDebugSnapshot. + let pendingChars = 0 + let maxPendingCharsByPty = 0 + for (const pending of pendingData.values()) { + const chars = pending.data.length + pendingChars += chars + maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) + } + peakPendingChars = Math.max(peakPendingChars, pendingChars) + peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, maxPendingCharsByPty) + peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) peakMaxRendererInFlightCharsByPty = Math.max( peakMaxRendererInFlightCharsByPty, - current.maxRendererInFlightCharsByPty + getMaxMapValue(rendererInFlightCharsByPty.values()) ) } From dfc839fcb5df0ed6245e047ee8e6f4dfcc46acb9 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:01:14 -0700 Subject: [PATCH 009/170] perf(renderer): gate WorktreeCardAgentsBody's send-target subscriptions (#7545) Co-authored-by: Orca --- .../components/sidebar/WorktreeCardAgents.tsx | 35 ++++---- .../worktree-card-send-target-inputs.test.ts | 87 +++++++++++++++++++ .../worktree-card-send-target-inputs.ts | 47 ++++++++++ 3 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts create mode 100644 src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index b6a0f9f88e0..aa6f02d8378 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -2,12 +2,14 @@ agent rendering and lineage disclosure behavior together; splitting during this bug fix would risk divergent parent-child row behavior. */ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' import DashboardAgentRow from '@/components/dashboard/DashboardAgentRow' import { useNow } from '@/components/dashboard/useNow' import { deriveRunningAgentSendTargets } from '@/lib/running-agent-targets' +import { selectSendTargetInputs } from './worktree-card-send-target-inputs' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { cn } from '@/lib/utils' import type { DashboardAgentRow as DashboardAgentRowData } from '@/components/dashboard/useDashboardData' @@ -89,12 +91,15 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ const dropAgentStatus = useAppStore((s) => s.dropAgentStatus) const dismissRetainedAgent = useAppStore((s) => s.dismissRetainedAgent) const agentSendPopoverTargetMode = useAppStore((s) => s.agentSendPopoverTargetMode) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) + // Why: these five maps are read only to derive send-target eligibility, which + // matters only while the send-target popover targets THIS card. Two of them + // (runtimePaneTitlesByTabId, agentStatusByPaneKey) churn on every pane-title + // and agent-status write app-wide, so subscribing to them unconditionally made + // every mounted agent body re-render on unrelated terminals. Gate the + // subscription: return a stable empty constant when the popover isn't ours, so + // useShallow keeps the same result and idle bodies stop reacting to the churn. + const sendTargetInputs = useAppStore(useShallow((s) => selectSendTargetInputs(s, worktreeId))) const sendPromptToSidebarAgentTarget = useAppStore((s) => s.sendPromptToSidebarAgentTarget) const focusedAgentPaneKey = useFocusedAgentPaneKey(worktreeId) const compactAgentListRootRef = useRef(null) @@ -134,16 +139,7 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ } return new Map( - deriveRunningAgentSendTargets( - { - agentStatusByPaneKey, - tabsByWorktree, - terminalLayoutsByTabId, - ptyIdsByTabId, - runtimePaneTitlesByTabId - }, - worktreeId - ).map((target) => [ + deriveRunningAgentSendTargets(sendTargetInputs, worktreeId).map((target) => [ target.paneKey, agentSendPopoverTargetMode?.status === 'sending' && agentSendPopoverTargetMode.sendingPaneKey === target.paneKey @@ -159,12 +155,11 @@ const WorktreeCardAgentsBody = React.memo(function WorktreeCardAgentsBody({ agentStatusEpoch, agentSendPopoverTargetMode?.sendingPaneKey, agentSendPopoverTargetMode?.status, - agentStatusByPaneKey, isAgentSendTargetModeActive, - ptyIdsByTabId, - runtimePaneTitlesByTabId, - tabsByWorktree, - terminalLayoutsByTabId, + // sendTargetInputs is a stable empty constant while inactive and a + // shallow-compared bundle of the five maps while active, so it covers all + // five former deps in one reference. + sendTargetInputs, worktreeId ]) diff --git a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts new file mode 100644 index 00000000000..03286c4f330 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import type { AgentSendPopoverTargetMode } from '@/store/slices/ui' +import { + EMPTY_SEND_TARGET_INPUTS, + selectSendTargetInputs, + type SendTargetInputsState +} from './worktree-card-send-target-inputs' + +const BASE: SendTargetInputsState = { + agentSendPopoverTargetMode: null, + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {} +} + +function makeMode(worktreeId: string): AgentSendPopoverTargetMode { + return { + id: 'mode-1', + instanceId: 'inst-1', + worktreeId, + source: 'diff-notes', + prompt: 'do it', + label: 'Send', + launchSource: 'sidebar', + eligiblePaneKeys: [], + disabledPaneKeys: {}, + status: 'open' + } +} + +describe('selectSendTargetInputs', () => { + it('returns the shared empty constant when the popover does not target this worktree', () => { + // Inactive (popover closed): stable empty reference. + const inactive = selectSendTargetInputs(BASE, 'wt-A') + expect(inactive).toBe(EMPTY_SEND_TARGET_INPUTS) + + // Churning the hottest maps while inactive must NOT change the selected + // reference, so a useShallow subscription skips the re-render. + const churned: SendTargetInputsState = { + ...BASE, + runtimePaneTitlesByTabId: { 'tab-1': 'claude' }, + agentStatusByPaneKey: { 'tab-1:leaf-1': {} as never } + } + const afterChurn = selectSendTargetInputs(churned, 'wt-A') + expect(afterChurn).toBe(EMPTY_SEND_TARGET_INPUTS) + expect(shallow(inactive, afterChurn)).toBe(true) + }) + + it('stays the stable empty constant when the popover targets a different worktree', () => { + const s: SendTargetInputsState = { ...BASE, agentSendPopoverTargetMode: makeMode('wt-OTHER') } + expect(selectSendTargetInputs(s, 'wt-A')).toBe(EMPTY_SEND_TARGET_INPUTS) + }) + + it('exposes the live maps when the popover targets this worktree', () => { + const titles = { 'tab-1': 'claude' } + const s: SendTargetInputsState = { + ...BASE, + agentSendPopoverTargetMode: makeMode('wt-A'), + runtimePaneTitlesByTabId: titles + } + const active = selectSendTargetInputs(s, 'wt-A') + // Live map references pass straight through so eligibility derives correctly. + expect(active.runtimePaneTitlesByTabId).toBe(titles) + expect(active).not.toBe(EMPTY_SEND_TARGET_INPUTS) + }) + + it('shallow-changes only when a subscribed map reference actually changes while active', () => { + const titles = { 'tab-1': 'claude' } + const s1: SendTargetInputsState = { + ...BASE, + agentSendPopoverTargetMode: makeMode('wt-A'), + runtimePaneTitlesByTabId: titles + } + const r1 = selectSendTargetInputs(s1, 'wt-A') + + // Same underlying map refs -> shallow-equal -> no re-render. + expect(shallow(r1, selectSendTargetInputs(s1, 'wt-A'))).toBe(true) + + // A real pane-title write replaces the map ref -> shallow-unequal -> the + // open popover re-derives eligibility, exactly as before this change. + const s2: SendTargetInputsState = { ...s1, runtimePaneTitlesByTabId: { 'tab-1': 'codex' } } + expect(shallow(r1, selectSendTargetInputs(s2, 'wt-A'))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts new file mode 100644 index 00000000000..a7e7efadc4e --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-card-send-target-inputs.ts @@ -0,0 +1,47 @@ +import type { AppState } from '@/store/types' +import type { RunningAgentTargetState } from '@/lib/running-agent-targets' + +export type SendTargetInputsState = Pick< + AppState, + | 'agentSendPopoverTargetMode' + | 'agentStatusByPaneKey' + | 'tabsByWorktree' + | 'terminalLayoutsByTabId' + | 'ptyIdsByTabId' + | 'runtimePaneTitlesByTabId' +> + +// Why: shared stable reference returned whenever the send-target popover isn't +// targeting this card. useShallow keeps the same result across unrelated +// pane-title / agent-status churn, so idle agent bodies stop re-rendering. +// Frozen so this shared singleton can never be mutated by a consumer. +export const EMPTY_SEND_TARGET_INPUTS: RunningAgentTargetState = Object.freeze({ + agentStatusByPaneKey: {}, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + runtimePaneTitlesByTabId: {} +}) + +/** + * Select the five maps `deriveRunningAgentSendTargets` needs — but only while + * the send-target popover targets this worktree. When it doesn't, return a + * stable empty constant so a useShallow-wrapped subscription stays referentially + * equal across the (very hot) pane-title / agent-status writes and skips the + * re-render that would otherwise fire on every mounted agent body app-wide. + */ +export function selectSendTargetInputs( + s: SendTargetInputsState, + worktreeId: string +): RunningAgentTargetState { + if (s.agentSendPopoverTargetMode?.worktreeId !== worktreeId) { + return EMPTY_SEND_TARGET_INPUTS + } + return { + agentStatusByPaneKey: s.agentStatusByPaneKey, + tabsByWorktree: s.tabsByWorktree, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId + } +} From e33b2006f49a6da81b5df420534fb45c1c7443bf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:12:32 -0700 Subject: [PATCH 010/170] Remove stale max-lines lint disables from files under the limit (#7548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 110 files carried an eslint/oxlint-disable max-lines directive but are already under the default max-lines budget (300 .ts / 400 .tsx / 600 .mjs / 800 test), so the suppression is dead. Removing it restores real max-lines coverage on these files with zero behavior change. Each removed directive had max-lines as its only rule; verified via a full oxlint run (0 max-lines violations, 0 new errors). Diff is pure deletions (200 lines, 0 additions) — no code touched. Co-authored-by: Orca --- mobile/src/hooks/use-mobile-dictation.ts | 3 --- src/cli/browser.test.ts | 1 - src/cli/handlers/file.test.ts | 1 - src/main/agent-hooks/remote-hook-service-installers.test.ts | 1 - src/main/attribution/terminal-attribution.test.ts | 3 --- src/main/automations/external-manager.test.ts | 1 - src/main/browser/browser-cookie-import.test.ts | 2 -- src/main/browser/browser-screencast-stream.test.ts | 1 - src/main/browser/cdp-bridge-integration.test.ts | 1 - src/main/claude-usage/store.test.ts | 2 -- src/main/cli/wsl-cli-installer.test.ts | 2 -- src/main/codex-usage/scanner-paths.test.ts | 1 - src/main/daemon/client.test.ts | 1 - src/main/daemon/daemon-server.test.ts | 1 - src/main/daemon/reattach-snapshot.test.ts | 1 - src/main/daemon/shell-ready.test.ts | 3 --- src/main/daemon/terminal-host.test.ts | 1 - src/main/git/runner-wsl-gh-fallback.test.ts | 1 - src/main/github/client-create-pr.test.ts | 2 -- src/main/github/client-issue-source.test.ts | 4 ---- src/main/github/client-work-items.test.ts | 3 --- src/main/github/project-view/internals.ts | 4 ---- src/main/gitlab/issues.test.ts | 1 - src/main/gitlab/merge-request-creation.test.ts | 1 - src/main/ipc/browser.test.ts | 1 - src/main/ipc/crash-reporting.test.ts | 1 - src/main/ipc/diagnostics.test.ts | 1 - src/main/ipc/filesystem-import.test.ts | 2 -- src/main/ipc/filesystem-list-files.test.ts | 1 - src/main/ipc/filesystem-watcher.test.ts | 3 --- src/main/ipc/pty-management.test.ts | 4 ---- src/main/ipc/register-core-handlers.test.ts | 2 -- src/main/ipc/remote-workspace-patch-queue.test.ts | 1 - src/main/ipc/shell.test.ts | 1 - src/main/ipc/telemetry.test.ts | 1 - src/main/ipc/workspace-cleanup.test.ts | 2 -- src/main/ipc/worktree-logic.test.ts | 3 --- src/main/observability/bundle.test.ts | 1 - src/main/observability/redactor.test.ts | 1 - src/main/opencode-usage/scanner.test.ts | 1 - src/main/opencode/hook-service.test.ts | 5 ----- src/main/project-groups/nested-repo-discovery.test.ts | 2 -- src/main/providers/ssh-filesystem-provider.test.ts | 3 --- src/main/runtime/mobile-subscribe-integration.test.ts | 1 - src/main/runtime/orca-runtime-browser.test.ts | 3 --- src/main/runtime/orchestration/coordinator.test.ts | 1 - src/main/runtime/orchestration/db.test.ts | 1 - src/main/runtime/rpc/methods/files.test.ts | 2 -- src/main/runtime/rpc/methods/git.test.ts | 1 - src/main/runtime/rpc/methods/github.test.ts | 1 - src/main/runtime/rpc/methods/gitlab.test.ts | 1 - src/main/runtime/rpc/terminal-subscribe-buffer.test.ts | 1 - src/main/runtime/rpc/ws-transport.test.ts | 3 --- src/main/runtime/rpc/ws-transport.ts | 3 --- src/main/source-control/hosted-review-creation.test.ts | 1 - src/main/ssh/ssh-channel-multiplexer.test.ts | 2 -- src/main/ssh/ssh-config-parser.test.ts | 1 - src/main/ssh/ssh-connection-utils.test.ts | 1 - src/main/ssh/ssh-relay-native-deps-install.test.ts | 5 ----- .../ssh/ssh-relay-session-agent-hooks.integration.test.ts | 3 --- src/main/ssh/ssh-relay-session.test.ts | 3 --- src/main/startup/run-electron-vite-dev.test.ts | 2 -- src/main/terminal-history.test.ts | 4 ---- src/main/window/attach-main-window-services.test.ts | 1 - src/main/workspace-space-analysis.test.ts | 1 - src/relay/dispatcher.test.ts | 3 --- src/relay/fs-handler.test.ts | 2 -- src/relay/subprocess.test.ts | 1 - src/renderer/src/components/UpdateCard.test.ts | 2 -- .../src/components/editor/editor-autosave-controller.test.ts | 2 -- src/renderer/src/components/onboarding/NotificationStep.tsx | 1 - .../right-sidebar/source-control-dropdown-items.test.ts | 1 - src/renderer/src/components/settings/AgentsPane.test.tsx | 2 -- .../src/components/settings/TerminalPane.pwsh.test.ts | 3 --- .../src/components/sidebar/WorktreeCardAgents.test.tsx | 2 -- src/renderer/src/components/sidebar/WorktreeCardAgents.tsx | 3 --- src/renderer/src/components/sidebar/smart-attention.test.ts | 1 - src/renderer/src/components/sidebar/smart-sort.test.ts | 1 - .../src/components/sidebar/visible-worktrees.test.ts | 1 - .../components/status-bar/mergeSnapshotAndSessions.test.ts | 3 --- .../src/components/tab-bar/TabBar.context-menu.test.ts | 2 -- .../terminal-pane/agent-interrupt-inference.test.ts | 1 - .../components/terminal-pane/layout-serialization.test.ts | 2 -- .../terminal-pane/terminal-agent-session-fork.test.ts | 1 - .../terminal-pane/terminal-shortcut-policy.test.ts | 3 --- .../terminal-pane/use-notification-dispatch.test.ts | 1 - .../src/hooks/agent-hook-completion-notifications.test.ts | 1 - src/renderer/src/hooks/ipc-tab-switch.test.ts | 3 --- src/renderer/src/lib/agent-status.test.ts | 4 ---- .../src/lib/floating-workspace-terminal-actions.test.ts | 2 -- src/renderer/src/lib/launch-agent-background-session.test.ts | 1 - src/renderer/src/runtime/runtime-git-client.test.ts | 1 - src/renderer/src/runtime/runtime-linear-client.test.ts | 2 -- .../src/runtime/sync-runtime-graph-terminal-layout.test.ts | 2 -- src/renderer/src/store/slices/diffComments.test.ts | 2 -- src/renderer/src/store/slices/hosted-review.test.ts | 2 -- src/renderer/src/store/slices/new-markdown.test.ts | 2 -- src/renderer/src/store/slices/terminals-hydration.test.ts | 1 - src/renderer/src/store/slices/workspace-cleanup.test.ts | 2 -- src/renderer/src/web/web-runtime-client.test.ts | 3 --- src/shared/telemetry-events.test.ts | 2 -- src/shared/window-shortcut-policy.test.ts | 4 ---- tests/e2e/activity-agent-pane-isolation.spec.ts | 1 - tests/e2e/droid-notification.spec.ts | 1 - tests/e2e/onboarding.spec.ts | 1 - tests/e2e/setup-guide-sidebar.spec.ts | 1 - tests/e2e/ssh-localhost.spec.ts | 1 - tests/e2e/terminal-output-scheduler.spec.ts | 1 - tests/e2e/terminal-restart-persistence.spec.ts | 1 - tests/e2e/terminal-shortcuts.spec.ts | 1 - 110 files changed, 200 deletions(-) diff --git a/mobile/src/hooks/use-mobile-dictation.ts b/mobile/src/hooks/use-mobile-dictation.ts index 5f43ffcf0e0..a1028cf9205 100644 --- a/mobile/src/hooks/use-mobile-dictation.ts +++ b/mobile/src/hooks/use-mobile-dictation.ts @@ -1,6 +1,3 @@ -/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording, - * chunk upload, completion, and cancellation in one hook so native audio state - * cannot drift from the runtime RPC lifecycle. */ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' // Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro // can't resolve Node's builtin in a React Native bundle. diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 7ca2c509a57..83ca43d4859 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this file groups every CLI browser-command test (page targeting, profiles, waits, viewport) so test-fixture imports and the runtime-client mock stay shared in one place. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() diff --git a/src/cli/handlers/file.test.ts b/src/cli/handlers/file.test.ts index fa03e286ce2..4e36ddbfff2 100644 --- a/src/cli/handlers/file.test.ts +++ b/src/cli/handlers/file.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: file CLI coverage shares one mocked runtime setup across command contracts. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const callMock = vi.fn() diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 3cd61db5556..8a85128cdef 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this fixture verifies the shared remote hook installer fake across every managed agent so SSH regressions are caught together. */ import { describe, expect, it, vi } from 'vitest' import type { SFTPWrapper } from 'ssh2' diff --git a/src/main/attribution/terminal-attribution.test.ts b/src/main/attribution/terminal-attribution.test.ts index a051d04ca7d..86096168f0c 100644 --- a/src/main/attribution/terminal-attribution.test.ts +++ b/src/main/attribution/terminal-attribution.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these tests exercise generated shell wrapper -scripts end-to-end, and keeping the regression fixtures adjacent makes the -attribution safety cases easier to audit. */ import { execFileSync } from 'node:child_process' import { chmodSync, diff --git a/src/main/automations/external-manager.test.ts b/src/main/automations/external-manager.test.ts index a44ad186f37..dc96b9b964a 100644 --- a/src/main/automations/external-manager.test.ts +++ b/src/main/automations/external-manager.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: external automation mapping and lifecycle IPC share fixtures. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createExternalAutomation, diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 767f35931f0..c2965e7f17f 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: cookie import tests share import-time Electron mocks plus - browser-specific cookie fixtures; splitting would duplicate brittle setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { sessionFromPartitionMock, dialogShowOpenDialogMock } = vi.hoisted(() => ({ diff --git a/src/main/browser/browser-screencast-stream.test.ts b/src/main/browser/browser-screencast-stream.test.ts index 6c6be1cbc5c..d1fc838807d 100644 --- a/src/main/browser/browser-screencast-stream.test.ts +++ b/src/main/browser/browser-screencast-stream.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one fixture-backed file covers screencast frame, viewport, dialog, and cleanup behavior together. */ import { Buffer } from 'node:buffer' import { EventEmitter } from 'node:events' import { describe, expect, it, vi } from 'vitest' diff --git a/src/main/browser/cdp-bridge-integration.test.ts b/src/main/browser/cdp-bridge-integration.test.ts index e1992fe2360..daaae60540e 100644 --- a/src/main/browser/cdp-bridge-integration.test.ts +++ b/src/main/browser/cdp-bridge-integration.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: integration test covering the full browser automation pipeline end-to-end. */ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 1bd4502d246..08a76e4696c 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: scan gating, pricing, and automation -usage attribution share one stateful Claude usage store fixture. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ClaudeUsagePersistedState } from './types' diff --git a/src/main/cli/wsl-cli-installer.test.ts b/src/main/cli/wsl-cli-installer.test.ts index 4698281ecd0..b9228fcb6a2 100644 --- a/src/main/cli/wsl-cli-installer.test.ts +++ b/src/main/cli/wsl-cli-installer.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- WSL CLI tests cover one installer state machine with shared - runner fixtures; splitting would duplicate the fake WSL filesystem setup. */ import type { CliInstallStatus } from '../../shared/cli-install-types' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/main/codex-usage/scanner-paths.test.ts b/src/main/codex-usage/scanner-paths.test.ts index 3f3eec4426a..46c10be0c8f 100644 --- a/src/main/codex-usage/scanner-paths.test.ts +++ b/src/main/codex-usage/scanner-paths.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: path discovery and legacy bridge scan preference cases need shared mocked homes to keep filesystem behavior realistic. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { linkSync, lstatSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 7d2bea915d2..25aaebe164f 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: daemon connection, RPC, event, and disconnect behavior share one socket test harness. */ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type Server, type Socket } from 'node:net' diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ab348667791..ae017e0ef02 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: daemon server RPC, auth, stream batching, and shutdown behavior share one socket/client harness; splitting would duplicate setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { connect, type Server, type Socket } from 'node:net' import { tmpdir } from 'node:os' diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index 8735b76be53..a8bdf252442 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines */ import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalHost } from './terminal-host' import { HeadlessEmulator } from './headless-emulator' diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index af3ced5ade5..b741ef057d0 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: shell-ready wrapper coverage keeps zsh, - bash, marker scanning, and env restoration cases in one suite so the - generated wrapper contract is reviewed as a unit. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawnSync } from 'node:child_process' import { tmpdir } from 'node:os' diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index ecc9fd1161d..4f92d4c1e1f 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Session, type SubprocessHandle } from './session' import { TerminalHost } from './terminal-host' diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 4bafa9f1984..3aae182432c 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as WslModule from '../wsl' diff --git a/src/main/github/client-create-pr.test.ts b/src/main/github/client-create-pr.test.ts index d46f461b963..6c64e4b8066 100644 --- a/src/main/github/client-create-pr.test.ts +++ b/src/main/github/client-create-pr.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: create-PR tests share gh/SSH mocks across - template, CLI, and error-path cases; keeping them together prevents drift. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { readFile } from 'node:fs/promises' diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 2b4a039b36a..3daf66943c9 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: the issue-source test suite covers the -heuristic split (#1076), the partial-failure envelope (feature 1), and the -three-state preference matrix (feature 2) as one surface so a regression in -any of them blocks the same merge gate. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GhUtils from './gh-utils' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index e6ce6005c69..5a281ddfc80 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: work-items coverage stays in one file so -the fan-out mock plumbing (issue + PR gh calls, allSettled handling) does -not drift across split files. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/github/project-view/internals.ts b/src/main/github/project-view/internals.ts index 52509c30652..586bbaa6e95 100644 --- a/src/main/github/project-view/internals.ts +++ b/src/main/github/project-view/internals.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: shared infrastructure for project-view — -slug validation, error classification, runGraphql/runRest, and rate-limit -synthesis. Co-located so the read and write paths observe identical -classification semantics. */ // Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site // for gh calls. The legacy plain `execFileAsync` is NOT used here — routing // every gh call through the runner gives us transient-5xx retry, WSL path diff --git a/src/main/gitlab/issues.test.ts b/src/main/gitlab/issues.test.ts index f567936d930..edc6d245733 100644 --- a/src/main/gitlab/issues.test.ts +++ b/src/main/gitlab/issues.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: GitLab issue mutation/list coverage shares glab mocks across related endpoint cases. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GlUtils from './gl-utils' diff --git a/src/main/gitlab/merge-request-creation.test.ts b/src/main/gitlab/merge-request-creation.test.ts index cd1b5de9882..b7ead7c98be 100644 --- a/src/main/gitlab/merge-request-creation.test.ts +++ b/src/main/gitlab/merge-request-creation.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: MR creation tests share glab and SSH filesystem mocks across CLI, template, and duplicate-detection paths. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 98fab06f4d8..042140cd38b 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: browser IPC tests share one mocked trust-boundary handler registry plus registration waiters; splitting would duplicate setup and weaken coverage. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/ipc/crash-reporting.test.ts b/src/main/ipc/crash-reporting.test.ts index 1c2059bb762..04432899abb 100644 --- a/src/main/ipc/crash-reporting.test.ts +++ b/src/main/ipc/crash-reporting.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: crash-reporting IPC tests share mocked Electron/observability handler setup; splitting would duplicate brittle IPC wiring. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CrashReportRecord } from '../../shared/crash-reporting' diff --git a/src/main/ipc/diagnostics.test.ts b/src/main/ipc/diagnostics.test.ts index b30d013f6ea..2868fdec19d 100644 --- a/src/main/ipc/diagnostics.test.ts +++ b/src/main/ipc/diagnostics.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: diagnostics IPC tests share mocked Electron handler setup; splitting would duplicate brittle IPC wiring. */ import { readFileSync, writeFileSync } from 'node:fs' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CollectedBundle } from '../observability/bundle' diff --git a/src/main/ipc/filesystem-import.test.ts b/src/main/ipc/filesystem-import.test.ts index 01b28e1c39a..fcd004dcd06 100644 --- a/src/main/ipc/filesystem-import.test.ts +++ b/src/main/ipc/filesystem-import.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: import tests cover local copy, SSH routing, -symlink safety, and runtime-upload staging against one shared IPC fixture. */ import path from 'node:path' import { constants } from 'node:fs' import { Readable, Writable } from 'node:stream' diff --git a/src/main/ipc/filesystem-list-files.test.ts b/src/main/ipc/filesystem-list-files.test.ts index 9a04660ec80..638775a4811 100644 --- a/src/main/ipc/filesystem-list-files.test.ts +++ b/src/main/ipc/filesystem-list-files.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one Quick Open file-list suite covers both rg and git fallback process lifecycles. */ import { describe, expect, it, vi, beforeEach } from 'vitest' const { diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index b1af20fd139..0b3b07ca071 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: filesystem watcher tests share module-level -state across local, WSL, and SSH lifecycle paths; keeping them together makes -closeAllWatchers cleanup regressions visible in one suite. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { handleMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ diff --git a/src/main/ipc/pty-management.test.ts b/src/main/ipc/pty-management.test.ts index c644d885d27..443b356ba4a 100644 --- a/src/main/ipc/pty-management.test.ts +++ b/src/main/ipc/pty-management.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: covers every pty:management IPC channel -against shared mocks (electron, fs, daemon-init, DaemonPtyRouter). Splitting -across files would duplicate the vi.hoisted setup and the shared helpers, -with no meaningful ownership seam. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DaemonSessionInfo } from '../daemon/types' diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index 5ce6342c351..0b7c4e07146 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this test mirrors the complete core IPC handler registry so - duplicate-registration coverage stays tied to the one production entry point. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/ipc/remote-workspace-patch-queue.test.ts b/src/main/ipc/remote-workspace-patch-queue.test.ts index 6de465626e9..1c97ad6b724 100644 --- a/src/main/ipc/remote-workspace-patch-queue.test.ts +++ b/src/main/ipc/remote-workspace-patch-queue.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: queue/cache regression cases share one mocked IPC harness so stale revision sequencing stays visible. */ import { ipcMain } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' diff --git a/src/main/ipc/shell.test.ts b/src/main/ipc/shell.test.ts index 24ac41e2deb..2e1b842904d 100644 --- a/src/main/ipc/shell.test.ts +++ b/src/main/ipc/shell.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: shell IPC path validation, OS opener fallbacks, and launcher lifecycle tests share one mocked Electron/child_process boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { normalize, resolve } from 'node:path' import { pathToFileURL } from 'node:url' diff --git a/src/main/ipc/telemetry.test.ts b/src/main/ipc/telemetry.test.ts index 27c7d0cd15d..40679160f93 100644 --- a/src/main/ipc/telemetry.test.ts +++ b/src/main/ipc/telemetry.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: a single test file pins the IPC boundary behavior for all four telemetry handlers plus the cohort-injection invariants; splitting would fragment the threat-model coverage. */ // IPC boundary behavior for the telemetry surface. Strict type narrows must // drop obviously-malformed calls before they reach the validator (the // renderer is in the threat model). Pins the consent-mutation rate limit: diff --git a/src/main/ipc/workspace-cleanup.test.ts b/src/main/ipc/workspace-cleanup.test.ts index 6fe5320b1b1..af084713a0b 100644 --- a/src/main/ipc/workspace-cleanup.test.ts +++ b/src/main/ipc/workspace-cleanup.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: scan and IPC process-liveness tests share - hoisted Electron/git provider mocks; splitting would duplicate brittle setup. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ipcMain } from 'electron' import type { Store } from '../persistence' diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index 7ba2350c855..219235ac913 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these worktree path/name tests share a -single setup-free pure-logic module, and splitting them would make the related -edge cases harder to audit together. */ import { posix, resolve } from 'node:path' import { describe, expect, it } from 'vitest' import { diff --git a/src/main/observability/bundle.test.ts b/src/main/observability/bundle.test.ts index 539eebc70c0..d532711828a 100644 --- a/src/main/observability/bundle.test.ts +++ b/src/main/observability/bundle.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: diagnostics bundle fixtures cover collection, preview deletion, upload URL hardening, and byte caps as one contract surface. Splitting would duplicate the temp-file/server harness and make edge-case coverage harder to audit. */ // Bundle collection + upload tests. Upload helpers live outside bundle.ts, but // this suite keeps the diagnostic bundle contract in one place. diff --git a/src/main/observability/redactor.test.ts b/src/main/observability/redactor.test.ts index 4ad470bc7a2..8de2661515a 100644 --- a/src/main/observability/redactor.test.ts +++ b/src/main/observability/redactor.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: the redactor has intentionally broad fixture coverage across every secret location and key-shape rule; keeping it together makes gaps visible. */ // Fixture-based test suite for the redactor. Each provider-key shape is // exercised in three locations (attribute value, span event message, // exit-status `cause`) — that's the contract telemetry-error-tracking.md diff --git a/src/main/opencode-usage/scanner.test.ts b/src/main/opencode-usage/scanner.test.ts index 6efec7beb5d..c4bd46aae68 100644 --- a/src/main/opencode-usage/scanner.test.ts +++ b/src/main/opencode-usage/scanner.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: OpenCode scanner tests cover multiple DB schema generations and attribution boundaries together so parser regressions stay auditable. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/opencode/hook-service.test.ts b/src/main/opencode/hook-service.test.ts index 9d71a25d891..2ad00cf5baa 100644 --- a/src/main/opencode/hook-service.test.ts +++ b/src/main/opencode/hook-service.test.ts @@ -1,8 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite covers four orthogonal regimes - (plugin source, id guards, legacy per-PTY round-trip, and overlay mode for - user-set OPENCODE_CONFIG_DIR). Splitting them across files would scatter - tightly coupled fixtures (userData mock, hooks/overlay roots) and obscure - the docs/opencode-config-dir-collision.md regression matrix. */ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { existsSync, diff --git a/src/main/project-groups/nested-repo-discovery.test.ts b/src/main/project-groups/nested-repo-discovery.test.ts index 0d016cf1cbc..f38e29ea53b 100644 --- a/src/main/project-groups/nested-repo-discovery.test.ts +++ b/src/main/project-groups/nested-repo-discovery.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: nested scan behavior is intentionally covered in one -suite so traversal order, ignore rules, cancellation, and filesystem fixtures stay aligned. */ import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' diff --git a/src/main/providers/ssh-filesystem-provider.test.ts b/src/main/providers/ssh-filesystem-provider.test.ts index f008c380dde..6d4170209e7 100644 --- a/src/main/providers/ssh-filesystem-provider.test.ts +++ b/src/main/providers/ssh-filesystem-provider.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH filesystem provider coverage keeps relay fallback, -SFTP binary writes, watch fan-out, and provider lifecycle tests together so -transport parity regressions are visible in one suite. */ import { describe, expect, it, vi, beforeEach } from 'vitest' import { SshFilesystemProvider } from './ssh-filesystem-provider' import { JsonRpcErrorCode } from '../ssh/relay-protocol' diff --git a/src/main/runtime/mobile-subscribe-integration.test.ts b/src/main/runtime/mobile-subscribe-integration.test.ts index f2cf2587d57..3d36716dd5e 100644 --- a/src/main/runtime/mobile-subscribe-integration.test.ts +++ b/src/main/runtime/mobile-subscribe-integration.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: integration tests cover the full mobile subscribe lifecycle across many scenarios; splitting would scatter related assertions. */ /** * Integration tests for the server-authoritative mobile subscribe lifecycle. * Tests handleMobileSubscribe, handleMobileUnsubscribe, applyMobileDisplayMode, diff --git a/src/main/runtime/orca-runtime-browser.test.ts b/src/main/runtime/orca-runtime-browser.test.ts index b3edc6cdbfd..7c55f75327b 100644 --- a/src/main/runtime/orca-runtime-browser.test.ts +++ b/src/main/runtime/orca-runtime-browser.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: browser runtime command tests share one -mocked BrowserManager/agent-browser bridge so page wake and registration -ordering regressions stay in one boundary-focused suite. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { RuntimeBrowserCommandHost } from './orca-runtime-browser' diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index 5ec2b914ab8..a10c4ea2091 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */ import { afterEach, describe, expect, it } from 'vitest' import { OrchestrationDb } from './db' import { reconcileLifecycleMessage } from './lifecycle-reconciliation' diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 3f2c12ef279..8f43298a92c 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/rpc/methods/files.test.ts b/src/main/runtime/rpc/methods/files.test.ts index 7aea0b7c78f..7282489c706 100644 --- a/src/main/runtime/rpc/methods/files.test.ts +++ b/src/main/runtime/rpc/methods/files.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: file RPC routing coverage stays together so -the dispatcher contract for read, write, mutation, and watch methods is easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 850276906a5..bb69dfc5958 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: git RPC methods share one dispatcher fixture, and keeping the contract cases together makes method coverage easy to audit. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index 8330a1d0ba5..2135507c3e9 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: runtime GitHub RPC methods share one dispatcher suite so repo-scoped and Project-scoped contract coverage cannot drift. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/methods/gitlab.test.ts b/src/main/runtime/rpc/methods/gitlab.test.ts index cb88fbff7bb..ca429f3d492 100644 --- a/src/main/runtime/rpc/methods/gitlab.test.ts +++ b/src/main/runtime/rpc/methods/gitlab.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: one routing test exercises the full GitLab RPC surface so provider parity regressions show up in a single contract fixture. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index dfd185b33b4..adad88dded0 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: terminal subscribe buffering tests share a live dispatcher harness; splitting would duplicate stream setup and weaken lifecycle coverage. */ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from './dispatcher' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/ws-transport.test.ts b/src/main/runtime/rpc/ws-transport.test.ts index 775050fe8b3..b9f4c94ddcd 100644 --- a/src/main/runtime/rpc/ws-transport.test.ts +++ b/src/main/runtime/rpc/ws-transport.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these tests exercise one stateful transport - boundary across connection lifecycle, heartbeat, pre-auth timeout, and - shutdown behavior; splitting the setup would obscure the shared invariants. */ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/main/runtime/rpc/ws-transport.ts b/src/main/runtime/rpc/ws-transport.ts index 53a776f3983..37f6094b589 100644 --- a/src/main/runtime/rpc/ws-transport.ts +++ b/src/main/runtime/rpc/ws-transport.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: the WebSocket transport owns connection - admission, heartbeat, pre-auth timeout, and client-id cleanup together; those - invariants are easier to audit in one transport boundary. */ // Why: the WebSocket transport enables mobile clients to connect to the Orca // runtime over the local network. When TLS cert/key are provided it uses wss:// // to prevent passive sniffing; otherwise it falls back to plain ws://. Per-device diff --git a/src/main/source-control/hosted-review-creation.test.ts b/src/main/source-control/hosted-review-creation.test.ts index e50731c8588..c03447d4043 100644 --- a/src/main/source-control/hosted-review-creation.test.ts +++ b/src/main/source-control/hosted-review-creation.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: hosted review creation permutations share large mocks; splitting would hide branch-specific expectations. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/ssh/ssh-channel-multiplexer.test.ts b/src/main/ssh/ssh-channel-multiplexer.test.ts index f0576a65fb5..8c56ae145b0 100644 --- a/src/main/ssh/ssh-channel-multiplexer.test.ts +++ b/src/main/ssh/ssh-channel-multiplexer.test.ts @@ -1,5 +1,3 @@ -/* oxlint-disable max-lines -- Why: keeps the mux protocol lifecycle harness - together across request, notification, keepalive, and disposal cases. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { SshChannelMultiplexer, type MultiplexerTransport } from './ssh-channel-multiplexer' import { encodeFrame, MessageType, HEADER_LENGTH, encodeKeepAliveFrame } from './relay-protocol' diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index 5999c1c96d2..f550c833a0f 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH config parsing fixtures cover OpenSSH file parsing and ssh -G output together so import and connection resolution stay aligned. */ import { describe, expect, it, vi } from 'vitest' import { join } from 'node:path' import { parseSshConfig, sshConfigHostsToTargets, parseSshGOutput } from './ssh-config-parser' diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index ed10780ca3d..0c578cff7d2 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: SSH connection utility tests share mocked filesystem and environment setup across auth, proxy, and retry helpers. */ import { EventEmitter } from 'node:events' import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest' import { join } from 'node:path' diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 0cedf1aff05..2b63b3a2d50 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,8 +1,3 @@ -/* eslint-disable max-lines -- Why: pinning every layer that should have - caught the original "node-pty not available" bug (chained shell, package - ordering, probe shape, channel-failure surfacing, .bashrc-noise immunity, - platform-tagged logs) requires keeping these scenarios in one file so the - shared mock connection and exec-response fixture stay aligned. */ // Why: regression coverage for the install-probe contract. The original // "node-pty is not available" bug shipped because every layer that should // have caught it (chained shell, swallowing catch, dir-only probe) was diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index 31b357ca047..3de35a65fb2 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: this integration spec keeps the SSH relay, -agent-hook server, and replay/interrupt ordering fixtures together so regressions -cover the full mux-to-main path. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index aa616dd1518..0dfa2edc8d8 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: relay session tests need one shared mocked -provider/multiplexer harness to cover establish, reconnect, detach, and dispose -state transitions without duplicating brittle setup. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index 7a30d115b16..2146563373d 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this integration-style wrapper suite shares - process cleanup and fake Electron CLI fixtures across related regressions. */ import { existsSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index c5e8e2ca805..a9750bb60bc 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: history scoping touches shell detection, env -injection, fallback patching, WSL translation, cleanup, and GC with a TOCTOU age -guard — covering each path in one test file keeps assertions co-located with the -shared mock harness rather than splitting across files. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { diff --git a/src/main/window/attach-main-window-services.test.ts b/src/main/window/attach-main-window-services.test.ts index 192e6e8f7f0..718711b629d 100644 --- a/src/main/window/attach-main-window-services.test.ts +++ b/src/main/window/attach-main-window-services.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: attachMainWindowServices centralizes main-window IPC wiring; keeping its integration-style mocks together avoids brittle cross-file setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Store } from '../persistence' diff --git a/src/main/workspace-space-analysis.test.ts b/src/main/workspace-space-analysis.test.ts index d6b24b3dfde..bfee5295635 100644 --- a/src/main/workspace-space-analysis.test.ts +++ b/src/main/workspace-space-analysis.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: scan, cancellation, SSH fallback, and compaction tests share temp-repo fixtures. */ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/src/relay/dispatcher.test.ts b/src/relay/dispatcher.test.ts index 5598ae46e0a..a87e37c6078 100644 --- a/src/relay/dispatcher.test.ts +++ b/src/relay/dispatcher.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: dispatcher behavior is stateful across - primary, socket, timeout, and cancellation paths; keeping fixtures shared - makes regression tests easier to audit. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { RelayDispatcher } from './dispatcher' import { diff --git a/src/relay/fs-handler.test.ts b/src/relay/fs-handler.test.ts index b90a786f7fd..7d5354cf47a 100644 --- a/src/relay/fs-handler.test.ts +++ b/src/relay/fs-handler.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite covers relay filesystem RPCs, - Space scans, file watcher lifecycle edges, and cross-platform path behavior together. */ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { FsHandler } from './fs-handler' import { MAX_TEXT_FILE_SIZE } from './fs-handler-utils' diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index 059e5739f6b..4d19542b91c 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -1,4 +1,3 @@ -/* oxlint-disable max-lines -- Why: subprocess coverage shares one bundled relay artifact; splitting this file would rebuild the same daemon bundle across suites and make these lifecycle tests slower/flakier. */ import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' diff --git a/src/renderer/src/components/UpdateCard.test.ts b/src/renderer/src/components/UpdateCard.test.ts index 510ec81d7d1..9d4b75094d2 100644 --- a/src/renderer/src/components/UpdateCard.test.ts +++ b/src/renderer/src/components/UpdateCard.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: the update-card behavior is easiest to verify as one - lifecycle-oriented suite because the store caching and visibility rules interact directly. */ import { createStore, type StoreApi } from 'zustand/vanilla' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getDefaultUIState } from '../../../shared/constants' diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 8493536a285..9d50997d947 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: autosave behavior depends on event wiring, - dirty drafts, remote routing, quiesce, and failure cleanup in one harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createStore, type StoreApi } from 'zustand/vanilla' import { createEditorSlice } from '@/store/slices/editor' diff --git a/src/renderer/src/components/onboarding/NotificationStep.tsx b/src/renderer/src/components/onboarding/NotificationStep.tsx index 29bd0c87a90..ef43e335c7b 100644 --- a/src/renderer/src/components/onboarding/NotificationStep.tsx +++ b/src/renderer/src/components/onboarding/NotificationStep.tsx @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this onboarding step owns the full notification setup surface, including macOS guidance, sound choices, and upload controls. */ import { useCallback, useEffect, useRef, useState } from 'react' import { BellRing, FileAudio, Settings, Upload } from 'lucide-react' import { toast } from 'sonner' diff --git a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts index 770aeb2e483..58f5c064351 100644 --- a/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts +++ b/src/renderer/src/components/right-sidebar/source-control-dropdown-items.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: the dropdown priority table is easier to audit when the row-state cases live together. */ import { describe, expect, it } from 'vitest' import { resolveDropdownItems, type DropdownActionInputs } from './source-control-dropdown-items' diff --git a/src/renderer/src/components/settings/AgentsPane.test.tsx b/src/renderer/src/components/settings/AgentsPane.test.tsx index d2915f198f8..2d69afaffee 100644 --- a/src/renderer/src/components/settings/AgentsPane.test.tsx +++ b/src/renderer/src/components/settings/AgentsPane.test.tsx @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: Agents pane settings interactions share - store-backed queue fixtures that are easier to audit beside the UI helper coverage. */ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts index 07050938229..a9655a41624 100644 --- a/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts +++ b/src/renderer/src/components/settings/TerminalPane.pwsh.test.ts @@ -1,6 +1,3 @@ -/* oxlint-disable max-lines -- Why: TerminalPane tests share a large mocked - settings harness; splitting the new Windows-shell cases would duplicate - brittle React/store mocks without improving coverage. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const mockStateValues: unknown[] = [] diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx index f3bd592f3e2..380bb9194c4 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.test.tsx @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite shares a broad mocked sidebar - harness across compact/full mode, lineage, and image-note cases. */ import { renderToStaticMarkup } from 'react-dom/server' import type { ReactNode } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx index aa6f02d8378..7bfda38d231 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardAgents.tsx @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: this component keeps compact/full inline - agent rendering and lineage disclosure behavior together; splitting during - this bug fix would risk divergent parent-child row behavior. */ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' diff --git a/src/renderer/src/components/sidebar/smart-attention.test.ts b/src/renderer/src/components/sidebar/smart-attention.test.ts index c4fbd18ce73..96b54f9a5c8 100644 --- a/src/renderer/src/components/sidebar/smart-attention.test.ts +++ b/src/renderer/src/components/sidebar/smart-attention.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { describe, expect, it } from 'vitest' import { AGENT_STATUS_STALE_AFTER_MS, diff --git a/src/renderer/src/components/sidebar/smart-sort.test.ts b/src/renderer/src/components/sidebar/smart-sort.test.ts index cb61490c90a..da10bf713e6 100644 --- a/src/renderer/src/components/sidebar/smart-sort.test.ts +++ b/src/renderer/src/components/sidebar/smart-sort.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { describe, expect, it } from 'vitest' import type { Repo, TerminalTab, Worktree } from '../../../../shared/types' import { diff --git a/src/renderer/src/components/sidebar/visible-worktrees.test.ts b/src/renderer/src/components/sidebar/visible-worktrees.test.ts index 25165df25b8..f403a86815a 100644 --- a/src/renderer/src/components/sidebar/visible-worktrees.test.ts +++ b/src/renderer/src/components/sidebar/visible-worktrees.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines */ import { describe, expect, it } from 'vitest' import { computeClearFilterActions, diff --git a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts index e1b96f4b9c8..e542f90a818 100644 --- a/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts +++ b/src/renderer/src/components/status-bar/mergeSnapshotAndSessions.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: this file co-locates tightly-coupled scenario - tests for the resource-usage merge function. Splitting them weakens the - single-source view of how snapshot + daemon-session inputs combine. */ import { describe, expect, it } from 'vitest' import type { MemorySnapshot, TerminalTab, WorktreeMemory } from '../../../../shared/types' import { mergeSnapshotAndSessions, UNATTRIBUTED_REPO_ID } from './mergeSnapshotAndSessions' diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index 9e313bccd55..03e85f8f0e5 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -1,5 +1,3 @@ -/* oxlint-disable max-lines -- Why: keeping these mocked TabBar wiring cases - * together avoids duplicating the lightweight renderer harness. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const appStoreSnapshot: { diff --git a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts index 780137e1184..fc4eb329c8c 100644 --- a/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts +++ b/src/renderer/src/components/terminal-pane/agent-interrupt-inference.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this suite locks the interrupt inference state machine across several agent-specific keyboard semantics. */ import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index b72cc28a82a..95b7bb0920c 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -1,5 +1,3 @@ -/* oxlint-disable max-lines -- Why: this test keeps split layout replay fixtures together so - * stable leaf-id migration regressions are visible in one focused suite. */ import { describe, expect, it, beforeAll, vi } from 'vitest' import type { TerminalPaneLayoutNode } from '../../../../shared/types' diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index cbbbe685080..8cd870aec13 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: fork flow tests share a mocked store and launch harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' diff --git a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts index fd5c81c314d..0f0c4cec3d1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: terminal keyboard policy covers platform - * readline compatibility, pane management, and Option-as-Alt translation in - * one pure function; the cases need to stay adjacent. */ import { describe, expect, it } from 'vitest' import { resolveTerminalShortcutAction, diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 50d3b3bab30..c9725235323 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: dispatch guards are interdependent, so these notification liveness and unread regressions stay together with one store mock. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { dispatchTerminalNotification } from './use-notification-dispatch' import type { AgentStatusEntry } from '../../../../shared/agent-status-types' diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index 9717f4ff602..d39e2aab294 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: notification edge cases share one module-scoped coordinator, so keeping setup and regression cases together prevents brittle cross-file mock resets. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' diff --git a/src/renderer/src/hooks/ipc-tab-switch.test.ts b/src/renderer/src/hooks/ipc-tab-switch.test.ts index c347a2808e7..1a1112b415a 100644 --- a/src/renderer/src/hooks/ipc-tab-switch.test.ts +++ b/src/renderer/src/hooks/ipc-tab-switch.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: tab-switch shortcut behavior is shared by - * renderer and IPC entry points; keeping terminal, sequential, across-type, - * and MRU cases together makes regressions obvious. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { getStateMock, getActiveTabNavOrderMock } = vi.hoisted(() => ({ diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index 5c5d5e96f69..3a9215278ee 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- - * Why: agent title detection is intentionally table-driven in one place so the - * supported title variants stay readable and regressions are easy to compare. - */ import { describe, expect, it, test, vi } from 'vitest' import { detectAgentStatusFromTitle, diff --git a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts index 166b7df7e4e..d50711e6663 100644 --- a/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts +++ b/src/renderer/src/lib/floating-workspace-terminal-actions.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Focus, shortcut, creation, and switching cases share - * the same floating-workspace DOM/store fixtures. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { Tab, TerminalTab } from '../../../shared/types' diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index f42f886fde5..954a1314ef9 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: local/runtime launch tests share a mock harness. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { BACKGROUND_MOUNT_TERMINAL_WORKTREE_EVENT } from '@/constants/terminal' import { createCompatibleRuntimeStatusResponseIfNeeded } from '@/runtime/runtime-compatibility-test-fixture' diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 909ca60c229..72b020df4ec 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: runtime git routing tests share compatibility-cache and IPC stubs; splitting would hide cross-environment contract drift. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { bulkDiscardRuntimeGitPaths, diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 531e13660bd..57f182f8341 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: runtime Linear routing cases stay together - so local preload fallback and SSH runtime transport parity are reviewed as one boundary. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { linearCreateIssue, diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts index fa86f2766ca..6095f4e1840 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this file keeps terminal session publication - * fixtures together so split-pane and split-tab parity assertions do not drift. */ import { describe, expect, it } from 'vitest' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph' import type { AppState } from '../store/types' diff --git a/src/renderer/src/store/slices/diffComments.test.ts b/src/renderer/src/store/slices/diffComments.test.ts index 3726098e85d..e4862f12913 100644 --- a/src/renderer/src/store/slices/diffComments.test.ts +++ b/src/renderer/src/store/slices/diffComments.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: this test file owns the diff-comments -slice's persistence, runtime routing, rollback, and compatibility behavior. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' diff --git a/src/renderer/src/store/slices/hosted-review.test.ts b/src/renderer/src/store/slices/hosted-review.test.ts index 7bd6c4f3dda..b5a91fe6a9e 100644 --- a/src/renderer/src/store/slices/hosted-review.test.ts +++ b/src/renderer/src/store/slices/hosted-review.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: hosted-review tests cover runtime routing, -hinted cache revalidation, provider discovery, and PR cache reconciliation. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' diff --git a/src/renderer/src/store/slices/new-markdown.test.ts b/src/renderer/src/store/slices/new-markdown.test.ts index 3a8d22eb7e1..b0f1ebb9997 100644 --- a/src/renderer/src/store/slices/new-markdown.test.ts +++ b/src/renderer/src/store/slices/new-markdown.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines */ - import { createStore, type StoreApi } from 'zustand/vanilla' import { describe, expect, it } from 'vitest' import { createEditorSlice } from './editor' diff --git a/src/renderer/src/store/slices/terminals-hydration.test.ts b/src/renderer/src/store/slices/terminals-hydration.test.ts index e4b37423137..aab9c280838 100644 --- a/src/renderer/src/store/slices/terminals-hydration.test.ts +++ b/src/renderer/src/store/slices/terminals-hydration.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: hydration regressions share store setup and session invariants that are easier to audit together. */ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) diff --git a/src/renderer/src/store/slices/workspace-cleanup.test.ts b/src/renderer/src/store/slices/workspace-cleanup.test.ts index 116ec974d10..3a5199e2adc 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.test.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: cleanup store tests share a Zustand store - harness and mocked window API; splitting would obscure the state transitions. */ import { create } from 'zustand' import { describe, expect, it, vi } from 'vitest' import type { AppState } from '../types' diff --git a/src/renderer/src/web/web-runtime-client.test.ts b/src/renderer/src/web/web-runtime-client.test.ts index 2e6123118c5..8f7e500c3d3 100644 --- a/src/renderer/src/web/web-runtime-client.test.ts +++ b/src/renderer/src/web/web-runtime-client.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable max-lines -- Why: these tests share one mocked browser - WebSocket/E2EE transport fixture, and splitting them would obscure the - subscription lifecycle regressions they cover. */ import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import WebSocket, { WebSocketServer } from 'ws' import { WebRuntimeClient } from './web-runtime-client' diff --git a/src/shared/telemetry-events.test.ts b/src/shared/telemetry-events.test.ts index 7b65e72b66c..8abdddba69b 100644 --- a/src/shared/telemetry-events.test.ts +++ b/src/shared/telemetry-events.test.ts @@ -1,5 +1,3 @@ -/* eslint-disable max-lines -- Why: telemetry schema tests keep related event - invariants together so cross-event payload rules stay easy to audit. */ // Schema round-trip coverage for the event map. Fail-closed invariants that // must hold: agent_error is enum-only (error_message / error_stack rejected // by `.strict()`), unknown enum values fail, and any well-formed payload diff --git a/src/shared/window-shortcut-policy.test.ts b/src/shared/window-shortcut-policy.test.ts index efae3595786..25e66e80366 100644 --- a/src/shared/window-shortcut-policy.test.ts +++ b/src/shared/window-shortcut-policy.test.ts @@ -1,7 +1,3 @@ -/* eslint-disable max-lines -- Why: this file covers every branch of the -shortcut policy (letter chords, zoom variants, alt/shift gating, history -navigation, new-workspace tab routing). Splitting across files would -fragment the test of a single pure function. */ import { describe, expect, it } from 'vitest' import { isRecentTabSwitcherCommitRelease, diff --git a/tests/e2e/activity-agent-pane-isolation.spec.ts b/tests/e2e/activity-agent-pane-isolation.spec.ts index ee1c87c2d6b..fe6600f3f76 100644 --- a/tests/e2e/activity-agent-pane-isolation.spec.ts +++ b/tests/e2e/activity-agent-pane-isolation.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Activity E2E keeps the setup helpers beside the split-pane, split-group, and workspace-card routing assertions they support. */ import type { Page } from '@stablyai/playwright-test' import { test, expect } from './helpers/orca-app' import { diff --git a/tests/e2e/droid-notification.spec.ts b/tests/e2e/droid-notification.spec.ts index 16dc2a69ffe..5872520422e 100644 --- a/tests/e2e/droid-notification.spec.ts +++ b/tests/e2e/droid-notification.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this e2e shares one Electron notification spy and hook endpoint setup across related notification regressions. */ import { test, expect } from './helpers/orca-app' import type { ElectronApplication, Page } from '@stablyai/playwright-test' import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log' diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index 1fd2e1cc846..5a93391f277 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: onboarding E2E coverage shares one first-launch wizard fixture and step helpers; splitting this file would make the linear flow harder to audit. */ /** * E2E tests for the first-launch Onboarding flow. * diff --git a/tests/e2e/setup-guide-sidebar.spec.ts b/tests/e2e/setup-guide-sidebar.spec.ts index 7e1ff7087e8..b01454e3006 100644 --- a/tests/e2e/setup-guide-sidebar.spec.ts +++ b/tests/e2e/setup-guide-sidebar.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Why: this regression spec keeps the deterministic IPC fakes, setup-state seeding, and frame-level flash monitor together so the flicker contract is auditable in one place. */ import type { ElectronApplication, Page } from '@stablyai/playwright-test' import type { SkillDiscoveryResult } from '../../src/shared/skills' import { test, expect } from './helpers/orca-app' diff --git a/tests/e2e/ssh-localhost.spec.ts b/tests/e2e/ssh-localhost.spec.ts index d51ad2f7a6a..c406186d97c 100644 --- a/tests/e2e/ssh-localhost.spec.ts +++ b/tests/e2e/ssh-localhost.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Localhost SSH E2E covers setup, remote PTY, hook relay, and interrupt inference in one expensive app boot. */ import os from 'node:os' import type { Page } from '@stablyai/playwright-test' diff --git a/tests/e2e/terminal-output-scheduler.spec.ts b/tests/e2e/terminal-output-scheduler.spec.ts index e4fa1cc4362..014ffe9cd4a 100644 --- a/tests/e2e/terminal-output-scheduler.spec.ts +++ b/tests/e2e/terminal-output-scheduler.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Scheduler E2E coverage shares one booted Electron app and debug API. */ /** * E2E repro for terminal output bursts from many background tabs. * diff --git a/tests/e2e/terminal-restart-persistence.spec.ts b/tests/e2e/terminal-restart-persistence.spec.ts index cdb6a5423a5..19cccc43c36 100644 --- a/tests/e2e/terminal-restart-persistence.spec.ts +++ b/tests/e2e/terminal-restart-persistence.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Restart persistence E2E covers separate lifecycle regressions that need real relaunches. */ /** * E2E tests for terminal scrollback persistence across clean app restarts. * diff --git a/tests/e2e/terminal-shortcuts.spec.ts b/tests/e2e/terminal-shortcuts.spec.ts index fbea6ebf1fb..d0243f5cc03 100644 --- a/tests/e2e/terminal-shortcuts.spec.ts +++ b/tests/e2e/terminal-shortcuts.spec.ts @@ -1,4 +1,3 @@ -/* eslint-disable max-lines -- Terminal shortcut E2E keeps platform keyboard paths beside their shared PTY assertions. */ /** * E2E test for terminal keyboard shortcuts. * From b41cab1a9ef25e2091ccea58bc6ceccaa54fdc88 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:13:10 -0700 Subject: [PATCH 011/170] fix(renderer): raise renderer V8 heap toward the 4GB pointer-compression cage (#7531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(renderer): raise renderer V8 heap toward the 4GB pointer-compression cage Renderer OOM ('renderer crashed'/'oom', exit 5 / 0xE0000008 / SIGTRAP) is the dominant crash in the crash channel: the renderer JS heap reaches Chromium's default V8 old-space ceiling (~RAM/4) and V8 aborts. Two adversarial leak hunts (13 agents across every renderer subsystem) found no unbounded GB-scale leak, so this is a capacity ceiling, not a leak. Chromium sizes the renderer heap at ~RAM/4, leaving 8-15GB machines well under V8's ~4GB pointer-compression cage (an 8GB machine caps near 2.2GB). Reclaim that unused headroom via --max-old-space-size in a focused startup module, gated on physical RAM (>=8GB, ~40% of RAM, floor 3072MB, capped at the real 4096MB cage). 16GB+ machines are already at the cage so this is a no-op for them; low-RAM machines keep the default to avoid trading a clean OOM for OS memory-pressure kills. Overridable with ORCA_RENDERER_HEAP_MB (number to force, default/off/0 to opt out). Verified on Electron 42.3.3: the main-process js-flags switch propagates to the renderer V8 and is honored up to the 4096MB cage (5000/12288 -> 4096). Co-authored-by: Orca * fix(renderer): address CodeRabbit — floor-to-0 override + Linux 8GB gate - parseRendererHeapOverrideMb: a fractional override in (0,1) floored to 0 and emitted an invalid --max-old-space-size=0; treat floored-to-0 as an opt-out. - Lower the RAM gate from 8 to 7.5 GiB: os.totalmem() on Linux reports MemTotal (excludes kernel/firmware-reserved RAM), so a real 8 GB box reports ~7.7 GiB and was wrongly excluded from the headroom — the exact crashing population. 7.5 still cleanly excludes 6 GB machines (report ~5.7 GiB). Co-authored-by: Orca --------- Co-authored-by: Orca --- src/main/index.ts | 2 + .../startup/renderer-heap-headroom.test.ts | 121 ++++++++++++++++++ src/main/startup/renderer-heap-headroom.ts | 102 +++++++++++++++ 3 files changed, 225 insertions(+) create mode 100644 src/main/startup/renderer-heap-headroom.test.ts create mode 100644 src/main/startup/renderer-heap-headroom.ts diff --git a/src/main/index.ts b/src/main/index.ts index 4949dd36db7..7d599f8dfdc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -62,6 +62,7 @@ import { patchPackagedProcessPath, shouldInstallManagedHooks } from './startup/configure-process' +import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { readActiveGpuFallbackMarker, @@ -585,6 +586,7 @@ if (hasSingleInstanceLock) { platform: process.platform }) configureElectronNetworkCompatibility() + enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() if (!gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() diff --git a/src/main/startup/renderer-heap-headroom.test.ts b/src/main/startup/renderer-heap-headroom.test.ts new file mode 100644 index 00000000000..88b1baa0f2e --- /dev/null +++ b/src/main/startup/renderer-heap-headroom.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeRendererHeapCeilingMb } from './renderer-heap-headroom' + +vi.mock('electron', () => ({ + app: { + commandLine: { + appendSwitch: vi.fn(), + getSwitchValue: vi.fn(() => '') + } + } +})) + +afterEach(() => { + vi.restoreAllMocks() +}) + +const GIB = 1024 * 1024 * 1024 + +describe('computeRendererHeapCeilingMb', () => { + it('leaves Chromium default (null) below the ~8 GB gate to avoid OS memory pressure', () => { + expect(computeRendererHeapCeilingMb(4 * GIB)).toBeNull() + expect(computeRendererHeapCeilingMb(6 * GIB)).toBeNull() // 6 GB reports ~5.7 GiB + expect(computeRendererHeapCeilingMb(7 * GIB)).toBeNull() // below the 7.5 GiB gate + }) + + it('includes 8 GB machines that report below 8 GiB (Linux MemTotal excludes reserved RAM)', () => { + // A real 8 GB Linux box reports ~7.7 GiB; it must still get the headroom. + expect(computeRendererHeapCeilingMb(7.7 * GIB)).toBe(3072) + expect(computeRendererHeapCeilingMb(7.5 * GIB)).toBe(3072) + }) + + it('raises the ceiling toward the 4 GB pointer-compression cage, floored and capped', () => { + expect(computeRendererHeapCeilingMb(8 * GIB)).toBe(3072) // floor: 8 GB default ~2.2 GB -> 3072 + expect(computeRendererHeapCeilingMb(12 * GIB)).toBe(4096) // 0.4*12 -> 4096 (cage) + expect(computeRendererHeapCeilingMb(16 * GIB)).toBe(4096) // cage cap + expect(computeRendererHeapCeilingMb(128 * GIB)).toBe(4096) // cage cap, never higher + }) + + it('honors a positive ORCA_RENDERER_HEAP_MB override regardless of RAM', () => { + expect(computeRendererHeapCeilingMb(4 * GIB, '5000')).toBe(5000) + expect(computeRendererHeapCeilingMb(128 * GIB, '4096')).toBe(4096) + }) + + it('opts out (null) for default/off/none/0/negative overrides', () => { + for (const value of ['default', 'off', 'none', '0', '-1']) { + expect(computeRendererHeapCeilingMb(16 * GIB, value)).toBeNull() + } + }) + + it('opts out (null) for a fractional override that would floor to 0 (never emits max-old-space-size=0)', () => { + for (const value of ['0.5', '0.9', '0.0001']) { + expect(computeRendererHeapCeilingMb(16 * GIB, value)).toBeNull() + } + }) + + it('falls through to RAM tiers for blank/invalid overrides', () => { + expect(computeRendererHeapCeilingMb(16 * GIB, '')).toBe(4096) + expect(computeRendererHeapCeilingMb(16 * GIB, 'abc')).toBe(4096) + }) + + it('returns null for a non-finite / non-positive RAM reading', () => { + expect(computeRendererHeapCeilingMb(Number.NaN)).toBeNull() + expect(computeRendererHeapCeilingMb(0)).toBeNull() + }) +}) + +describe('enableRendererHeapHeadroom', () => { + it('appends --max-old-space-size as a js-flags switch on a RAM-capable machine', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'js-flags', + '--max-old-space-size=4096' + ) + }) + + it('does not set a switch on low-RAM machines', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('') + + enableRendererHeapHeadroom({ totalMemoryBytes: 4 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('js-flags', expect.anything()) + }) + + it('preserves an explicit prior --max-old-space-size instead of stacking a second value', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('--max-old-space-size=2048') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('js-flags', expect.anything()) + }) + + it('merges with an unrelated existing js-flags value', async () => { + const { app } = await import('electron') + const { enableRendererHeapHeadroom } = await import('./renderer-heap-headroom') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + vi.mocked(app.commandLine.getSwitchValue).mockReturnValue('--no-opt') + + enableRendererHeapHeadroom({ totalMemoryBytes: 16 * GIB, env: {} }) + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'js-flags', + '--no-opt --max-old-space-size=4096' + ) + }) +}) diff --git a/src/main/startup/renderer-heap-headroom.ts b/src/main/startup/renderer-heap-headroom.ts new file mode 100644 index 00000000000..78ea8907c08 --- /dev/null +++ b/src/main/startup/renderer-heap-headroom.ts @@ -0,0 +1,102 @@ +import { app } from 'electron' +import { totalmem } from 'node:os' + +const RENDERER_HEAP_ENV_VAR = 'ORCA_RENDERER_HEAP_MB' +const BYTES_PER_GIB = 1024 * 1024 * 1024 +// Why: Chromium sizes the renderer's V8 old-space heap from a physical-memory +// heuristic (~RAM/4), so an 8 GB machine caps the renderer near ~2.2 GB even +// though V8's pointer-compression cage allows up to ~4 GB. Heavy Orca sessions +// (many agent terminals × scrollback, PR/git caches, React tree) legitimately +// reach that low default and V8 aborts with an OOM — the dominant renderer +// crash in the crash channel. Reclaim the unused headroom up to the 4 GB cage +// on machines that have the RAM. Requests above 4096 are silently capped by the +// pointer-compression cage, so 4096 is the real ceiling — we cannot go higher. +// Machines below ~8 GB keep Chromium's default: raising their ceiling would +// trade a clean OOM for OS memory-pressure kills / swap thrash. +// Why 7.5 not 8: os.totalmem() on Linux reports MemTotal, which excludes +// kernel/firmware-reserved RAM, so a real 8 GB machine reports ~7.7 GiB. Gating +// at exactly 8 would wrongly exclude 8 GB Linux boxes (a crashing population) +// while still cleanly excluding 6 GB machines (which report ~5.7 GiB). +const RENDERER_HEAP_MIN_TOTAL_GIB = 7.5 +const RENDERER_HEAP_RAM_FRACTION = 0.4 +const RENDERER_HEAP_FLOOR_MB = 3072 +// V8 pointer-compression cage hard limit; --max-old-space-size above this is ignored. +const RENDERER_HEAP_CAP_MB = 4096 + +type HeapOverride = number | 'disable' | undefined + +function parseRendererHeapOverrideMb(value: string | undefined): HeapOverride { + if (value === undefined) { + return undefined + } + const normalized = value.trim().toLowerCase() + if (normalized === '') { + return undefined + } + // Why: give operators an explicit opt-out (and E2E a way to pin the default) + // without editing the RAM tiers. + if (normalized === 'default' || normalized === 'off' || normalized === 'none') { + return 'disable' + } + const parsed = Number(normalized) + // Why: ignore an unparseable value (typo) and fall through to the RAM tiers, + // but treat an explicit non-positive number as an opt-out. + if (!Number.isFinite(parsed)) { + return undefined + } + if (parsed <= 0) { + return 'disable' + } + // Why: a fractional value in (0,1) floors to 0, which would emit an invalid + // --max-old-space-size=0. Treat a floored-to-0 override as an opt-out too. + const flooredMb = Math.floor(parsed) + return flooredMb <= 0 ? 'disable' : flooredMb +} + +/** + * Renderer V8 old-space ceiling (MB) to request via --max-old-space-size, or + * null to keep Chromium's physical-memory default. Pure so the RAM tiers and + * the env override are unit-testable without spawning Electron. + */ +export function computeRendererHeapCeilingMb( + totalMemoryBytes: number, + envOverride?: string +): number | null { + const override = parseRendererHeapOverrideMb(envOverride) + if (override === 'disable') { + return null + } + if (typeof override === 'number') { + return override + } + if (!Number.isFinite(totalMemoryBytes) || totalMemoryBytes <= 0) { + return null + } + const totalGib = totalMemoryBytes / BYTES_PER_GIB + if (totalGib < RENDERER_HEAP_MIN_TOTAL_GIB) { + return null + } + const targetMb = Math.floor(totalGib * RENDERER_HEAP_RAM_FRACTION) * 1024 + return Math.min(RENDERER_HEAP_CAP_MB, Math.max(RENDERER_HEAP_FLOOR_MB, targetMb)) +} + +export function enableRendererHeapHeadroom( + options: { totalMemoryBytes?: number; env?: NodeJS.ProcessEnv } = {} +): void { + const totalMemoryBytes = options.totalMemoryBytes ?? totalmem() + const envOverride = (options.env ?? process.env)[RENDERER_HEAP_ENV_VAR] + const ceilingMb = computeRendererHeapCeilingMb(totalMemoryBytes, envOverride) + if (ceilingMb === null) { + return + } + const existing = app.commandLine.getSwitchValue('js-flags') + // Why: respect an explicit --max-old-space-size someone already set (e.g. via + // ELECTRON_EXTRA_LAUNCH_ARGS) instead of stacking a second, ignored value. + if (existing.includes('--max-old-space-size')) { + return + } + const flag = `--max-old-space-size=${ceilingMb}` + // Why: js-flags is process-wide and must be set before app 'ready' so it + // reaches renderer/utility V8 isolates when Chromium spawns them. + app.commandLine.appendSwitch('js-flags', existing ? `${existing} ${flag}` : flag) +} From 9d9ebe69af9d8e43ba55e31873e2839444111e01 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:32:45 +0000 Subject: [PATCH 012/170] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index abc2d902e9a..3f124029e89 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 3.6m + + downloads: 3.7m @@ -15,7 +15,7 @@ downloads downloads - 3.6m - 3.6m + 3.7m + 3.7m From cd3d0e99dbb8a83367fb43e4ee0cbef436f11de3 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Mon, 6 Jul 2026 10:03:43 -0700 Subject: [PATCH 013/170] fix: add view file action to file context menu * fix: add view file context menu action * Address PR review feedback (#4620) - Localize the View File context menu label - Update file explorer virtual row test fixture * test: cover view file context menu visibility --------- Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../right-sidebar/FileExplorer.test.tsx | 65 ++++++++++++++++++- .../components/right-sidebar/FileExplorer.tsx | 1 + .../right-sidebar/FileExplorerRow.tsx | 12 ++++ .../right-sidebar/FileExplorerVirtualRows.tsx | 3 + ...FileExplorerVirtualRowsAddProject.test.tsx | 1 + .../file-explorer-drag-scroll-marker.test.tsx | 1 + src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- 11 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 0b0dee7079a..f8067c7dfea 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -19,7 +19,8 @@ import { shouldShowFindInFolderAction, shouldShowCopyFileAction, shouldShowOpenInTerminalAction, - shouldShowRemoteDownloadAction + shouldShowRemoteDownloadAction, + shouldShowViewFileAction } from './FileExplorerRow' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' import type { TreeNode } from './file-explorer-types' @@ -581,6 +582,11 @@ describe('FileExplorerRow collapse folder action', () => { expect(shouldShowOpenInTerminalAction(fileNode)).toBe(false) }) + it('only shows view file for files', () => { + expect(shouldShowViewFileAction(fileNode)).toBe(true) + expect(shouldShowViewFileAction(directoryNode)).toBe(false) + }) + it('shows remote download only for desktop SSH or Remote Host file-like rows', () => { const runtimeContext = { settings: { activeRuntimeEnvironmentId: 'runtime-1' }, @@ -775,6 +781,7 @@ describe('FileExplorerRow collapse folder action', () => { deleteShortcutLabel: 'Del', onClick: vi.fn(), onDoubleClick: vi.fn(), + onViewFile: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), onStartNew: vi.fn(), @@ -827,6 +834,7 @@ describe('FileExplorerRow collapse folder action', () => { deleteShortcutLabel: 'Del', onClick: vi.fn(), onDoubleClick: vi.fn(), + onViewFile: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), onStartNew: vi.fn(), @@ -879,6 +887,7 @@ describe('FileExplorerRow collapse folder action', () => { connectionId: 'ssh-1', onClick: vi.fn(), onDoubleClick: vi.fn(), + onViewFile: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), onStartNew: vi.fn(), @@ -930,6 +939,7 @@ describe('FileExplorerRow collapse folder action', () => { deleteShortcutLabel: 'Del', onClick: vi.fn(), onDoubleClick: vi.fn(), + onViewFile: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), onStartNew: vi.fn(), @@ -957,4 +967,57 @@ describe('FileExplorerRow collapse folder action', () => { expect(onOpenInTerminal).toHaveBeenCalledWith(directoryNode) }) + + it('passes the row node to the view file handler', () => { + const onViewFile = vi.fn() + const element = FileExplorerVirtualRows({ + virtualizer: { + getTotalSize: () => 26, + getVirtualItems: () => [{ index: 0, key: 'src', start: 0 }], + measureElement: vi.fn() + } as never, + inlineInputIndex: -1, + rowProjection: createFileExplorerRowProjection([fileNode]), + inlineInput: null, + handleInlineSubmit: vi.fn(), + dismissInlineInput: vi.fn(), + folderStatusByRelativePath: new Map(), + statusByRelativePath: new Map(), + ignoredByRelativePath: new Set(), + expanded: new Set(), + dirCache: {}, + selectedPaths: new Set(), + activeFileId: null, + flashingPath: null, + deleteShortcutLabel: 'Del', + onClick: vi.fn(), + onDoubleClick: vi.fn(), + onViewFile, + onContextMenuSelect: vi.fn(), + onCopyPaths: vi.fn(), + onStartNew: vi.fn(), + onStartRename: vi.fn(), + onDuplicate: vi.fn(), + onAddFolderAsProject: vi.fn(), + canAddFolderAsProject: () => false, + onOpenInTerminal: vi.fn(), + onRequestDelete: vi.fn(), + onCollapseFolderSubtree: vi.fn(), + onFindInFolder: vi.fn(), + onMoveDrop: vi.fn(), + onDragTargetChange: vi.fn(), + onDragSourceChange: vi.fn(), + onDragExpandDir: vi.fn(), + onNativeDragTargetChange: vi.fn(), + onNativeDragExpandDir: vi.fn(), + dropTargetDir: null, + dragSourcePath: null, + nativeDropTargetDir: null + }) + + const row = findFileExplorerRow(element) + ;(row.props.onViewFile as () => void)() + + expect(onViewFile).toHaveBeenCalledWith(fileNode) + }) }) diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.tsx index 83defe0e715..fb91c21963b 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.tsx @@ -746,6 +746,7 @@ function FileExplorerFiles(): React.JSX.Element { runtimeDownloadContext={runtimeDownloadContext} onClick={handleRowClick} onDoubleClick={handleDoubleClick} + onViewFile={handleClick} onContextMenuSelect={preserveSelectionForContextMenu} onCopyPaths={copyPathsForNode} onStartNew={startNew} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 765c1071b54..6c4ace896e9 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -279,6 +279,7 @@ type FileExplorerRowProps = { selectionSize: number onClick: (event: React.MouseEvent) => void onDoubleClick: () => void + onViewFile: () => void onContextMenuSelect: () => void onCopyPaths: (pathKind: 'absolute' | 'relative') => void onStartNew: (type: 'file' | 'folder', dir: string, depth: number) => void @@ -310,6 +311,10 @@ export function shouldShowOpenInTerminalAction(node: TreeNode): boolean { return node.isDirectory } +export function shouldShowViewFileAction(node: TreeNode): boolean { + return !node.isDirectory +} + export function shouldShowRemoteDownloadAction( node: TreeNode, connectionId?: string | null, @@ -421,6 +426,7 @@ export function FileExplorerRow({ selectionSize, onClick, onDoubleClick, + onViewFile, onContextMenuSelect, onCopyPaths, onStartNew, @@ -712,6 +718,12 @@ export function FileExplorerRow({ )} )} + {shouldShowViewFileAction(node) && ( + + + {translate('auto.components.right.sidebar.FileExplorerRow.1d8e182c32', 'View File')} + + )} {!node.isDirectory && activeWorktreeId && ( diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx index fe4e71dd608..5c64a5a0462 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRows.tsx @@ -30,6 +30,7 @@ type FileExplorerVirtualRowsProps = { runtimeDownloadContext?: RuntimeFileOperationArgs | null onClick: (node: TreeNode, event: React.MouseEvent) => void onDoubleClick: (node: TreeNode) => void + onViewFile: (node: TreeNode) => void onContextMenuSelect: (node: TreeNode) => void onCopyPaths: (node: TreeNode, pathKind: 'absolute' | 'relative') => void onStartNew: (type: 'file' | 'folder', parentPath: string, depth: number) => void @@ -74,6 +75,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re runtimeDownloadContext, onClick, onDoubleClick, + onViewFile, onContextMenuSelect, onCopyPaths, onStartNew, @@ -180,6 +182,7 @@ export function FileExplorerVirtualRows(props: FileExplorerVirtualRowsProps): Re selectionSize={selectedPaths.has(n.path) ? visibleSelectionCount : 1} onClick={(event) => onClick(n, event)} onDoubleClick={() => onDoubleClick(n)} + onViewFile={() => onViewFile(n)} onContextMenuSelect={() => onContextMenuSelect(n)} onCopyPaths={(pathKind) => onCopyPaths(n, pathKind)} onStartNew={onStartNew} diff --git a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx index 16236cbd71f..d1388383c3f 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerVirtualRowsAddProject.test.tsx @@ -70,6 +70,7 @@ describe('FileExplorerVirtualRows add-as-project action', () => { deleteShortcutLabel: 'Del', onClick: vi.fn(), onDoubleClick: vi.fn(), + onViewFile: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), onStartNew: vi.fn(), diff --git a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx index a9d1d9a46f5..3e490c7a9a5 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx +++ b/src/renderer/src/components/right-sidebar/file-explorer-drag-scroll-marker.test.tsx @@ -73,6 +73,7 @@ function virtualRowsElement(nodes: TreeNode[]): React.JSX.Element { onDoubleClick: vi.fn(), onContextMenuSelect: vi.fn(), onCopyPaths: vi.fn(), + onViewFile: vi.fn(), onStartNew: vi.fn(), onStartRename: vi.fn(), onDuplicate: vi.fn(), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cf70af13657..2d4efa1e146 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8893,7 +8893,8 @@ "b234ab25b4": "Could not copy the file to the clipboard", "f9d7ca753d": "Copy Paths", "3161c4e425": "folder", - "e887fa4b2e": "Open in Terminal" + "e887fa4b2e": "Open in Terminal", + "1d8e182c32": "View File" }, "FileExplorerToolbar": { "d238264654": "Show Git Ignored Files", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5d9b07de4cd..66a2b10f23b 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8893,7 +8893,8 @@ "b234ab25b4": "No se pudo copiar el archivo en el portapapeles", "f9d7ca753d": "Copiar rutas", "3161c4e425": "carpeta", - "e887fa4b2e": "Abrir en terminal" + "e887fa4b2e": "Abrir en terminal", + "1d8e182c32": "Ver archivo" }, "FileExplorerToolbar": { "d238264654": "Mostrar archivos ignorados por Git", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cafa057ad53..2b76dba34a1 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8893,7 +8893,8 @@ "b234ab25b4": "ファイルをクリップボードにコピーできませんでした", "f9d7ca753d": "パスのコピー", "3161c4e425": "フォルダ", - "e887fa4b2e": "ターミナルで開く" + "e887fa4b2e": "ターミナルで開く", + "1d8e182c32": "ファイルを表示" }, "FileExplorerToolbar": { "d238264654": "Git で無視されたファイルを表示", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index f0e5e2328fa..646b74a42f7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8893,7 +8893,8 @@ "b234ab25b4": "파일을 클립보드에 복사하지 못했습니다", "f9d7ca753d": "경로 복사", "3161c4e425": "폴더", - "e887fa4b2e": "터미널에서 열기" + "e887fa4b2e": "터미널에서 열기", + "1d8e182c32": "파일 보기" }, "FileExplorerToolbar": { "d238264654": "Git이 무시한 파일 표시", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d00704947fa..66de9505f78 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8893,7 +8893,8 @@ "b234ab25b4": "无法将文件复制到剪贴板", "f9d7ca753d": "复制路径", "3161c4e425": "文件夹", - "e887fa4b2e": "在终端中打开" + "e887fa4b2e": "在终端中打开", + "1d8e182c32": "查看文件" }, "FileExplorerToolbar": { "d238264654": "显示 Git 忽略的文件", From 4f284652c8adda923b40aa4a8e5f8c2b15a58a32 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Mon, 6 Jul 2026 13:05:03 -0400 Subject: [PATCH 014/170] fix(orchestration): stop coordinator message auto-submit (#7391) --- src/main/runtime/orca-runtime.test.ts | 57 +++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 6 +++ 2 files changed, 63 insertions(+) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 48892c2d2a2..d2655b3d372 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -856,6 +856,8 @@ function antigravityPromptBeforeModelReadyScreen(model = 'Gemini 3.5 Flash (High class InMemoryOrchestrationMessages { private sequence = 0 + private activeCoordinatorRun: { coordinator_handle: string } | null = null + private messages: MessageRow[] = [] insertMessage(msg: { @@ -903,6 +905,14 @@ class InMemoryOrchestrationMessages { return this.getUnreadMessages(toHandle, types).filter((message) => !message.delivered_at) } + setActiveCoordinatorRun(run: { coordinator_handle: string } | null): void { + this.activeCoordinatorRun = run + } + + getActiveCoordinatorRun(): { coordinator_handle: string } | null { + return this.activeCoordinatorRun + } + markAsDelivered(ids: string[]): void { const deliveredIds = new Set(ids) for (const message of this.messages) { @@ -10880,6 +10890,7 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.setActiveCoordinatorRun({ coordinator_handle: 'term_other' }) db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' }) runtime.deliverPendingMessagesForHandle(terminal.handle) @@ -10905,6 +10916,52 @@ describe('OrcaRuntimeService', () => { } }) + it('injects pending orchestration messages into the active coordinator without auto-submitting', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const db = new InMemoryOrchestrationMessages() + const write = vi.fn().mockReturnValue(true) + setInMemoryOrchestrationMessages(runtime, db) + runtime.setPtyController({ + write, + kill: vi.fn(), + getForegroundProcess: async () => null + }) + syncSinglePty(runtime) + + const [terminal] = (await runtime.listTerminals()).terminals + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + db.setActiveCoordinatorRun({ coordinator_handle: terminal.handle }) + db.insertMessage({ + from: 'term_sender', + to: terminal.handle, + subject: 'hello coordinator' + }) + + runtime.deliverPendingMessagesForHandle(terminal.handle) + + expect(write).toHaveBeenCalledWith( + 'pty-1', + expect.stringContaining('Subject: hello coordinator') + ) + await vi.advanceTimersByTimeAsync(500) + const submitWrites = write.mock.calls.filter( + ([ptyId, text]) => ptyId === 'pty-1' && text === '\r' + ) + expect(submitWrites).toHaveLength(0) + + const unread = db.getUnreadMessages(terminal.handle) + expect(unread).toHaveLength(1) + expect(unread[0].read).toBe(0) + expect(unread[0].delivered_at).not.toBeNull() + db.close() + } finally { + vi.useRealTimers() + } + }) + it('injects pending orchestration messages into Cursor Agent without auto-submitting', async () => { vi.useFakeTimers() try { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2e87e28e681..e6a5bcbfa95 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19936,6 +19936,12 @@ export class OrcaRuntimeService { return } + // The active coordinator prompt is user-owned input, so push-on-idle must not synthesize Enter. + if (this._orchestrationDb.getActiveCoordinatorRun()?.coordinator_handle === handle) { + this._orchestrationDb.markAsDelivered(unread.map((m) => m.id)) + return + } + const tabTitle = this.tabs.get(leaf.tabId)?.title if (isCursorAgentOrchestrationTarget(leaf, tabTitle)) { // Why: Cursor Agent treats injected PTY text as editable prompt input. From fbaf3f7d8142b31c3715742105f22ac3ea6ff8e8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:11:27 -0700 Subject: [PATCH 015/170] Stop terminal flicker during active Claude Code sessions (#7540) Co-authored-by: Orca --- .../terminal-visibility-resume.test.ts | 8 +- .../terminal-visibility-resume.ts | 7 +- .../terminal-webgl-atlas-recovery.test.ts | 162 ++++++++++++++++-- .../terminal-webgl-atlas-recovery.ts | 44 +++-- 4 files changed, 187 insertions(+), 34 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index d4b9762163a..e8b59a3a436 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -20,8 +20,11 @@ vi.mock('./pane-helpers', () => ({ fitPanes: vi.fn(), focusActivePane: vi.fn() })) +const scheduleTabRevealWebglAtlasRecovery = vi.fn() vi.mock('./terminal-webgl-atlas-recovery', () => ({ - scheduleTerminalWebglAtlasRecovery: vi.fn() + // Why: the light-tab reveal must recover the atlas immediately, decoupled from + // the terminal-output debounce (which a background stream could otherwise defer). + scheduleTabRevealWebglAtlasRecovery: () => scheduleTabRevealWebglAtlasRecovery() })) type FakeManager = { @@ -63,6 +66,9 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(manager.scheduleRevealRepaint).toHaveBeenCalledTimes(1) expect(manager.resumeRendering).not.toHaveBeenCalled() + // Reveal recovery is immediate (not the terminal-output debounce), so a + // background stream in another pane cannot defer this tab's atlas rebuild. + expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) it('schedules the repaint after rendering resumes on a heavy reveal', () => { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index fdf597c5587..0c821e5d030 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -7,7 +7,7 @@ import { } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' -import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' +import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024 const WINDOW_WAKE_FLUSH_CHARS = 64 * 1024 @@ -62,7 +62,10 @@ export function resumeTerminalVisibility({ // overlay's delayed geometry fit. Still request hidden-output recovery: // agent TUIs can suppress hidden bytes until the pane is foregrounded. requestLightTabBacklogRecovery(manager) - scheduleTerminalWebglAtlasRecovery() + // Why: reveal recovery must be immediate, not the terminal-output debounce + // — a background agent streaming in another pane must not defer this tab's + // atlas rebuild. + scheduleTabRevealWebglAtlasRecovery() if (isActive) { focusActivePane(manager) } diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts index 4d09bddbc67..93c696719f9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.test.ts @@ -5,7 +5,9 @@ import { } from '@/lib/pane-manager/pane-manager-registry' import { scheduleImagePasteWebglAtlasRecovery, - scheduleTerminalWebglAtlasRecovery + scheduleTabRevealWebglAtlasRecovery, + scheduleTerminalWebglAtlasRecovery, + TERMINAL_OUTPUT_RECOVERY_QUIET_MS } from './terminal-webgl-atlas-recovery' describe('terminal WebGL atlas recovery', () => { @@ -28,6 +30,10 @@ describe('terminal WebGL atlas recovery', () => { for (const manager of registeredManagers.splice(0)) { unregisterLivePaneManager(manager) } + // Why: a test can leave the module-global debounce timer armed; clear the + // fake-timer queue before restoring real timers so no pending fire leaks + // into a later test. + vi.clearAllTimers() vi.useRealTimers() vi.unstubAllGlobals() }) @@ -145,7 +151,63 @@ describe('terminal WebGL atlas recovery', () => { expect(manager.refreshAllPanes).not.toHaveBeenCalled() }) - it('coalesces terminal-output atlas recovery across a redraw burst', () => { + it('recovers immediately on a tab reveal, not through the streaming debounce', () => { + // Regression guard (STA-1365 review): reveal recovery must stay immediate so a + // background agent streaming in another pane cannot defer a revealed tab's + // atlas rebuild. It shares the paste path's immediate burst, not the debounce. + vi.useFakeTimers() + const rafCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + ) + const manager = registerManager() + + scheduleTabRevealWebglAtlasRecovery() + // First burst leg fires on the next frame — no 200ms debounce wait. + rafCallbacks[0]?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(120) + vi.advanceTimersByTime(380) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) + }) + + it('does not recover mid-stream while terminal output keeps arriving', () => { + // Regression (STA-1365): an alternate-screen TUI requests atlas recovery on + // every redraw frame. Recovering mid-stream clears the shared glyph atlas and + // repaints every pane several times a second, which reads as a flicker. The + // debounce must swallow a sustained stream entirely until it goes quiet. + vi.useFakeTimers() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + ) + const manager = registerManager() + + // Simulate ~1s of continuous streaming: a redraw chunk every 50ms, each + // shorter than the quiet window, so the debounce keeps resetting. + for (let elapsed = 0; elapsed < 1000; elapsed += 50) { + scheduleTerminalWebglAtlasRecovery() + vi.advanceTimersByTime(50) + } + + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + expect(manager.refreshAllPanes).not.toHaveBeenCalled() + }) + + it('recovers exactly once after terminal output settles, not a 3-stage burst', () => { + // Brennan-approved single-recovery decision: the streaming settle fires one + // clear+refresh, not the rAF+120+500 burst, so a clear can only ever land + // after 200ms of global quiet. Advancing past the burst delays must add no + // further fires on the streaming path. vi.useFakeTimers() vi.stubGlobal( 'requestAnimationFrame', @@ -160,22 +222,96 @@ describe('terminal WebGL atlas recovery', () => { scheduleTerminalWebglAtlasRecovery() scheduleTerminalWebglAtlasRecovery() + // Still nothing before the quiet window elapses. + vi.advanceTimersByTime(199) + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + + // Quiet window elapsed → exactly one clear+refresh for the coalesced stream. + vi.advanceTimersByTime(1) expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + + // The 120ms/500ms burst legs must NOT fire on the streaming path. + vi.advanceTimersByTime(120) + vi.advanceTimersByTime(380) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + }) + + it('does not clear mid-stream when a settle is followed by resumed streaming', () => { + // Cancelable invariant (STA-1365): a stream that pauses just over the quiet + // window lets the settle fire its single recovery during the gap, but the + // resumed chunks must not trigger any further clear while they keep arriving + // <200ms apart — the re-arm cancels the only pending handle (the debounce + // timer), so no clear lands mid-resumed-stream. + vi.useFakeTimers() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + callback(0) + return 1 + }) + ) + const manager = registerManager() + + // Initial stream, then a gap just over the quiet window → one settle recovery. + for (let elapsed = 0; elapsed < 300; elapsed += 50) { + scheduleTerminalWebglAtlasRecovery() + vi.advanceTimersByTime(50) + } + vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + + // Resume streaming: fire-count must stay pinned while chunks keep arriving. + for (let elapsed = 0; elapsed < 1000; elapsed += 50) { + scheduleTerminalWebglAtlasRecovery() + vi.advanceTimersByTime(50) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + } + }) + + it('coalesces the terminal-output callers onto one shared timer while paste stays immediate', () => { + // Cross-caller single-timer invariant: the terminal-output re-arm sites + // (foreground and hidden-output PTY writes) funnel through + // scheduleTerminalWebglAtlasRecovery and re-arm the one module-global debounce + // timer, so a single continuous stream (even a hidden one) keeps the recovery + // deferred for everyone. Image paste and tab reveal use their own immediate + // burst and are not coupled to the shared debounce timer. + vi.useFakeTimers() + const rafCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + ) + const manager = registerManager() + + // The terminal-output re-arm sites (foreground / hidden-output) both funnel + // through this one function; none may fire mid-stream. + for (let elapsed = 0; elapsed < 600; elapsed += 50) { + scheduleTerminalWebglAtlasRecovery() + vi.advanceTimersByTime(50) + } + expect(manager.resetWebglTextureAtlases).not.toHaveBeenCalled() + + // Global quiet → exactly one coalesced recovery for the whole shared stream. + vi.advanceTimersByTime(TERMINAL_OUTPUT_RECOVERY_QUIET_MS) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(manager.refreshAllPanes).toHaveBeenCalledTimes(1) + + // Paste is independent: its own immediate 3-stage burst, not the shared timer. + manager.resetWebglTextureAtlases.mockClear() + manager.refreshAllPanes.mockClear() + scheduleImagePasteWebglAtlasRecovery() + rafCallbacks.at(-1)?.(0) + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) vi.advanceTimersByTime(120) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(2) - scheduleTerminalWebglAtlasRecovery() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(2) vi.advanceTimersByTime(380) expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(3) expect(manager.refreshAllPanes).toHaveBeenCalledTimes(3) - - scheduleTerminalWebglAtlasRecovery() - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(4) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(4) - vi.advanceTimersByTime(500) - expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(6) - expect(manager.refreshAllPanes).toHaveBeenCalledTimes(6) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts index 26c8dfa283e..1912e0c0e06 100644 --- a/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-atlas-recovery.ts @@ -2,7 +2,12 @@ import { resetAndRefreshAllTerminalWebglAtlases } from '@/lib/pane-manager/pane- const ATLAS_RECOVERY_DELAYS_MS = [120, 500] -let terminalOutputRecoveryScheduled = false +// Why: a streaming TUI requests output atlas recovery every frame; recovering +// mid-stream clears the shared atlas and repaints every pane, which flickers +// (STA-1365). Wait for output to go quiet so recovery runs once, on settle. +export const TERMINAL_OUTPUT_RECOVERY_QUIET_MS = 200 + +let terminalOutputRecoveryDebounceTimer: ReturnType | null = null function scheduleNextFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame === 'function') { @@ -22,32 +27,35 @@ function resetAtlasesAndRefreshPanes(): void { } } -function scheduleAtlasRecoveryBurst(onComplete?: () => void): void { +function scheduleAtlasRecoveryBurst(): void { scheduleNextFrame(() => resetAtlasesAndRefreshPanes()) - for (const [index, delayMs] of ATLAS_RECOVERY_DELAYS_MS.entries()) { - globalThis.setTimeout(() => { - resetAtlasesAndRefreshPanes() - if (index === ATLAS_RECOVERY_DELAYS_MS.length - 1) { - onComplete?.() - } - }, delayMs) + for (const delayMs of ATLAS_RECOVERY_DELAYS_MS) { + globalThis.setTimeout(() => resetAtlasesAndRefreshPanes(), delayMs) } } export function scheduleImagePasteWebglAtlasRecovery(): void { // Why: image chips can redraw after bracketed paste parsing, so cover the - // short post-paste paint window with a few cheap atlas rebuilds. + // short post-paste paint window with a few cheap atlas rebuilds. Paste is a + // one-shot event, so recover immediately rather than debouncing. + scheduleAtlasRecoveryBurst() +} + +export function scheduleTabRevealWebglAtlasRecovery(): void { + // Why: a tab reveal is one-shot, so recover immediately — decoupled from the + // streaming debounce so a background stream can't defer a revealed tab's rebuild. scheduleAtlasRecoveryBurst() } export function scheduleTerminalWebglAtlasRecovery(): void { - if (terminalOutputRecoveryScheduled) { - return + // Why: terminal-output recovery (foreground + hidden PTY writes). Trailing-edge + // debounce so a clear only ever runs after 200ms of quiet — never mid-stream; + // a resumed stream cancels the pending timer, so a pause-then-resume can't leak. + if (terminalOutputRecoveryDebounceTimer != null) { + globalThis.clearTimeout(terminalOutputRecoveryDebounceTimer) } - terminalOutputRecoveryScheduled = true - // Why: TUI redraw bursts can corrupt xterm's shared WebGL glyph atlas without - // a context-loss event; coalesce resets so output storms do not queue timers. - scheduleAtlasRecoveryBurst(() => { - terminalOutputRecoveryScheduled = false - }) + terminalOutputRecoveryDebounceTimer = globalThis.setTimeout(() => { + terminalOutputRecoveryDebounceTimer = null + resetAtlasesAndRefreshPanes() + }, TERMINAL_OUTPUT_RECOVERY_QUIET_MS) } From 057db5b287d9367ec60d9d4b084d7bd256771604 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:14:30 +0000 Subject: [PATCH 016/170] release: v1.4.125-rc.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7ed80ddd68d..623876bbd92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.124", + "version": "1.4.125-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 883b56be6e5ee65cac9fbb8499bdd1c3c3dc492a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:50:35 +0000 Subject: [PATCH 017/170] release: v1.4.125-rc.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 623876bbd92..b39dbe185c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.125-rc.0", + "version": "1.4.125-rc.1", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 34a254956e267b6e3299bb76567630df0fde1694 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:09:44 -0700 Subject: [PATCH 018/170] perf(ai-vault): reuse and incrementally parse session transcripts across scans (STA-1278) (#7525) Co-authored-by: Orca --- .../ai-vault/session-scanner-discovery.ts | 3 +- .../session-scanner-parse-cache.test.ts | 249 ++++++++++++++++++ .../ai-vault/session-scanner-parse-cache.ts | 230 ++++++++++++++++ .../session-scanner-primary-parsers.ts | 182 ++++++++----- .../session-scanner-scope-discovery.ts | 35 ++- src/main/ai-vault/session-scanner-types.ts | 4 + src/main/ai-vault/session-scanner.ts | 122 +++++---- 7 files changed, 704 insertions(+), 121 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-parse-cache.test.ts create mode 100644 src/main/ai-vault/session-scanner-parse-cache.ts diff --git a/src/main/ai-vault/session-scanner-discovery.ts b/src/main/ai-vault/session-scanner-discovery.ts index f5541ca39c2..9bfade6e665 100644 --- a/src/main/ai-vault/session-scanner-discovery.ts +++ b/src/main/ai-vault/session-scanner-discovery.ts @@ -25,7 +25,8 @@ export async function discoverFiles(args: { files.push({ path, mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString() + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size }) } catch (err) { args.issues.push({ agent: args.agent, path, message: errorMessage(err) }) diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts new file mode 100644 index 00000000000..6ba5e3266d6 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -0,0 +1,249 @@ +import { appendFile, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + parseAgentSessionFileCached, + resetSessionParseCacheForTests, + createSessionParseStats +} from './session-scanner-parse-cache' +import { parseClaudeSessionFile } from './session-scanner-primary-parsers' +import type { FileWithMtime, SessionFileCandidate } from './session-scanner-types' + +let tempRoots: string[] = [] + +beforeEach(() => { + resetSessionParseCacheForTests() +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +async function makeTempDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-parse-cache-')) + tempRoots.push(root) + return root +} + +async function claudeCandidate(path: string): Promise { + const fileStat = await stat(path) + const file: FileWithMtime = { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + } + return { agent: 'claude', file, codexHome: null } +} + +function userRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'user', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + cwd: '/repo/app', + gitBranch: 'main', + message: { role: 'user', content: text } + }) +} + +function assistantRecord(index: number, text: string): string { + return JSON.stringify({ + type: 'assistant', + sessionId: 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee', + timestamp: new Date(1740000000000 + index * 60_000).toISOString(), + message: { + role: 'assistant', + model: 'claude-fable-5', + content: [{ type: 'text', text }], + usage: { input_tokens: 100, output_tokens: 40 } + } + }) +} + +// Ground truth: what a cold one-shot parse of the current file contents yields. +async function freshParse(path: string) { + const candidate = await claudeCandidate(path) + return parseClaudeSessionFile(candidate.file) +} + +async function cachedParse(path: string) { + const candidate = await claudeCandidate(path) + return parseAgentSessionFileCached(candidate, process.platform) +} + +describe('parseAgentSessionFileCached', () => { + it('returns the identical cached session for an unchanged file', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile(path, `${userRecord(0, 'first question')}\n${assistantRecord(1, 'answer')}\n`) + + const stats = createSessionParseStats() + const candidate = await claudeCandidate(path) + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + const second = await parseAgentSessionFileCached(candidate, process.platform, stats) + + expect(first).not.toBeNull() + expect(second).toBe(first) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(1) + expect(stats.incremental).toBe(0) + }) + + it('incrementally parses appended lines and matches a cold parse exactly', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${[ + userRecord(0, 'first question'), + assistantRecord(1, 'first answer'), + JSON.stringify({ type: 'ai-title', aiTitle: 'Original title' }) + ].join('\n')}\n` + ) + const stats = createSessionParseStats() + await parseAgentSessionFileCached(await claudeCandidate(path), process.platform, stats) + + await appendFile( + path, + `${[ + userRecord(2, 'second question'), + assistantRecord(3, 'second answer'), + JSON.stringify({ type: 'ai-title', aiTitle: 'Revised title' }), + assistantRecord(4, 'third answer') + ].join('\n')}\n` + ) + + const incremental = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.incremental).toBe(1) + expect(incremental).toEqual(await freshParse(path)) + expect(incremental?.messageCount).toBe(5) + expect(incremental?.title).toBe('Revised title') + expect(incremental?.totalTokens).toBe(420) + }) + + it('shows a trailing unterminated line without double-counting it later', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + // Final record has no trailing newline (mid-write file). + await writeFile(path, `${userRecord(0, 'question')}\n${assistantRecord(1, 'partial answer')}`) + + const first = await cachedParse(path) + expect(first?.messageCount).toBe(2) + expect(first).toEqual(await freshParse(path)) + + // The writer finishes the line and appends one more record. + await appendFile(path, `\n${userRecord(2, 'follow-up')}\n`) + const second = await cachedParse(path) + expect(second?.messageCount).toBe(3) + expect(second).toEqual(await freshParse(path)) + }) + + it('falls back to a full parse when the file is truncated', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${[userRecord(0, 'one'), assistantRecord(1, 'two'), userRecord(2, 'three')].join('\n')}\n` + ) + await cachedParse(path) + + await writeFile(path, `${userRecord(0, 'rewritten only line')}\n`) + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(reparsed).toEqual(await freshParse(path)) + expect(reparsed?.messageCount).toBe(1) + }) + + it('detects a grown rewrite via the resume-point newline guard', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + const original = `${userRecord(0, 'aa')}\n` + await writeFile(path, original) + await cachedParse(path) + + // Larger file whose byte at the old resume point is mid-line, not '\n'. + const rewritten = `${userRecord(0, 'a much longer rewritten question than before')}\n${assistantRecord(1, 'answer')}\n` + expect(rewritten.length).toBeGreaterThan(original.length) + expect(rewritten[original.length - 1]).not.toBe('\n') + await writeFile(path, rewritten) + + const stats = createSessionParseStats() + const reparsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(stats.fullParses).toBe(1) + expect(stats.incremental).toBe(0) + expect(reparsed).toEqual(await freshParse(path)) + expect(reparsed?.messageCount).toBe(2) + }) + + it('parses CRLF transcripts identically to the streaming parser', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + await writeFile( + path, + `${userRecord(0, 'windows question')}\r\n${assistantRecord(1, 'answer')}\r\n` + ) + + const first = await cachedParse(path) + expect(first).toEqual(await freshParse(path)) + expect(first?.messageCount).toBe(2) + + await appendFile(path, `${userRecord(2, 'more')}\r\n`) + const second = await cachedParse(path) + expect(second?.messageCount).toBe(3) + expect(second).toEqual(await freshParse(path)) + }) + + it('caches non-Claude sessions by mtime and size', async () => { + const root = await makeTempDir() + await mkdir(join(root, '2026', '05', '01'), { recursive: true }) + const path = join( + root, + '2026', + '05', + '01', + 'rollout-2026-05-01T10-00-00-019f0000-1111-7222-8333-444444444444.jsonl' + ) + await writeFile( + path, + `${JSON.stringify({ + timestamp: '2026-05-01T11:00:00.000Z', + type: 'session_meta', + payload: { id: '019f0000-1111-7222-8333-444444444444', cwd: '/repo/app' } + })}\n` + ) + const fileStat = await stat(path) + const candidate: SessionFileCandidate = { + agent: 'codex', + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }, + codexHome: null + } + const stats = createSessionParseStats() + const first = await parseAgentSessionFileCached(candidate, process.platform, stats) + const second = await parseAgentSessionFileCached(candidate, process.platform, stats) + expect(second).toBe(first) + expect(stats.fullParses).toBe(1) + expect(stats.reused).toBe(1) + }) +}) diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts new file mode 100644 index 00000000000..f8e20f92af8 --- /dev/null +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -0,0 +1,230 @@ +import { createReadStream } from 'node:fs' +import { open } from 'node:fs/promises' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { parseAgentSessionFile } from './session-scanner-agent-parser' +import { + cloneClaudeSessionParseState, + consumeClaudeSessionLine, + createClaudeSessionParseState, + finalizeClaudeSessionParseState, + type ClaudeSessionParseState +} from './session-scanner-primary-parsers' +import type { SessionFileCandidate } from './session-scanner-types' + +// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a +// full steady-state result set stays resident between forced rescans. +const MAX_CACHE_ENTRIES = 4096 + +const NEWLINE_BYTE = 0x0a +const CARRIAGE_RETURN_BYTE = 0x0d + +type ClaudeResumePoint = { + state: ClaudeSessionParseState + // Byte offset just past the last complete ('\n'-terminated) line consumed; + // a trailing unterminated line is deliberately left before this point. + byteOffset: number +} + +type SessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + platform: NodeJS.Platform + session: AiVaultSession | null + claudeResume: ClaudeResumePoint | null +} + +export type SessionParseStats = { + reused: number + incremental: number + fullParses: number + bytesRead: number +} + +export function createSessionParseStats(): SessionParseStats { + return { reused: 0, incremental: 0, fullParses: 0, bytesRead: 0 } +} + +const cache = new Map() + +export function resetSessionParseCacheForTests(): void { + cache.clear() +} + +function storeEntry(path: string, entry: SessionParseCacheEntry): void { + cache.delete(path) + cache.set(path, entry) + if (cache.size > MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } +} + +/** + * Parse a session file, reusing prior work where the file is provably + * unchanged (mtime+size) and, for Claude transcripts, resuming the parse from + * the last consumed byte when the file only grew. This is what keeps the + * renderer's ~5s forced rescans from re-reading gigabytes of transcripts + * (STA-1278: main process pegging one core during multi-agent workloads). + */ +export async function parseAgentSessionFileCached( + candidate: SessionFileCandidate, + platform: NodeJS.Platform, + stats?: SessionParseStats +): Promise { + const { file } = candidate + const entry = cache.get(file.path) + + const unchanged = + entry !== undefined && + entry.platform === platform && + entry.mtimeMs === file.mtimeMs && + (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) + if (unchanged) { + if (stats) { + stats.reused++ + } + storeEntry(file.path, entry) + return entry.session + } + + if (candidate.agent === 'claude') { + const parsed = await parseClaudeCandidateWithResume({ candidate, platform, entry, stats }) + storeEntry(file.path, parsed) + return parsed.session + } + + if (stats) { + stats.fullParses++ + stats.bytesRead += file.sizeBytes ?? 0 + } + const session = await parseAgentSessionFile(candidate, platform) + storeEntry(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform, + session, + claudeResume: null + }) + return session +} + +async function parseClaudeCandidateWithResume(args: { + candidate: SessionFileCandidate + platform: NodeJS.Platform + entry: SessionParseCacheEntry | undefined + stats?: SessionParseStats +}): Promise { + const { file } = args.candidate + const resume = args.entry?.platform === args.platform ? args.entry.claudeResume : null + const canResume = + resume !== null && + resume !== undefined && + typeof file.sizeBytes === 'number' && + file.sizeBytes >= resume.byteOffset && + (resume.byteOffset === 0 || (await endsWithNewlineAt(file.path, resume.byteOffset))) + + // Clone before consuming: a failed read must not corrupt the cached state, + // or the next resume would double-count the lines applied before the error. + const state = canResume + ? cloneClaudeSessionParseState(resume.state) + : createClaudeSessionParseState(file) + const startOffset = canResume ? resume.byteOffset : 0 + if (args.stats) { + if (canResume) { + args.stats.incremental++ + } else { + args.stats.fullParses++ + } + } + + const readResult = await consumeCompleteJsonlLines({ + path: file.path, + start: startOffset, + onLine: (line) => consumeClaudeSessionLine(state, line) + }) + if (args.stats) { + args.stats.bytesRead += readResult.bytesRead + } + + // The stat this scan displays is current even when nothing new was consumed. + state.accumulator.modifiedAt = file.modifiedAt + + // Keep parity with the one-shot parser: a final unterminated line is shown, + // but stays out of the resumable state so the (possibly still-growing) line + // is re-read once complete instead of being half-counted. + let displayState = state + if (readResult.trailingPartialLine !== null) { + displayState = cloneClaudeSessionParseState(state) + consumeClaudeSessionLine(displayState, readResult.trailingPartialLine) + } + + return { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + platform: args.platform, + session: finalizeClaudeSessionParseState(displayState, args.platform), + claudeResume: { state, byteOffset: readResult.consumedThrough } + } +} + +// A resume point is only valid if it still sits just past a line break; +// anything else means the file was rewritten, not appended. Heuristic: a +// grown rewrite keeping '\n' at exactly this byte would slip through, but +// agent transcripts are append-only so that trade is accepted (worst case is +// a stale vault row until the file is next truncated or the app restarts). +async function endsWithNewlineAt(path: string, offset: number): Promise { + const handle = await open(path, 'r') + try { + const { bytesRead, buffer } = await handle.read(Buffer.alloc(1), 0, 1, offset - 1) + return bytesRead === 1 && buffer[0] === NEWLINE_BYTE + } finally { + await handle.close() + } +} + +type JsonlReadResult = { + consumedThrough: number + trailingPartialLine: string | null + bytesRead: number +} + +// Byte-accurate replacement for readline: offsets must count bytes (not +// UTF-8-decoded characters) so a resumed read starts exactly where the last +// complete line ended. +async function consumeCompleteJsonlLines(args: { + path: string + start: number + onLine: (line: string) => void +}): Promise { + let consumedThrough = args.start + let bytesRead = 0 + let remainder: Buffer | null = null + + const stream = createReadStream(args.path, { start: args.start }) + for await (const chunk of stream as AsyncIterable) { + bytesRead += chunk.length + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + let lineStart = 0 + let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + while (newlineIndex !== -1) { + let lineEnd = newlineIndex + if (lineEnd > lineStart && data[lineEnd - 1] === CARRIAGE_RETURN_BYTE) { + lineEnd-- + } + args.onLine(data.toString('utf-8', lineStart, lineEnd)) + lineStart = newlineIndex + 1 + newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) + } + consumedThrough += lineStart + // Copy the tail so retaining it doesn't pin the whole chunk buffer. + remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null + } + + return { + consumedThrough, + trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, + bytesRead + } +} diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index b574cd3124e..eeedee9170f 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -29,6 +29,116 @@ type ParserSessionOptions = { executionHostPlatform?: NodeJS.Platform | null } +// Parse state kept resumable so the scan cache can append newly written +// transcript lines without re-reading the whole (potentially huge) file. +export type ClaudeSessionParseState = { + accumulator: SessionAccumulator + metaTitle: string | null + generatedTitle: string | null + firstUserTitle: string | null +} + +export function createClaudeSessionParseState(file: FileWithMtime): ClaudeSessionParseState { + return { + accumulator: createAccumulator({ + agent: 'claude', + file, + sessionId: sessionIdFromFileName(file.path) + }), + metaTitle: null, + generatedTitle: null, + firstUserTitle: null + } +} + +export function cloneClaudeSessionParseState( + state: ClaudeSessionParseState +): ClaudeSessionParseState { + return { + accumulator: { + ...state.accumulator, + previewMessages: [...state.accumulator.previewMessages] + }, + metaTitle: state.metaTitle, + generatedTitle: state.generatedTitle, + firstUserTitle: state.firstUserTitle + } +} + +export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: string): void { + const { accumulator } = state + const record = parseJsonObject(line) + if (!record) { + return + } + + if (typeof record.sessionId === 'string' && record.sessionId.trim()) { + accumulator.sessionId = record.sessionId.trim() + } + updateTimeline(accumulator, extractString(record.timestamp)) + updateLatestLocation(accumulator, record) + + if (record.type === 'custom-title') { + accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '') + return + } + + if (record.type === 'ai-title') { + const title = normalizeTitleText(extractString(record.aiTitle) ?? '') + if (title) { + // Claude can revise generated names; AI Vault should mirror the current one. + state.generatedTitle = title + } + return + } + + if (record.type === 'agent-name' && !state.generatedTitle) { + state.metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '') + return + } + + if (record.type === 'user') { + accumulator.messageCount++ + const title = extractMessageText(record.message) + addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) + if (title) { + // Meta prompts (injected context) only seed the last-resort title. + if (record.isMeta === true) { + state.metaTitle ??= title + } else { + state.firstUserTitle ??= title + } + } + return + } + + if (record.type === 'assistant') { + accumulator.messageCount++ + const message = asRecord(record.message) + addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp) + const model = extractString(message?.model) + if (model) { + accumulator.model = model + } + accumulator.totalTokens += claudeUsageTotal(message?.usage) + } +} + +export function finalizeClaudeSessionParseState( + state: ClaudeSessionParseState, + platform: NodeJS.Platform, + options: ParserSessionOptions = {} +): AiVaultSession | null { + // Finalize a snapshot: the live state (and its preview array) may keep + // accumulating appended lines after this session object is handed out. + const snapshot = cloneClaudeSessionParseState(state) + // Why: a user-set custom-title (accumulator.title) wins, but Claude's generated + // session name (ai-title) should outrank the raw first prompt when present. + snapshot.accumulator.fallbackTitle = + snapshot.generatedTitle ?? snapshot.firstUserTitle ?? snapshot.metaTitle + return finalizeSession(snapshot.accumulator, platform, options) +} + export async function parseClaudeSessionFile( file: FileWithMtime, platform: NodeJS.Platform = process.platform @@ -60,77 +170,11 @@ async function parseClaudeSessionLines(args: { platform: NodeJS.Platform options?: ParserSessionOptions }): Promise { - const accumulator = createAccumulator({ - agent: 'claude', - file: args.file, - sessionId: sessionIdFromFileName(args.file.path) - }) - let metaTitle: string | null = null - let generatedTitle: string | null = null - let firstUserTitle: string | null = null - + const state = createClaudeSessionParseState(args.file) for await (const line of args.lines) { - const record = parseJsonObject(line) - if (!record) { - continue - } - - if (typeof record.sessionId === 'string' && record.sessionId.trim()) { - accumulator.sessionId = record.sessionId.trim() - } - updateTimeline(accumulator, extractString(record.timestamp)) - updateLatestLocation(accumulator, record) - - if (record.type === 'custom-title') { - accumulator.title = normalizeTitleText(extractString(record.customTitle) ?? '') - continue - } - - if (record.type === 'ai-title') { - const title = normalizeTitleText(extractString(record.aiTitle) ?? '') - if (title) { - // Claude can revise generated names; AI Vault should mirror the current one. - generatedTitle = title - } - continue - } - - if (record.type === 'agent-name' && !generatedTitle) { - metaTitle ??= normalizeTitleText(extractString(record.agentName) ?? '') - continue - } - - if (record.type === 'user') { - accumulator.messageCount++ - const title = extractMessageText(record.message) - addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp) - if (title) { - // Meta prompts (injected context) only seed the last-resort title. - if (record.isMeta === true) { - metaTitle ??= title - } else { - firstUserTitle ??= title - } - } - continue - } - - if (record.type === 'assistant') { - accumulator.messageCount++ - const message = asRecord(record.message) - addPreviewContent(accumulator, 'assistant', message?.content, record.timestamp) - const model = extractString(message?.model) - if (model) { - accumulator.model = model - } - accumulator.totalTokens += claudeUsageTotal(message?.usage) - } + consumeClaudeSessionLine(state, line) } - - // Why: a user-set custom-title (accumulator.title) wins, but Claude's generated - // session name (ai-title) should outrank the raw first prompt when present. - accumulator.fallbackTitle = generatedTitle ?? firstUserTitle ?? metaTitle - return finalizeSession(accumulator, args.platform, args.options) + return finalizeClaudeSessionParseState(state, args.platform, args.options) } export async function parseGeminiSessionFile( diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 4900e3e2348..41aac5f2700 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -17,6 +17,36 @@ const REPRESENTATIVE_CWD_LINE_LIMIT = 200 const REPRESENTATIVE_FILE_LIMIT = 3 const CLAUDE_EXTENSIONS = new Set(['.jsonl']) +// A Claude project dir encodes exactly one cwd, so a resolved cwd never +// changes; caching it spares each rescan the transcript-head reads. +const PROJECT_DIR_CWD_CACHE_MAX = 2048 +const projectDirCwdCache = new Map() + +export function resetProjectDirCwdCacheForTests(): void { + projectDirCwdCache.clear() +} + +async function cachedProjectDirCwd(projectDir: string): Promise { + const cached = projectDirCwdCache.get(projectDir) + if (cached !== undefined) { + // Refresh recency so hot in-scope dirs outlive one-off ones at the cap. + projectDirCwdCache.delete(projectDir) + projectDirCwdCache.set(projectDir, cached) + return cached + } + const cwd = await readProjectDirCwd(projectDir) + if (cwd) { + if (projectDirCwdCache.size >= PROJECT_DIR_CWD_CACHE_MAX) { + const oldest = projectDirCwdCache.keys().next() + if (!oldest.done) { + projectDirCwdCache.delete(oldest.value) + } + } + projectDirCwdCache.set(projectDir, cwd) + } + return cwd +} + /** * Fully include the transcripts of Claude project directories whose cwd falls * inside the active workspace/project paths. @@ -41,7 +71,7 @@ export async function discoverInScopeClaudeFiles(args: { const collected = new Map() for (const rootDir of args.rootDirs) { for (const projectDir of await listProjectDirs(rootDir, scopeProjectPrefixes)) { - const cwd = await readProjectDirCwd(projectDir) + const cwd = await cachedProjectDirCwd(projectDir) if (!cwd || !args.scopePaths.some((scopePath) => isCwdInsideScopePath(scopePath, cwd))) { continue } @@ -205,7 +235,8 @@ async function collectClaudeFiles(args: { addBoundedFile(args.collected, args.limit, { path, mtimeMs: fileStat.mtimeMs, - modifiedAt: fileStat.mtime.toISOString() + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size }) } catch (err) { args.issues.push({ agent: 'claude', path, message: errorMessage(err) }) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 5d2208b7b60..56ec6971728 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -41,6 +41,10 @@ export type FileWithMtime = { path: string mtimeMs: number modifiedAt: string + // Present when discovery statted the file; lets the parse cache detect + // unchanged/truncated files without a second stat. Synthetic candidates + // (OpenCode SQLite rows, remote files) omit it. + sizeBytes?: number } export type SessionFileCandidate = { diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 59f92dafc7a..5ba2a95d503 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -4,9 +4,14 @@ import type { AiVaultSession } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host' +import { withSpan } from '../observability/tracer' import { sessionSortTime } from './session-scanner-accumulator' -import { parseAgentSessionFile } from './session-scanner-agent-parser' import { codexHomeForSessionsDir } from './session-scanner-codex-paths' +import { + createSessionParseStats, + parseAgentSessionFileCached, + type SessionParseStats +} from './session-scanner-parse-cache' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { DEFAULT_CODEX_HOME_DIR, @@ -39,54 +44,69 @@ const SCOPE_PARSE_LIMIT = 2000 export async function scanAiVaultSessions( options: AiVaultScanOptions = {} ): Promise { - const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) - const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) - const platform = options.platform ?? process.platform - const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID - const issues: AiVaultScanIssue[] = [] - const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + // The span makes scan cost visible in the local trace file: STA-1278-style + // "one core pegged" reports need to show whether transcript scanning is the + // subsystem burning CPU, and how much of each scan the cache absorbed. + return withSpan('aiVault.scan', async (span) => { + const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) + const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) + const platform = options.platform ?? process.platform + const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID + const issues: AiVaultScanIssue[] = [] + const parseStats = createSessionParseStats() + const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) - const candidates = discoveries - .flatMap((discovery) => - discovery.files.map( - (file): SessionFileCandidate => ({ - agent: discovery.agent, - file, - codexHome: - discovery.agent === 'codex' - ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) - : null - }) + const candidates = discoveries + .flatMap((discovery) => + discovery.files.map( + (file): SessionFileCandidate => ({ + agent: discovery.agent, + file, + codexHome: + discovery.agent === 'codex' + ? codexHomeForSessionsDir(discovery.rootDir, DEFAULT_CODEX_HOME_DIR) + : null + }) + ) ) - ) - .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) + .sort((left, right) => right.file.mtimeMs - left.file.mtimeMs) - const parsedSessions = await parseSessionCandidates({ - candidates, - limit, - platform, - executionHostId, - issues + const parsedSessions = await parseSessionCandidates({ + candidates, + limit, + platform, + executionHostId, + issues, + parseStats + }) + + const cappedSessions = parsedSessions + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) + + const scopeSessions = await scanInScopeSessions({ + discoveries, + scopePaths: options.scopePaths ?? [], + alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), + platform, + executionHostId, + issues, + parseStats + }) + + span.setAttribute('candidates', candidates.length) + span.setAttribute('reused', parseStats.reused) + span.setAttribute('incremental', parseStats.incremental) + span.setAttribute('fullParses', parseStats.fullParses) + span.setAttribute('bytesRead', parseStats.bytesRead) + span.setAttribute('issues', issues.length) + + return { + sessions: mergeSessions(cappedSessions, scopeSessions), + issues: issues.map((issue) => ({ executionHostId, ...issue })), + scannedAt: new Date().toISOString() + } }) - - const cappedSessions = parsedSessions - .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) - .slice(0, limit) - - const scopeSessions = await scanInScopeSessions({ - discoveries, - scopePaths: options.scopePaths ?? [], - alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), - platform, - executionHostId, - issues - }) - - return { - sessions: mergeSessions(cappedSessions, scopeSessions), - issues: issues.map((issue) => ({ executionHostId, ...issue })), - scannedAt: new Date().toISOString() - } } // In-scope sessions are guaranteed regardless of the recency cap, so the global @@ -116,6 +136,7 @@ async function scanInScopeSessions(args: { platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] + parseStats: SessionParseStats }): Promise { if (args.scopePaths.length === 0) { return [] @@ -142,7 +163,8 @@ async function scanInScopeSessions(args: { limit: candidates.length, platform: args.platform, executionHostId: args.executionHostId, - issues: args.issues + issues: args.issues, + parseStats: args.parseStats }) } @@ -152,6 +174,7 @@ async function parseSessionCandidates(args: { platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] + parseStats: SessionParseStats }): Promise { const sessions: AiVaultSession[] = [] let index = 0 @@ -167,7 +190,7 @@ async function parseSessionCandidates(args: { const batch = args.candidates.slice(index, index + batchSize) const results = await Promise.all( batch.map((candidate) => - parseSessionCandidate(candidate, args.platform, args.executionHostId) + parseSessionCandidate(candidate, args.platform, args.executionHostId, args.parseStats) ) ) @@ -189,10 +212,11 @@ async function parseSessionCandidates(args: { async function parseSessionCandidate( candidate: SessionFileCandidate, platform: NodeJS.Platform, - executionHostId: ExecutionHostId + executionHostId: ExecutionHostId, + parseStats: SessionParseStats ): Promise { try { - const session = await parseAgentSessionFile(candidate, platform) + const session = await parseAgentSessionFileCached(candidate, platform, parseStats) return { session: session ? withSessionExecutionHost(session, executionHostId) : null, issue: null From d8ed41f74cc9a767f60b53287bf39d1d852d8606 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:27:45 +0000 Subject: [PATCH 019/170] release: v1.4.125-rc.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b39dbe185c5..15364e14eeb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.125-rc.1", + "version": "1.4.125-rc.2", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 28495f6036022bf54498f350d271e3418acaeb85 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:25:41 +0000 Subject: [PATCH 020/170] release: v1.4.125 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 15364e14eeb..812acb357f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.125-rc.2", + "version": "1.4.125", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", From 63384d049734dc63bfcb33b50dc9fb379e7bb25b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:31:08 -0700 Subject: [PATCH 021/170] perf(renderer): gate the jump palette's status-map subscriptions while closed (#7558) Co-authored-by: Orca --- .../src/components/WorktreeJumpPalette.tsx | 55 +++++++++++---- ...orktree-jump-palette-status-inputs.test.ts | 67 +++++++++++++++++++ .../worktree-jump-palette-status-inputs.ts | 56 ++++++++++++++++ 3 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts create mode 100644 src/renderer/src/components/worktree-jump-palette-status-inputs.ts diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 4c310972e06..0b18f5f3f09 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -1,5 +1,6 @@ /* oxlint-disable max-lines */ import React, { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { @@ -14,6 +15,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import { getRepoMapFromState, useAllWorktrees } from '@/store/selectors' +import { selectPaletteStatusInputs } from './worktree-jump-palette-status-inputs' import { CommandDialog, CommandInput, @@ -193,6 +195,10 @@ type PaletteListEntry = PaletteItem | CreateWorktreePaletteItem | SectionHeader const CREATE_WORKSPACE_QUICK_ACTION_ITEM_ID = `quick-action:${CREATE_WORKSPACE_QUICK_ACTION_ID}` +// Why: comfortably outlast the CommandDialog close animation (~150–200ms) so the +// gated status maps stay live until the fading rows are gone from the DOM. +const PALETTE_STATUS_INPUTS_LINGER_MS = 300 + function getComposerPrefetchRepoId( state: ReturnType, initialRepoId?: string @@ -337,21 +343,44 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const projectHostSetups = useAppStore((s) => s.projectHostSetups) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations) - const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) - // Why: getWorktreeStatus needs per-pane titles so split-pane tabs with a - // working agent in a non-focused pane still surface as 'working' in the - // jump palette. Without this, clicking between panes would desync the - // palette's spinner from the sidebar's spinner. - const runtimePaneTitlesByTabId = useAppStore((s) => s.runtimePaneTitlesByTabId) - // Why: ptyIdsByTabId is the live-pty source of truth — without it, - // getWorktreeStatus would treat slept tabs as live (their preserved - // tab.ptyId is a wake-hint sessionId, not a liveness signal) and the jump - // palette dot would lie green even though the sidebar dot is correctly grey. - const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId) - const terminalLayoutsByTabId = useAppStore((s) => s.terminalLayoutsByTabId) + // Why: keep the (very hot) status maps subscribed through the dialog's close + // animation. `visible` flips false synchronously on close, but the CommandDialog + // content stays mounted while it fades/zooms out — dropping the maps to empty + // there would flash the switcher rows empty/reordered mid-animation. Linger a + // beat past close, then let the gate drop the subscription. + const [statusInputsLingering, setStatusInputsLingering] = useState(false) + useEffect(() => { + if (visible) { + setStatusInputsLingering(true) + return + } + const timer = window.setTimeout( + () => setStatusInputsLingering(false), + PALETTE_STATUS_INPUTS_LINGER_MS + ) + return () => window.clearTimeout(timer) + }, [visible]) + // Why: these five status maps drive per-worktree live/working dots and the + // switcher sort, but only matter while the palette is active. Two of them + // (agentStatusByPaneKey, runtimePaneTitlesByTabId) get a new identity on every + // agent-status / pane-title write app-wide, so subscribing to them while the + // always-mounted palette is closed re-rendered it on unrelated terminals. Gate + // the subscription on active-or-still-closing: a shared frozen constant while + // inactive keeps useShallow referentially equal across the churn, and the live + // maps flow through the instant the palette opens. + // - runtimePaneTitlesByTabId: split-pane tabs with a working agent in a + // non-focused pane still surface as 'working' (matches the sidebar spinner). + // - ptyIdsByTabId: the live-pty source of truth — slept tabs keep a wake-hint + // sessionId in tab.ptyId, so without it the palette dot would lie green. + const { + agentStatusByPaneKey, + runtimePaneTitlesByTabId, + ptyIdsByTabId, + terminalLayoutsByTabId, + tabsByWorktree + } = useAppStore(useShallow((s) => selectPaletteStatusInputs(s, visible || statusInputsLingering))) const prCache = useAppStore((s) => s.prCache) const issueCache = useAppStore((s) => s.issueCache) - const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const migrationUnsupportedByPtyId = useAppStore((s) => s.migrationUnsupportedByPtyId) const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) const activeTabType = useAppStore((s) => s.activeTabType) diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts new file mode 100644 index 00000000000..62428a06f0c --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { shallow } from 'zustand/shallow' +import { + EMPTY_PALETTE_STATUS_INPUTS, + selectPaletteStatusInputs, + type PaletteStatusInputsState +} from './worktree-jump-palette-status-inputs' + +const BASE: PaletteStatusInputsState = { + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + tabsByWorktree: {} +} + +describe('selectPaletteStatusInputs', () => { + it('returns the shared frozen constant while inactive', () => { + const inactive = selectPaletteStatusInputs(BASE, false) + expect(inactive).toBe(EMPTY_PALETTE_STATUS_INPUTS) + + // Churning the hottest maps while inactive must NOT change the selected + // reference, so a useShallow subscription skips the re-render. + const churned: PaletteStatusInputsState = { + ...BASE, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'claude' } }, + agentStatusByPaneKey: { 'tab-1:leaf-1': {} as never } + } + const afterChurn = selectPaletteStatusInputs(churned, false) + expect(afterChurn).toBe(EMPTY_PALETTE_STATUS_INPUTS) + expect(shallow(inactive, afterChurn)).toBe(true) + }) + + it('exposes the live maps the instant it becomes active', () => { + const titles = { 'tab-1': { 0: 'claude' } } + const state: PaletteStatusInputsState = { ...BASE, runtimePaneTitlesByTabId: titles } + const active = selectPaletteStatusInputs(state, true) + // Live map references pass straight through so status/sort derive correctly. + expect(active.runtimePaneTitlesByTabId).toBe(titles) + expect(active).not.toBe(EMPTY_PALETTE_STATUS_INPUTS) + }) + + it('keeps the live maps during the close linger (active stays true while animating)', () => { + // The component holds `active` true through the close animation, so churn is + // still reflected — no empty-row flash while the dialog fades out. + const titles = { 'tab-1': { 0: 'claude' } } + const closing = selectPaletteStatusInputs({ ...BASE, runtimePaneTitlesByTabId: titles }, true) + expect(closing.runtimePaneTitlesByTabId).toBe(titles) + }) + + it('shallow-changes only when a subscribed map reference actually changes while active', () => { + const titles = { 'tab-1': { 0: 'claude' } } + const s1: PaletteStatusInputsState = { ...BASE, runtimePaneTitlesByTabId: titles } + const r1 = selectPaletteStatusInputs(s1, true) + + // Same underlying map refs -> shallow-equal -> no re-render. + expect(shallow(r1, selectPaletteStatusInputs(s1, true))).toBe(true) + + // A real pane-title write replaces the map ref -> shallow-unequal -> the + // open palette re-derives status/order, exactly as before this change. + const s2: PaletteStatusInputsState = { + ...s1, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex' } } + } + expect(shallow(r1, selectPaletteStatusInputs(s2, true))).toBe(false) + }) +}) diff --git a/src/renderer/src/components/worktree-jump-palette-status-inputs.ts b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts new file mode 100644 index 00000000000..57a62d3ae58 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-status-inputs.ts @@ -0,0 +1,56 @@ +import type { AppState } from '@/store/types' + +export type PaletteStatusInputsState = Pick< + AppState, + | 'agentStatusByPaneKey' + | 'runtimePaneTitlesByTabId' + | 'ptyIdsByTabId' + | 'terminalLayoutsByTabId' + | 'tabsByWorktree' +> + +export type PaletteStatusInputs = PaletteStatusInputsState + +// Why: shared frozen bundle returned whenever the Cmd+J jump palette isn't +// active. The two hottest maps here — agentStatusByPaneKey and +// runtimePaneTitlesByTabId — get a new top-level identity on every agent-status +// transition and every terminal pane-title write app-wide. The palette is always +// mounted (App.tsx renders ) and stays mounted for +// the whole session once opened, so subscribing to them while it's closed +// re-rendered the whole palette — and recomputed its per-worktree live/working-dot +// sort over every worktree — on unrelated terminal chatter. A useShallow +// subscription keeps this same reference across that churn, so the closed palette +// stops reacting. Frozen so the shared singleton can't be mutated. +export const EMPTY_PALETTE_STATUS_INPUTS: PaletteStatusInputs = Object.freeze({ + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + tabsByWorktree: {} +}) + +/** + * Select the five status maps the jump palette needs to derive per-worktree + * live/working dots and switcher ordering — but only while it's `active` (open, + * or still animating closed). While inactive nothing is shown, so return a stable + * frozen constant that a `useShallow`-wrapped subscription keeps referentially + * equal across the (very hot) agent-status / pane-title writes, skipping the + * re-render that would otherwise fire on the always-mounted palette on every + * unrelated terminal. The instant it becomes active the live maps flow through + * and every derivation recomputes exactly as before. + */ +export function selectPaletteStatusInputs( + s: PaletteStatusInputsState, + active: boolean +): PaletteStatusInputs { + if (!active) { + return EMPTY_PALETTE_STATUS_INPUTS + } + return { + agentStatusByPaneKey: s.agentStatusByPaneKey, + runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + ptyIdsByTabId: s.ptyIdsByTabId, + terminalLayoutsByTabId: s.terminalLayoutsByTabId, + tabsByWorktree: s.tabsByWorktree + } +} From 47feae3fb35067c1d5807f7090d9b6c1899d5c95 Mon Sep 17 00:00:00 2001 From: Dzmitry Bachko Date: Mon, 6 Jul 2026 13:46:50 -0700 Subject: [PATCH 022/170] fix(settings): portal the IDE Font dropdown outside the settings section Fixes #7585 --- ...ngsFormControls.font-autocomplete.test.tsx | 54 +++- .../settings/SettingsFormControls.tsx | 280 ++++++++++-------- 2 files changed, 205 insertions(+), 129 deletions(-) diff --git a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx index f7f43cff2ed..62412f2e258 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.font-autocomplete.test.tsx @@ -34,11 +34,33 @@ describe('FontAutocomplete', () => { } function getOptionLabels(): string[] { - return Array.from(container.querySelectorAll('[role="option"]')).map( + // Why: the dropdown portals to document.body so it can escape the settings + // section; options are intentionally not descendants of the container. + return Array.from(document.querySelectorAll('[role="option"]')).map( (option) => option.textContent?.trim() ?? '' ) } + function expectOptionsToBePortaled(): void { + expect(container.querySelector('[role="option"]')).toBeNull() + } + + function getScrollArea(): HTMLElement { + const scrollArea = document.querySelector('[data-slot="scroll-area"]') + if (!scrollArea) { + throw new Error('Font autocomplete scroll area not found') + } + return scrollArea + } + + function getScrollAreaViewport(): HTMLElement { + const viewport = document.querySelector('[data-slot="scroll-area-viewport"]') + if (!viewport) { + throw new Error('Font autocomplete scroll area viewport not found') + } + return viewport + } + async function typeIntoInput(input: HTMLInputElement, value: string): Promise { await act(async () => { // Why: React tracks controlled input values through the native setter, so @@ -76,10 +98,11 @@ describe('FontAutocomplete', () => { }) expect( - container + document .querySelector('[role="option"][aria-selected="true"]') ?.textContent?.trim() ).toBe('JetBrains Mono') + expectOptionsToBePortaled() }) it('shows the full list on focus when the committed font has multiple matching suggestions', async () => { @@ -109,6 +132,33 @@ describe('FontAutocomplete', () => { 'Cascadia Mono PL', 'Consolas' ]) + expectOptionsToBePortaled() + }) + + it('bounds the portaled list to the available popover height', async () => { + function Harness(): ReactNode { + const [value, setValue] = useState('Cascadia Mono') + return ( + + ) + } + + await act(async () => { + root.render() + }) + + await act(async () => { + getInput().focus() + }) + + expect(getScrollArea().style.maxHeight).toBe('var(--radix-popover-content-available-height)') + expect(getScrollAreaViewport().style.maxHeight).toBe( + 'var(--radix-popover-content-available-height)' + ) }) it('keeps typed searches filtered even after the value updates', async () => { diff --git a/src/renderer/src/components/settings/SettingsFormControls.tsx b/src/renderer/src/components/settings/SettingsFormControls.tsx index 3de7c6ec956..a8b5bbdef05 100644 --- a/src/renderer/src/components/settings/SettingsFormControls.tsx +++ b/src/renderer/src/components/settings/SettingsFormControls.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react' import { ScrollArea } from '../ui/scroll-area' import { Input } from '../ui/input' import { Label } from '../ui/label' +import { Popover, PopoverAnchor, PopoverContent } from '../ui/popover' import { Check, ChevronsUpDown, CircleX } from 'lucide-react' import { normalizeColor, type TerminalThemeOption } from '@/lib/terminal-theme' import { MAX_THEME_RESULTS } from './SettingsConstants' @@ -618,21 +619,12 @@ export function FontAutocomplete({ } } - useEffect(() => { - if (!open) { - return + const handleOpenChange = (nextOpen: boolean): void => { + setOpen(nextOpen) + if (!nextOpen) { + setIsFilteringQuery(false) } - - const handlePointerDown = (event: MouseEvent): void => { - if (!rootRef.current?.contains(event.target as Node)) { - setOpen(false) - setIsFilteringQuery(false) - } - } - - document.addEventListener('mousedown', handlePointerDown) - return () => document.removeEventListener('mousedown', handlePointerDown) - }, [open]) + } const normalizedQuery = query.trim().toLowerCase() const normalizedValue = value.trim().toLowerCase() @@ -691,124 +683,158 @@ export function FontAutocomplete({ const focusInput = (): void => { inputRef.current?.focus() } + const popoverAvailableHeightStyle = { + // Why: tailwind-merge rewrites this arbitrary max-height class on the + // ScrollArea root, so keep the Radix available-height clamp as inline style. + maxHeight: 'var(--radix-popover-content-available-height)' + } as React.CSSProperties return (
-
- { - const next = e.target.value - setQuery(next) - setIsFilteringQuery(true) - onChange(next) - setOpen(true) - }} - onFocus={() => { - setIsFilteringQuery(false) - setOpen(true) - }} - onKeyDown={(e) => { - if (e.key === 'Escape') { - if (open) { - e.preventDefault() - setOpen(false) - setIsFilteringQuery(false) - } - return - } - - if (e.key === 'ArrowDown') { - e.preventDefault() - setOpen(true) - if (visibleSuggestions.length > 0) { - setHighlightedIndex((current) => - current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1) - ) - } - return - } - - if (e.key === 'ArrowUp') { - e.preventDefault() - setOpen(true) - if (visibleSuggestions.length > 0) { - setHighlightedIndex((current) => - current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0) - ) - } - return - } - - if (e.key === 'Enter' && open && highlightedIndex >= 0) { - const highlightedFont = visibleSuggestions[highlightedIndex] - if (highlightedFont) { - e.preventDefault() - commitValue(highlightedFont) - } - } - }} - placeholder={placeholder} - className="pr-18" - role="combobox" - aria-autocomplete="list" - aria-expanded={open} - aria-controls={listboxId} - aria-activedescendant={ - open && highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined - } - /> -
- {query ? ( - - ) : null} - -
-
+ setOpen(true) + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + if (open) { + e.preventDefault() + setOpen(false) + setIsFilteringQuery(false) + } + return + } - {open ? ( -
- 8 ? 'h-64' : undefined}> + if (e.key === 'ArrowDown') { + e.preventDefault() + setOpen(true) + if (visibleSuggestions.length > 0) { + setHighlightedIndex((current) => + current < 0 ? 0 : Math.min(current + 1, visibleSuggestions.length - 1) + ) + } + return + } + + if (e.key === 'ArrowUp') { + e.preventDefault() + setOpen(true) + if (visibleSuggestions.length > 0) { + setHighlightedIndex((current) => + current < 0 ? visibleSuggestions.length - 1 : Math.max(current - 1, 0) + ) + } + return + } + + if (e.key === 'Enter' && open && highlightedIndex >= 0) { + const highlightedFont = visibleSuggestions[highlightedIndex] + if (highlightedFont) { + e.preventDefault() + commitValue(highlightedFont) + } + } + }} + placeholder={placeholder} + className="pr-18" + role="combobox" + aria-autocomplete="list" + aria-expanded={open} + aria-controls={listboxId} + aria-activedescendant={ + open && highlightedIndex >= 0 + ? `${listboxId}-option-${highlightedIndex}` + : undefined + } + /> +
+ {query ? ( + + ) : null} + +
+
+ + + {/* Why: portal the dropdown outside the settings section — an in-flow + absolute panel makes the highlighted option's scrollIntoView scroll + the whole settings pane, pushing the section content out of view. */} + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + onInteractOutside={(e) => { + // Why: the input and its clear/toggle buttons are the anchor, not + // the content, so Radix would otherwise dismiss on every click there. + if (rootRef.current?.contains(e.target as Node)) { + e.preventDefault() + } + }} + > + 8 ? 'h-64' : undefined} + style={popoverAvailableHeightStyle} + viewportProps={{ style: popoverAvailableHeightStyle }} + >
{visibleSuggestions.length > 0 ? ( visibleSuggestions.map((font, index) => ( @@ -846,8 +872,8 @@ export function FontAutocomplete({ )}
-
- ) : null} + + ) } From 6aed5c112240d71f1df0276bf73c7f1cafceb999 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:01:06 -0700 Subject: [PATCH 023/170] Gate the Gemini GPU fallback on pane-owner evidence instead of raw title text (title evidence plan, PR 1) (#7458) Co-authored-by: Orca --- .../native-chat/native-chat-runtime-owner.ts | 3 +- .../native-chat-session-transport.test.ts | 14 +- .../use-native-chat-live-session.test.ts | 15 +- .../terminal-pane/pty-connection.test.ts | 215 ++++++++++++++++++ .../terminal-pane/pty-connection.ts | 64 ++++-- .../terminal-renderer-policy.test.ts | 185 +++++++++++++++ .../terminal-pane/terminal-renderer-policy.ts | 107 +++++++++ .../terminal-title-evidence.test.ts | 56 +++++ .../terminal-pane/terminal-title-evidence.ts | 57 +++++ 9 files changed, 694 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-title-evidence.ts diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts index b01ae9d8fda..02aaae1a6f3 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-owner.ts @@ -5,7 +5,8 @@ import { import type { AppState } from '@/store/types' import { findTerminalTabWorktreeId } from './native-chat-file-link' -export type NativeChatRuntimeOwnerState = Pick & WorktreeRuntimeOwnerState +export type NativeChatRuntimeOwnerState = Pick & + WorktreeRuntimeOwnerState /** * The runtime owner id for a Native Chat pane, as a primitive — non-null only for diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts index 0a39178a18f..324cdd48248 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.test.ts @@ -20,7 +20,13 @@ const runtimeEnvironmentsSubscribe = vi.fn() const ENV = 'env-1' function message(id: string): NativeChatMessage { - return { id, role: 'assistant', blocks: [{ type: 'text', text: id }], timestamp: 1, source: 'transcript' } + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text: id }], + timestamp: 1, + source: 'transcript' + } } function okEnvelope(result: unknown): { ok: true; result: unknown } { @@ -224,7 +230,11 @@ describe('runtime readSession error mapping', () => { expect( toRuntimeNativeChatErrorMessage( - new RuntimeRpcCallError({ id: 'r-1', ok: false, error: { code: 'method_not_found', message: 'x' } }) + new RuntimeRpcCallError({ + id: 'r-1', + ok: false, + error: { code: 'method_not_found', message: 'x' } + }) ) ).toEqual(tooOld) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index 31a0c5b3a97..c2b6e5d7e1b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -49,7 +49,13 @@ import { } from './use-native-chat-live-session' function assistant(id: string, text: string): NativeChatMessage { - return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 2, source: 'transcript' } + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 2, + source: 'transcript' + } } function user(id: string, text: string): NativeChatMessage { @@ -176,7 +182,12 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(transportFactory).toHaveBeenCalledWith('env-1') const transport = getMockTransport('env-1') - expect(transport.readSession).toHaveBeenCalledWith(AGENT, SESSION, expect.any(Number), undefined) + expect(transport.readSession).toHaveBeenCalledWith( + AGENT, + SESSION, + expect.any(Number), + undefined + ) expect(transport.subscribe).toHaveBeenCalledOnce() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7d1874e131f..aed0f383f15 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -1007,6 +1007,221 @@ describe('connectPanePty', () => { }) }) + it('drives runtime title, tab title, and renderer policy from one title decision', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-gemini') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // Display/runtime/tab title and the GPU gate all come from the same decision. + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '✦ Gemini CLI') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '✦ Gemini CLI') + // Genuine Gemini under the default `auto` setting takes the DOM fallback. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('keeps GPU enabled when a pane-scoped OMP owner emits a Gemini-looking title', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'omp', + state: 'working', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-omp-gemini-cwd') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // Pane-scoped owner evidence outranks the raw title, so the fallback cannot fire. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, true) + expect(manager.setPaneGpuRendering).not.toHaveBeenCalledWith(1, false) + }) + + it('does not let one split pane title change another pane GPU state', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane1 = createPane(1) + const pane2 = createPane(2) + const transport1 = createMockTransport('pty-split-1') + const transport2 = createMockTransport('pty-split-2') + transportFactoryQueue.push(transport1, transport2) + const manager = createManager(2, 1) + + connectPanePty(pane1 as never, manager as never, createDeps() as never) + connectPanePty(pane2 as never, manager as never, createDeps() as never) + await flushAsyncTicks() + const onTitleChange1 = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange1) { + throw new Error('missing title callback for split pane 1') + } + onTitleChange1('✦ Gemini CLI', '✦ Gemini CLI') + + const gpuCalls = manager.setPaneGpuRendering.mock.calls as [number, boolean][] + expect(gpuCalls.some(([paneId]) => paneId === 1)).toBe(true) + expect(gpuCalls.every(([paneId]) => paneId !== 2)).toBe(true) + }) + + it('DOM-gates a genuine Gemini split pane even when the tab launched a non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + // The shared tab.launchAgent must not veto the renderer for a sibling pane. + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }] + } + } as StoreState + const pane1 = createPane(1) + const pane2 = createPane(2) + const transport1 = createMockTransport('pty-split-owner-1') + const transport2 = createMockTransport('pty-split-owner-2') + transportFactoryQueue.push(transport1, transport2) + const manager = createManager(2, 2) + + connectPanePty(pane1 as never, manager as never, createDeps() as never) + connectPanePty(pane2 as never, manager as never, createDeps() as never) + await flushAsyncTicks() + const onTitleChange2 = createdTransportOptions[1]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange2) { + throw new Error('missing title callback for split pane 2') + } + onTitleChange2('✦ Gemini CLI', '✦ Gemini CLI') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(2, false) + }) + + it('DOM-gates a genuine Gemini title in a pane whose launch agent was non-Gemini', async () => { + const { connectPanePty } = await import('./pty-connection') + const pane = createPane(1) + const transport = createMockTransport('pty-reused-gemini') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + // Stale, never-cleared launch identity must not veto the renderer. + const deps = createDeps({ startup: { command: 'claude', launchAgent: 'claude' } }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('DOM-gates a genuine Gemini title when the only pane row is a done non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'claude', + state: 'done', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-reused-done-row') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // A `done` row is a leftover from a prior agent, so it must not veto. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + + it('DOM-gates a genuine Gemini title when the only pane row is a stale non-Gemini agent', async () => { + const { connectPanePty } = await import('./pty-connection') + const paneKey = makePaneKey('tab-1', LEAF_1) + const staleAt = Date.now() - 60 * 60 * 1000 + mockStoreState = { + ...mockStoreState, + agentStatusByPaneKey: { + [paneKey]: { + paneKey, + agentType: 'claude', + state: 'working', + prompt: '', + updatedAt: staleAt, + stateStartedAt: staleAt, + stateHistory: [] + } + } + } as StoreState + const pane = createPane(1) + const transport = createMockTransport('pty-reused-stale-row') + transportFactoryQueue.push(transport) + const manager = createManager(1, 1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + const onTitleChange = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!onTitleChange) { + throw new Error('missing title callback') + } + onTitleChange('✦ Gemini CLI', '✦ Gemini CLI') + + // A stale working row (older than AGENT_STATUS_STALE_AFTER_MS) must not veto. + expect(manager.setPaneGpuRendering).toHaveBeenCalledWith(1, false) + }) + it('normalizes after shell word deletion edits a typed command to omp', async () => { const { connectPanePty } = await import('./pty-connection') enableActiveRuntimeEnvironment() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 1dead4f1ca8..ffe958ddb27 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -3,12 +3,8 @@ import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' import type { ManagedPaneInternal } from '@/lib/pane-manager/pane-manager-types' import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' -import { - detectAgentStatusFromTitle, - agentTypeToIconAgent, - isGeminiTerminalTitle, - isClaudeAgent -} from '@/lib/agent-status' +import { detectAgentStatusFromTitle, agentTypeToIconAgent, isClaudeAgent } from '@/lib/agent-status' +import { resolvePaneTitleDecision } from './terminal-title-evidence' import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' @@ -1390,15 +1386,22 @@ export function connectPanePty( )?.title return runtimeTitle ?? tabTitle ?? null } - const hasFreshPaneAgentSurface = (): boolean => { - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const now = Date.now() - const entryIsFresh = - entry && + // Why: a pane-scoped explicit row only counts as current ownership evidence + // when it is fresh and not already `done` — a stale or completed row is a + // leftover from a prior agent that may no longer own the shell. + const isFreshActivePaneAgentEntry = ( + entry: AgentStatusEntry | undefined + ): entry is AgentStatusEntry => { + return ( + !!entry && typeof entry.updatedAt === 'number' && - now - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS - if (entryIsFresh && entry.state !== 'done') { + Date.now() - entry.updatedAt <= AGENT_STATUS_STALE_AFTER_MS && + entry.state !== 'done' + ) + } + const hasFreshPaneAgentSurface = (): boolean => { + const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] + if (isFreshActivePaneAgentEntry(entry)) { return true } const liveTitle = getLivePaneAgentTitle() @@ -1493,6 +1496,23 @@ export function connectPanePty( state.agentStatusByPaneKey[cacheKey]?.agentType ) } + // Why: the renderer veto (owner evidence beating a Gemini-looking title) must + // use only pane-scoped, CURRENT ownership. getAuthoritativePaneAgent leads + // with the tab-shared `tab.launchAgent` and a never-cleared + // `paneStartup.launchAgent`, which would let a sibling split pane or a reused + // pane keep WebGL for a genuine Gemini terminal (#7428 regression class). + // Launch identity is excluded, and the never-clearing startup seed + // (`paneStartup.initialAgentStatus`) too; a stale or `done` explicit row is + // ignored via the freshness predicate so a reused pane cannot inherit a prior + // agent's veto. Only live foreground command inference and a fresh, active + // hook row count. A genuine OMP/Pi pane stays protected owner-independently by + // the isPiAgentTitle guard inside isGeminiTerminalTitle. + const getPaneScopedRendererOwner = (): AgentType | undefined => { + const entry = useAppStore.getState().agentStatusByPaneKey[cacheKey] + return ( + commandInferredPaneAgent ?? (isFreshActivePaneAgentEntry(entry) ? entry.agentType : undefined) + ) + } const clearInferredInterruptWorkingTitle = (): void => { const state = useAppStore.getState() const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] @@ -2166,7 +2186,17 @@ export function connectPanePty( let allowInitialIdleCacheSeed = false const onTitleChange = (title: string, rawTitle: string): void => { - const paneTitle = normalizeCompatibleAgentTitleForOwner(title, getAuthoritativePaneAgent()) + // Why: one owner-aware decision drives the display label, the runtime/tab + // title, task-completion tracking, and the renderer gate, so raw title text + // can no longer disable GPU behind stronger owner evidence (#7428/#7447). + const decision = resolvePaneTitleDecision({ + normalizedTitle: title, + rawTitle, + displayOwnerAgentType: getAuthoritativePaneAgent(), + rendererOwnerAgentType: getPaneScopedRendererOwner(), + userGpuMode: useAppStore.getState().settings?.terminalGpuAcceleration ?? 'auto' + }) + const paneTitle = decision.displayTitle if ( shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, { paneKey: cacheKey, @@ -2176,10 +2206,10 @@ export function connectPanePty( ) { return } - manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) + manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle) if (syncAgentTaskCompleteTrackingEnabled()) { - agentCompletionCoordinator.observeTitle(rawTitle) + agentCompletionCoordinator.observeTitle(decision.rawTitle) } // Why: only the focused pane should drive the tab title — otherwise two // agents in split panes cause rapid title flickering as each emits OSC diff --git a/src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts new file mode 100644 index 00000000000..344e3df8193 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest' +import type { AgentType } from '../../../../shared/agent-status-types' +import { resolvePaneRendererPolicy } from './terminal-renderer-policy' + +const GEMINI_WORKING = '✦' + +describe('resolvePaneRendererPolicy', () => { + describe('user GPU modes', () => { + it('keeps the content gate open under `off` for non-Gemini panes', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'zsh', + ownerAgentType: undefined, + userGpuMode: 'off' + }) + // Why: the mode gate downstream forces DOM; the gate stays open so a later + // switch to auto/on can re-attach without waiting for a new title frame. + expect(decision).toEqual({ + gpuEnabled: true, + reason: 'user-setting', + confidence: 'authoritative' + }) + }) + + it('DOM-gates a genuine Gemini pane under `off`', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: 'gemini', + userGpuMode: 'off' + }) + expect(decision.gpuEnabled).toBe(false) + expect(decision.reason).toBe('user-setting') + }) + + it('forces GPU on under `on` even for a genuine Gemini pane', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: 'gemini', + userGpuMode: 'on' + }) + // Why: agent compatibility exclusions must not override an explicit on. + expect(decision).toEqual({ + gpuEnabled: true, + reason: 'user-setting', + confidence: 'authoritative' + }) + }) + + it('enables GPU under `auto` for an ordinary shell', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'bash', + ownerAgentType: undefined, + userGpuMode: 'auto' + }) + expect(decision).toEqual({ + gpuEnabled: true, + reason: 'capability', + confidence: 'authoritative' + }) + }) + }) + + describe('WebGL capability and context-loss containment', () => { + it('disables GPU under `on` when WebGL is unavailable', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'bash', + ownerAgentType: undefined, + userGpuMode: 'on', + webglUnavailable: true + }) + expect(decision).toEqual({ + gpuEnabled: false, + reason: 'capability', + confidence: 'authoritative' + }) + }) + + it('disables GPU under `on` inside context-loss containment', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'bash', + ownerAgentType: undefined, + userGpuMode: 'on', + inContextLossContainment: true + }) + expect(decision).toEqual({ + gpuEnabled: false, + reason: 'context-loss', + confidence: 'authoritative' + }) + }) + + it('disables GPU under `auto` inside context-loss containment', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'bash', + ownerAgentType: undefined, + userGpuMode: 'auto', + inContextLossContainment: true + }) + expect(decision.gpuEnabled).toBe(false) + expect(decision.reason).toBe('context-loss') + }) + }) + + describe('Gemini compatibility fallback under `auto`', () => { + it('DOM-gates a genuine Gemini title with no authoritative owner', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: undefined, + userGpuMode: 'auto' + }) + expect(decision).toEqual({ + gpuEnabled: false, + reason: 'agent-compatibility', + confidence: 'fallback' + }) + }) + + it('DOM-gates a genuine Gemini title with a Gemini owner at higher confidence', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: 'gemini', + userGpuMode: 'auto' + }) + expect(decision).toEqual({ + gpuEnabled: false, + reason: 'agent-compatibility', + confidence: 'authoritative' + }) + }) + + it('keeps GPU on when a non-Gemini owner emits a Gemini-looking title', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: 'omp', + userGpuMode: 'auto' + }) + // Why: OMP ownership is authoritative and outranks raw title text (#7428). + expect(decision.gpuEnabled).toBe(true) + expect(decision.reason).toBe('capability') + }) + + it('does not treat an `unknown` owner as an authoritative non-Gemini veto', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: `${GEMINI_WORKING} Gemini CLI`, + ownerAgentType: 'unknown', + userGpuMode: 'auto' + }) + expect(decision.gpuEnabled).toBe(false) + expect(decision.reason).toBe('agent-compatibility') + }) + }) + + describe('agent token text cannot flip renderer policy under authoritative owners', () => { + const AGENT_TOKENS = ['gemini', 'claude', 'codex', 'opencode', 'cursor', 'omp', 'pi'] as const + + for (const token of AGENT_TOKENS) { + it(`keeps GPU on when a title mentions "${token}" but owner is another agent`, () => { + const ownerAgentType: AgentType = token === 'claude' ? 'codex' : 'claude' + const decision = resolvePaneRendererPolicy({ + rawTitle: token, + ownerAgentType, + userGpuMode: 'auto' + }) + expect(decision.gpuEnabled).toBe(true) + }) + } + + it('keeps GPU on for an OMP owner whose title is exactly the Gemini token', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'gemini', + ownerAgentType: 'omp', + userGpuMode: 'auto' + }) + expect(decision.gpuEnabled).toBe(true) + }) + + it('keeps GPU on for a Pi owner whose title is exactly the Gemini token', () => { + const decision = resolvePaneRendererPolicy({ + rawTitle: 'gemini', + ownerAgentType: 'pi', + userGpuMode: 'auto' + }) + expect(decision.gpuEnabled).toBe(true) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts new file mode 100644 index 00000000000..f6d225dcd09 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-renderer-policy.ts @@ -0,0 +1,107 @@ +import { isGeminiTerminalTitle } from '@/lib/agent-status' +import type { AgentType } from '../../../../shared/agent-status-types' +import type { GlobalSettings } from '../../../../shared/types' + +export type TerminalGpuAccelerationMode = GlobalSettings['terminalGpuAcceleration'] + +/** + * The resolved renderer decision for a pane. `gpuEnabled` is the content-compat + * gate passed to `setPaneGpuRendering`; the user-setting mode gate and WebGL + * capability/context-loss latches are still applied downstream by the pane + * manager, so this decision never has to force WebGL on when it is unavailable. + */ +export type RendererPolicyDecision = { + gpuEnabled: boolean + reason: 'user-setting' | 'capability' | 'context-loss' | 'agent-compatibility' + confidence: 'authoritative' | 'fallback' +} + +export type ResolvePaneRendererPolicyInput = { + rawTitle: string | null + ownerAgentType: AgentType | null | undefined + userGpuMode: TerminalGpuAccelerationMode + /** Set when the pane cannot obtain a WebGL context at all. */ + webglUnavailable?: boolean + /** Set when the pane is inside GPU crash/context-loss containment. */ + inContextLossContainment?: boolean +} + +// Why: an authoritative non-Gemini owner (OMP, Pi, Claude, a shell, …) outranks +// raw title text. 'unknown' is not authoritative, so it does not veto the +// title-derived Gemini fallback. +function isKnownNonGeminiOwner(ownerAgentType: AgentType | null | undefined): boolean { + return ( + typeof ownerAgentType === 'string' && + ownerAgentType !== '' && + ownerAgentType !== 'gemini' && + ownerAgentType !== 'unknown' + ) +} + +type GeminiCompatFallback = { + disable: boolean + confidence: 'authoritative' | 'fallback' +} + +// Why: keep the existing title-driven Gemini fallback, but let owner evidence +// veto it so a Pi/OMP pane whose cwd or session text mentions gemini is never +// forced onto the DOM renderer. +function resolveGeminiCompatFallback( + rawTitle: string | null, + ownerAgentType: AgentType | null | undefined +): GeminiCompatFallback { + if (!isGeminiTerminalTitle(rawTitle ?? '')) { + return { disable: false, confidence: 'authoritative' } + } + if (isKnownNonGeminiOwner(ownerAgentType)) { + return { disable: false, confidence: 'authoritative' } + } + return { disable: true, confidence: ownerAgentType === 'gemini' ? 'authoritative' : 'fallback' } +} + +/** + * Resolves the pane renderer (WebGL vs DOM content gate) from the user GPU + * setting, WebGL capability/context-loss state, and owner/title evidence. + * + * Precedence: user `off` keeps the effective renderer on DOM downstream while + * leaving the content gate open for a later mode switch; WebGL + * unavailable/context-loss force DOM; explicit `on` keeps GPU regardless of + * agent compatibility; `auto` applies the Gemini compatibility fallback only + * when owner evidence does not attribute the pane to another agent/shell. + */ +export function resolvePaneRendererPolicy( + input: ResolvePaneRendererPolicyInput +): RendererPolicyDecision { + const { rawTitle, ownerAgentType, userGpuMode } = input + + if (userGpuMode === 'off') { + // Why: the user-setting mode gate downstream already forces DOM. Mirror the + // title/owner content gate so a genuine Gemini pane stays DOM-gated while + // other panes keep the gate open for a later switch to `auto`/`on`. + const fallback = resolveGeminiCompatFallback(rawTitle, ownerAgentType) + // Why: carry the fallback's own confidence so identical inputs report the + // same confidence whether the effective mode is `off` or `auto`. + return { + gpuEnabled: !fallback.disable, + reason: 'user-setting', + confidence: fallback.confidence + } + } + + if (input.inContextLossContainment) { + return { gpuEnabled: false, reason: 'context-loss', confidence: 'authoritative' } + } + if (input.webglUnavailable) { + return { gpuEnabled: false, reason: 'capability', confidence: 'authoritative' } + } + + if (userGpuMode === 'on') { + return { gpuEnabled: true, reason: 'user-setting', confidence: 'authoritative' } + } + + const fallback = resolveGeminiCompatFallback(rawTitle, ownerAgentType) + if (fallback.disable) { + return { gpuEnabled: false, reason: 'agent-compatibility', confidence: fallback.confidence } + } + return { gpuEnabled: true, reason: 'capability', confidence: 'authoritative' } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts new file mode 100644 index 00000000000..960c354f509 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-evidence.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { resolvePaneDisplayTitle, resolvePaneTitleDecision } from './terminal-title-evidence' + +describe('resolvePaneDisplayTitle', () => { + it('normalizes a Pi-compatible title to the resolved OMP owner', () => { + expect(resolvePaneDisplayTitle('Pi ready', 'omp')).toBe('OMP ready') + }) + + it('passes an unowned title through unchanged', () => { + expect(resolvePaneDisplayTitle('bash', undefined)).toBe('bash') + }) +}) + +describe('resolvePaneTitleDecision', () => { + it('derives the display label and the renderer veto from a pane-scoped OMP owner', () => { + const decision = resolvePaneTitleDecision({ + normalizedTitle: 'Pi ready', + rawTitle: '✦ Gemini CLI', + displayOwnerAgentType: 'omp', + rendererOwnerAgentType: 'omp', + userGpuMode: 'auto' + }) + expect(decision.displayTitle).toBe('OMP ready') + expect(decision.rawTitle).toBe('✦ Gemini CLI') + // Why: the OMP owner renames the label and vetoes the Gemini glyph fallback. + expect(decision.rendererPolicy.gpuEnabled).toBe(true) + }) + + it('uses the renderer owner, not the display owner, for the GPU veto', () => { + const decision = resolvePaneTitleDecision({ + normalizedTitle: 'Pi ready', + rawTitle: '✦ Gemini CLI', + // Display label follows the sticky/tab-scoped owner, but the renderer veto + // sees no current pane-scoped owner, so the genuine Gemini pane goes DOM. + displayOwnerAgentType: 'omp', + rendererOwnerAgentType: undefined, + userGpuMode: 'auto' + }) + expect(decision.displayTitle).toBe('OMP ready') + expect(decision.rendererPolicy.gpuEnabled).toBe(false) + expect(decision.rendererPolicy.reason).toBe('agent-compatibility') + }) + + it('DOM-gates a genuine Gemini pane while preserving its raw title', () => { + const decision = resolvePaneTitleDecision({ + normalizedTitle: '✦ Gemini CLI', + rawTitle: '✦ Gemini CLI', + displayOwnerAgentType: 'gemini', + rendererOwnerAgentType: 'gemini', + userGpuMode: 'auto' + }) + expect(decision.rawTitle).toBe('✦ Gemini CLI') + expect(decision.rendererPolicy.gpuEnabled).toBe(false) + expect(decision.rendererPolicy.reason).toBe('agent-compatibility') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts b/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts new file mode 100644 index 00000000000..f89eb6bbeb5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-evidence.ts @@ -0,0 +1,57 @@ +import { normalizeCompatibleAgentTitleForOwner } from '../../../../shared/agent-title-owner' +import type { AgentType } from '../../../../shared/agent-status-types' +import { + resolvePaneRendererPolicy, + type RendererPolicyDecision, + type TerminalGpuAccelerationMode +} from './terminal-renderer-policy' + +/** + * Owner-aware display label. Wraps the compatible-owner title normalization so + * the display label follows the resolved owner rather than raw wrapper text. + */ +export function resolvePaneDisplayTitle( + title: string, + ownerAgentType: AgentType | null | undefined +): string { + return normalizeCompatibleAgentTitleForOwner(title, ownerAgentType) +} + +/** + * The resolved decision for one OSC title frame: a single owner-aware display + * label plus the renderer policy, so `updateTabTitle`, `setRuntimePaneTitle`, + * task-completion tracking, and the GPU gate all consume one interpretation. + */ +export type PaneTitleDecision = { + displayTitle: string + rawTitle: string + rendererPolicy: RendererPolicyDecision +} + +export type ResolvePaneTitleDecisionInput = { + /** Normalized title from the transport (may already be display-shaped). */ + normalizedTitle: string + rawTitle: string + /** Owner used for the display label — may include sticky/tab-scoped launch + * identity, which is correct for the visible label. */ + displayOwnerAgentType: AgentType | null | undefined + /** Owner used for the renderer veto — must be pane-scoped and current so a + * sibling/reused pane's launch identity cannot keep GPU for a genuine + * Gemini pane. */ + rendererOwnerAgentType: AgentType | null | undefined + userGpuMode: TerminalGpuAccelerationMode + webglUnavailable?: boolean + inContextLossContainment?: boolean +} + +export function resolvePaneTitleDecision(input: ResolvePaneTitleDecisionInput): PaneTitleDecision { + const displayTitle = resolvePaneDisplayTitle(input.normalizedTitle, input.displayOwnerAgentType) + const rendererPolicy = resolvePaneRendererPolicy({ + rawTitle: input.rawTitle, + ownerAgentType: input.rendererOwnerAgentType, + userGpuMode: input.userGpuMode, + webglUnavailable: input.webglUnavailable, + inContextLossContainment: input.inContextLossContainment + }) + return { displayTitle, rawTitle: input.rawTitle, rendererPolicy } +} From 377bde351c7b41aeb5419c1a4f36d90efe645a82 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:11:48 -0700 Subject: [PATCH 024/170] Bridge WSL Codex sessions into runtime home (#7477) --- .../runtime-home-service.test.ts | 128 ++++++++++++ .../codex-accounts/runtime-home-service.ts | 34 +++- .../codex/wsl-codex-session-bridge.test.ts | 168 ++++++++++++++++ src/main/codex/wsl-codex-session-bridge.ts | 185 ++++++++++++++++++ 4 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 src/main/codex/wsl-codex-session-bridge.test.ts create mode 100644 src/main/codex/wsl-codex-session-bridge.ts diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 0f6d968cc52..9a6b8566f57 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -484,6 +484,134 @@ describe('CodexRuntimeHomeService', () => { } }) + it('starts WSL session bridging after materializing the WSL launch home', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'wsl-home') + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => 'Ubuntu', + getWslHome: () => wslHome + })) + const store = createStore( + createSettings({ + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Ubuntu: null } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( + wslRuntimeHomePath + ) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledTimes(1) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Ubuntu', + systemCodexHomePath: join(wslHome, '.codex'), + managedCodexHomePath: wslRuntimeHomePath + }) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + + it('starts WSL session bridging for the distro used by the materialized runtime home', async () => { + const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + const startWslCodexSessionBridgeInBackground = vi.fn(() => Promise.resolve()) + vi.doMock('../codex/wsl-codex-session-bridge', () => ({ + startWslCodexSessionBridgeInBackground + })) + const wslHome = join(testState.userDataDir, 'debian-wsl-home') + const wslRuntimeHomePath = join( + wslHome, + '.local', + 'share', + 'orca', + 'codex-runtime-home', + 'home' + ) + vi.doMock('../wsl', () => ({ + getDefaultWslDistro: () => null, + getWslHome: (distro: string) => (distro === 'Debian' ? wslHome : null) + })) + vi.doMock('../../shared/wsl-paths', () => ({ + parseWslUncPath: (candidate: string) => + candidate === wslRuntimeHomePath + ? { + distro: 'Debian', + linuxPath: '/home/alice/.local/share/orca/codex-runtime-home/home' + } + : null + })) + const managedHomePath = createManagedAuth( + testState.userDataDir, + 'debian-account', + '{"account":"debian"}\n' + ) + const store = createStore( + createSettings({ + codexManagedAccounts: [ + { + id: 'debian-account', + email: 'debian@example.com', + managedHomePath, + managedHomeRuntime: 'wsl', + wslDistro: 'Debian', + wslLinuxHomePath: '/home/alice/.local/share/orca/codex-accounts/debian/home', + providerAccountId: null, + workspaceLabel: null, + workspaceAccountId: null, + createdAt: 1, + updatedAt: 1, + lastAuthenticatedAt: 1 + } + ], + activeCodexManagedAccountId: null, + activeCodexManagedAccountIdsByRuntime: { host: null, wsl: { Debian: 'debian-account' } } + }) + ) + + try { + const { CodexRuntimeHomeService } = await import('./runtime-home-service') + const service = new CodexRuntimeHomeService(store as never) + + expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: null })).toBe( + wslRuntimeHomePath + ) + expect(startWslCodexSessionBridgeInBackground).toHaveBeenCalledWith({ + distro: 'Debian', + systemCodexHomePath: join(wslHome, '.codex'), + managedCodexHomePath: wslRuntimeHomePath + }) + } finally { + vi.doUnmock('../codex/wsl-codex-session-bridge') + vi.doUnmock('../wsl') + vi.doUnmock('../../shared/wsl-paths') + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) + it('restores the system-default snapshot when no managed account is selected', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 4d5d8e4c645..3d2b0670354 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -40,6 +40,7 @@ import { syncSystemCodexResourcesIntoManagedHome } from '../codex/codex-home-paths' import { startSystemCodexSessionBridgeInBackground } from '../codex/codex-session-bridge' +import { startWslCodexSessionBridgeInBackground } from '../codex/wsl-codex-session-bridge' import { prepareSystemConfigForFreshRuntimeMirror, syncSystemConfigIntoManagedCodexHome @@ -134,10 +135,11 @@ export class CodexRuntimeHomeService { prepareForCodexLaunch(target?: CodexAccountSelectionTarget): string | null { if (target?.runtime === 'wsl') { const wslTarget = this.resolveWslDefaultTarget(target) - return ( + const runtimeHomePath = this.syncWslRuntimeForCurrentSelection(wslTarget) ?? this.getWslSystemCodexHomePath(wslTarget) - ) + this.startWslSessionBridgeForLaunch(wslTarget, runtimeHomePath) + return runtimeHomePath } this.syncForCurrentSelection() syncSystemCodexResourcesIntoManagedHome() @@ -148,6 +150,34 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } + private startWslSessionBridgeForLaunch( + target: CodexAccountSelectionTarget, + runtimeHomePath: string | null + ): void { + if (process.platform !== 'win32' || !runtimeHomePath) { + return + } + const runtimeHomeWsl = parseWslUncPath(runtimeHomePath) + const distro = target.wslDistro?.trim() || runtimeHomeWsl?.distro || getDefaultWslDistro() + if (!distro) { + return + } + const systemCodexHomePath = this.getWslSystemCodexHomePath({ + runtime: 'wsl', + wslDistro: distro + }) + if (!systemCodexHomePath || systemCodexHomePath === runtimeHomePath) { + return + } + // Why: WSL history must be hardlinked inside the distro; host-side links + // cannot bridge Windows and WSL filesystems in a resume-visible way. + void startWslCodexSessionBridgeInBackground({ + distro, + systemCodexHomePath, + managedCodexHomePath: runtimeHomePath + }) + } + getHostRuntimeHomePath(): string { return this.getRuntimeHomePath() } diff --git a/src/main/codex/wsl-codex-session-bridge.test.ts b/src/main/codex/wsl-codex-session-bridge.test.ts new file mode 100644 index 00000000000..c4218913a5b --- /dev/null +++ b/src/main/codex/wsl-codex-session-bridge.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChildProcess } from 'node:child_process' + +const { execFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: execFileMock +})) + +import { + buildWslCodexSessionBridgeShellCommand, + resolveWslCodexSessionBridgeLinuxPaths, + startWslCodexSessionBridgeInBackground, + syncWslCodexSessionsIntoManagedHome +} from './wsl-codex-session-bridge' + +function mockExecFileSuccess(stdout = '{"scannedFiles":2,"linkedFiles":1}\n'): void { + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ): ChildProcess => { + callback(null, stdout, '') + return {} as ChildProcess + } + ) +} + +beforeEach(() => { + execFileMock.mockReset() +}) + +describe('syncWslCodexSessionsIntoManagedHome', () => { + it('runs a WSL hardlink bridge from the WSL system sessions into the managed home', async () => { + mockExecFileSuccess() + + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 2, linkedFiles: 1 }) + expect(execFileMock).toHaveBeenCalledTimes(1) + const firstCall = execFileMock.mock.calls[0] + expect(firstCall).toBeDefined() + const [command, args, options] = firstCall as [ + string, + string[], + { timeout?: number; windowsHide?: boolean } + ] + expect(command).toBe('wsl.exe') + expect(args.slice(0, 5)).toEqual(['-d', 'Ubuntu', '--', 'bash', '-lc']) + expect(args).toHaveLength(6) + expect(options.timeout).toBe(30_000) + expect(options.windowsHide).toBe(true) + + const shellCommand = args[5] + expect(shellCommand).toContain("source_sessions_root='/home/alice/.codex/sessions'") + expect(shellCommand).toContain( + "managed_sessions_root='/home/alice/.local/share/orca/codex-runtime-home/home/sessions'" + ) + expect(shellCommand).toContain(`find "\\$source_sessions_root" -type f -name '*.jsonl' -print0`) + expect(shellCommand).toContain('ln -- "\\$source_file" "\\$target_file"') + expect(shellCommand).toContain('if [ -e "\\$target_file" ] || [ -L "\\$target_file" ]; then') + expect(shellCommand).not.toContain('ln -s') + expect(shellCommand).not.toContain('cp ') + expect(shellCommand).not.toContain('sqlite') + }) + + it('does not invoke WSL when paths are not resolvable inside the distro', async () => { + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: 'C:\\Users\\alice\\.codex', + managedCodexHomePath: 'C:\\Users\\alice\\AppData\\Roaming\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 0, linkedFiles: 0 }) + expect(execFileMock).not.toHaveBeenCalled() + }) + + it('parses the summary after WSL profile stdout', async () => { + mockExecFileSuccess('Welcome to Ubuntu\nprofile output\n{"scannedFiles":4,"linkedFiles":3}\n') + + const summary = await syncWslCodexSessionsIntoManagedHome({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + + expect(summary).toEqual({ scannedFiles: 4, linkedFiles: 3 }) + }) + + it('coalesces duplicate background bridges for the same WSL target', async () => { + mockExecFileSuccess() + const target = { + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + } + + const firstTask = startWslCodexSessionBridgeInBackground(target) + const secondTask = startWslCodexSessionBridgeInBackground(target) + + expect(firstTask).toBe(secondTask) + await firstTask + expect(execFileMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('resolveWslCodexSessionBridgeLinuxPaths', () => { + it('requires both homes to belong to the requested distro', () => { + expect( + resolveWslCodexSessionBridgeLinuxPaths({ + distro: 'Ubuntu', + systemCodexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex', + managedCodexHomePath: + '\\\\wsl.localhost\\Debian\\home\\alice\\.local\\share\\orca\\codex-runtime-home\\home' + }) + ).toBeNull() + }) + + it('accepts Linux paths for direct script construction tests', () => { + expect( + resolveWslCodexSessionBridgeLinuxPaths({ + distro: 'Ubuntu', + systemCodexHomePath: '/home/alice/.codex', + managedCodexHomePath: '/home/alice/.local/share/orca/codex-runtime-home/home' + }) + ).toEqual({ + systemSessionsRoot: '/home/alice/.codex/sessions', + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + }) +}) + +describe('buildWslCodexSessionBridgeShellCommand', () => { + it('only targets JSONL session files under sessions', () => { + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: "/home/alice/.codex/sessions with 'quote'", + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + + expect(shellCommand).toContain( + `source_sessions_root='/home/alice/.codex/sessions with '\\''quote'\\'''` + ) + expect(shellCommand).toContain(`-name '*.jsonl'`) + expect(shellCommand).not.toContain('.sqlite') + }) + + it('escapes Linux-side shell variable expansion for wsl.exe argv', () => { + const shellCommand = buildWslCodexSessionBridgeShellCommand({ + systemSessionsRoot: '/home/alice/.codex/sessions', + managedSessionsRoot: '/home/alice/.local/share/orca/codex-runtime-home/home/sessions' + }) + + expect(shellCommand).toContain('\\$source_sessions_root') + expect(shellCommand).toContain('\\$source_file') + expect(shellCommand).toContain('\\$((scanned_files + 1))') + }) +}) diff --git a/src/main/codex/wsl-codex-session-bridge.ts b/src/main/codex/wsl-codex-session-bridge.ts new file mode 100644 index 00000000000..e83abfadd95 --- /dev/null +++ b/src/main/codex/wsl-codex-session-bridge.ts @@ -0,0 +1,185 @@ +import { execFile } from 'node:child_process' +import { posix as pathPosix } from 'node:path' +import { escapeWslShCommandForWindows } from '../../shared/wsl-login-shell-command' +import { parseWslUncPath } from '../../shared/wsl-paths' + +export type WslCodexSessionBridgeTarget = { + distro: string + systemCodexHomePath: string + managedCodexHomePath: string +} + +export type WslCodexSessionBridgeLinuxPaths = { + systemSessionsRoot: string + managedSessionsRoot: string +} + +export type WslCodexSessionBridgeSummary = { + scannedFiles: number + linkedFiles: number +} + +const emptySummary: WslCodexSessionBridgeSummary = { scannedFiles: 0, linkedFiles: 0 } +const backgroundWslSessionBridgeTasks = new Map>() +const WSL_SESSION_BRIDGE_TIMEOUT_MS = 30_000 + +export function startWslCodexSessionBridgeInBackground( + target: WslCodexSessionBridgeTarget +): Promise { + const taskKey = getWslSessionBridgeTaskKey(target) + const existingTask = backgroundWslSessionBridgeTasks.get(taskKey) + if (existingTask) { + return existingTask + } + + const task = syncWslCodexSessionsIntoManagedHome(target) + .catch((error: unknown) => { + console.warn('[codex-session-bridge] Background WSL session bridge failed:', error) + }) + .then(() => undefined) + backgroundWslSessionBridgeTasks.set(taskKey, task) + void task.finally(() => { + if (backgroundWslSessionBridgeTasks.get(taskKey) === task) { + backgroundWslSessionBridgeTasks.delete(taskKey) + } + }) + return task +} + +export async function syncWslCodexSessionsIntoManagedHome( + target: WslCodexSessionBridgeTarget +): Promise { + const paths = resolveWslCodexSessionBridgeLinuxPaths(target) + if (!paths) { + return emptySummary + } + + const stdout = await execFileUtf8('wsl.exe', [ + '-d', + target.distro, + '--', + 'bash', + '-lc', + buildWslCodexSessionBridgeShellCommand(paths) + ]) + return parseWslSessionBridgeSummary(stdout) +} + +export function resolveWslCodexSessionBridgeLinuxPaths( + target: WslCodexSessionBridgeTarget +): WslCodexSessionBridgeLinuxPaths | null { + const systemHomePath = getLinuxPathForWslDistro(target.systemCodexHomePath, target.distro) + const managedHomePath = getLinuxPathForWslDistro(target.managedCodexHomePath, target.distro) + if (!systemHomePath || !managedHomePath) { + return null + } + + return { + systemSessionsRoot: joinLinuxPath(systemHomePath, 'sessions'), + managedSessionsRoot: joinLinuxPath(managedHomePath, 'sessions') + } +} + +export function buildWslCodexSessionBridgeShellCommand( + paths: WslCodexSessionBridgeLinuxPaths +): string { + const shellCommand = [ + 'set -u', + `source_sessions_root=${quoteBashString(paths.systemSessionsRoot)}`, + `managed_sessions_root=${quoteBashString(paths.managedSessionsRoot)}`, + 'scanned_files=0', + 'linked_files=0', + 'if [ ! -d "$source_sessions_root" ]; then', + ` printf '{"scannedFiles":0,"linkedFiles":0}\\n'`, + ' exit 0', + 'fi', + "while IFS= read -r -d '' source_file; do", + ' scanned_files=$((scanned_files + 1))', + ' relative_path=${source_file#"$source_sessions_root"/}', + ' target_file="$managed_sessions_root/$relative_path"', + ' if [ -e "$target_file" ] || [ -L "$target_file" ]; then', + ' continue', + ' fi', + ' target_dir=${target_file%/*}', + ' mkdir -p -- "$target_dir" || continue', + // Why: Codex resume ignores symlinked JSONL, so WSL links must be + // Linux hardlinks created inside the distro filesystem. + ' if ln -- "$source_file" "$target_file"; then', + ' linked_files=$((linked_files + 1))', + ' fi', + `done < <(find "$source_sessions_root" -type f -name '*.jsonl' -print0 2>/dev/null)`, + `printf '{"scannedFiles":%s,"linkedFiles":%s}\\n' "$scanned_files" "$linked_files"` + ].join('\n') + return escapeWslShCommandForWindows(shellCommand) +} + +function getWslSessionBridgeTaskKey(target: WslCodexSessionBridgeTarget): string { + return [target.distro, target.systemCodexHomePath, target.managedCodexHomePath].join('\0') +} + +function getLinuxPathForWslDistro(path: string, distro: string): string | null { + const wslPath = parseWslUncPath(path) + if (wslPath) { + return wslDistroNamesMatch(wslPath.distro, distro) ? wslPath.linuxPath : null + } + return path.startsWith('/') ? path : null +} + +function wslDistroNamesMatch(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase() +} + +function joinLinuxPath(basePath: string, ...segments: string[]): string { + return pathPosix.join(basePath, ...segments) +} + +function quoteBashString(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} + +function execFileUtf8(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + command, + args, + { + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + timeout: WSL_SESSION_BRIDGE_TIMEOUT_MS, + windowsHide: true + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(stdout) + } + ) + }) +} + +function parseWslSessionBridgeSummary(stdout: string): WslCodexSessionBridgeSummary { + try { + // Why: login/profile scripts may write stdout before the bridge summary. + const summaryLine = + stdout + .split(/\r?\n/) + .findLast((line) => line.trim().length > 0) + ?.trim() ?? '' + const parsed: unknown = JSON.parse(summaryLine) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return emptySummary + } + const summary = parsed as Record + if (typeof summary.scannedFiles !== 'number' || typeof summary.linkedFiles !== 'number') { + return emptySummary + } + return { + scannedFiles: summary.scannedFiles, + linkedFiles: summary.linkedFiles + } + } catch { + return emptySummary + } +} From 417723411e4deafe73cb557210d671c0c3d4c8cc Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:12:56 -0700 Subject: [PATCH 025/170] perf(source-control): stop gh rate-limit storms and idle git-status spawn churn (#7595) Co-authored-by: Orca --- .gitignore | 1 + package.json | 1 + .../main-thread-churn-probe.test.ts | 74 ++++ .../diagnostics/main-thread-churn-probe.ts | 185 ++++++++ src/main/git/gh-rate-limit-breaker.test.ts | 93 ++++ src/main/git/gh-rate-limit-breaker.ts | 166 +++++++ .../git/runner-gh-rate-limit-breaker.test.ts | 95 ++++ src/main/git/runner.ts | 55 ++- .../git/status-upstream-probe-churn.test.ts | 84 ++++ src/main/git/status.ts | 66 ++- src/main/github/client-issue-source.test.ts | 3 +- src/main/github/client-work-items.test.ts | 39 +- src/main/github/client.ts | 70 ++- .../github/gh-cwd-repo-negative-cache.test.ts | 65 +++ src/main/github/gh-cwd-repo-negative-cache.ts | 83 ++++ src/main/github/gh-error-classification.ts | 14 +- src/main/github/rate-limit.ts | 77 ++++ src/main/index.ts | 4 + src/main/updater.check-failure.test.ts | 50 +++ src/main/updater.ts | 26 +- src/renderer/src/store/slices/github.ts | 6 + src/shared/git-effective-upstream.ts | 29 +- tools/benchmarks/main-thread-jank-bench.mjs | 419 ++++++++++++++++++ 23 files changed, 1671 insertions(+), 34 deletions(-) create mode 100644 src/main/diagnostics/main-thread-churn-probe.test.ts create mode 100644 src/main/diagnostics/main-thread-churn-probe.ts create mode 100644 src/main/git/gh-rate-limit-breaker.test.ts create mode 100644 src/main/git/gh-rate-limit-breaker.ts create mode 100644 src/main/git/runner-gh-rate-limit-breaker.test.ts create mode 100644 src/main/github/gh-cwd-repo-negative-cache.test.ts create mode 100644 src/main/github/gh-cwd-repo-negative-cache.ts create mode 100644 tools/benchmarks/main-thread-jank-bench.mjs diff --git a/.gitignore b/.gitignore index 7cdf3374035..24595e4c708 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ coverage/ # Benchmark run output /tools/benchmarks/results/*.json +/.bench-fixtures/ # Temp tmp/ diff --git a/package.json b/package.json index 812acb357f0..ca14f3fdbbe 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "bench:idle-cpu": "pnpm run ensure:electron-runtime && node config/scripts/run-idle-cpu-benchmark.mjs", "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", + "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { diff --git a/src/main/diagnostics/main-thread-churn-probe.test.ts b/src/main/diagnostics/main-thread-churn-probe.test.ts new file mode 100644 index 00000000000..cfc159fc1fb --- /dev/null +++ b/src/main/diagnostics/main-thread-churn-probe.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + MAIN_THREAD_DIAGNOSTICS_ENV, + classifySubprocessCommand, + drainSubprocessSpawnStats, + isMainThreadDiagnosticsEnabled, + recordSubprocessSpawn +} from './main-thread-churn-probe' + +afterEach(() => { + vi.unstubAllEnvs() + drainSubprocessSpawnStats() +}) + +describe('classifySubprocessCommand', () => { + it('uses the git subcommand, skipping global value flags', () => { + expect(classifySubprocessCommand('git', ['-C', '/repo', 'status', '--porcelain=v2'])).toBe( + 'git status' + ) + expect( + classifySubprocessCommand('git', ['-c', 'core.quotepath=off', 'rev-list', '--count']) + ).toBe('git rev-list') + expect(classifySubprocessCommand('git', ['--git-dir=/repo/.git', 'log', '--oneline'])).toBe( + 'git log' + ) + }) + + it('normalizes absolute paths and .exe suffixes', () => { + expect(classifySubprocessCommand('/usr/bin/git', ['status'])).toBe('git status') + expect(classifySubprocessCommand('C:\\Program Files\\Git\\git.exe', ['fetch'])).toBe( + 'git fetch' + ) + expect(classifySubprocessCommand('gh', ['api', 'rate_limit'])).toBe('gh api') + }) + + it('unwraps wsl.exe-routed commands', () => { + expect( + classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu', '--', 'git', 'status', '--porcelain']) + ).toBe('git status') + expect(classifySubprocessCommand('wsl.exe', ['-d', 'Ubuntu'])).toBe('wsl') + }) + + it('falls back to the binary name when no subcommand exists', () => { + expect(classifySubprocessCommand('rg', ['--files'])).toBe('rg') + }) + + it('never treats positionals or git-flag values as subcommands for non-subcommand CLIs', () => { + // rg's -C takes a number; it must not be consumed as a git-style flag + // value, and "3"/"pattern" must not become fake subcommand buckets. + expect(classifySubprocessCommand('rg', ['-C', '3', 'pattern'])).toBe('rg') + expect(classifySubprocessCommand('node', ['script.js'])).toBe('node') + }) +}) + +describe('recordSubprocessSpawn', () => { + it('is a no-op when the diagnostics env var is unset', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '') + expect(isMainThreadDiagnosticsEnabled()).toBe(false) + recordSubprocessSpawn('git', ['status'], 1) + expect(drainSubprocessSpawnStats()).toEqual({}) + }) + + it('aggregates count and block time per command, and drain resets', () => { + vi.stubEnv(MAIN_THREAD_DIAGNOSTICS_ENV, '1') + recordSubprocessSpawn('git', ['-C', '/repo', 'status'], 2) + recordSubprocessSpawn('/usr/bin/git', ['status', '--porcelain=v2'], 4) + recordSubprocessSpawn('git', ['rev-list', '--count'], 1.5) + expect(drainSubprocessSpawnStats()).toEqual({ + 'git status': { count: 2, blockMsTotal: 6, blockMsMax: 4 }, + 'git rev-list': { count: 1, blockMsTotal: 1.5, blockMsMax: 1.5 } + }) + expect(drainSubprocessSpawnStats()).toEqual({}) + }) +}) diff --git a/src/main/diagnostics/main-thread-churn-probe.ts b/src/main/diagnostics/main-thread-churn-probe.ts new file mode 100644 index 00000000000..f4fe900cf8c --- /dev/null +++ b/src/main/diagnostics/main-thread-churn-probe.ts @@ -0,0 +1,185 @@ +import { writeStartupDiagnosticLine } from '../startup/startup-diagnostics' + +export const MAIN_THREAD_DIAGNOSTICS_ENV = 'ORCA_MAIN_THREAD_DIAGNOSTICS' + +// Why: 25ms mirrors event-loop-stall-probe — a timer that fires late by N ms +// proves the main thread was blocked for N ms, which is the direct in-process +// measurement of the macOS "Performance Diagnostics" main-thread warnings +// reported in issue #7576. +const TICK_MS = 25 +const REPORT_EVERY_MS = 5_000 + +export function isMainThreadDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env[MAIN_THREAD_DIAGNOSTICS_ENV] === '1' +} + +// Git global options that precede the subcommand. Value-taking flags must be +// skipped together with their value to find the real subcommand. +const GIT_VALUE_FLAGS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--exec-path']) + +// Why: only subcommand-style CLIs get a " " bucket; for +// anything else (rg, node, …) the first positional is an operand, not a +// subcommand, and would fragment the aggregation. +const SUBCOMMAND_BINARIES = new Set(['git', 'gh', 'glab']) + +/** + * Reduce a resolved spawn to a stable aggregation key like "git status" or + * "gh api". Handles WSL wrapping (`wsl.exe -d -- git …`), absolute + * binary paths, `.exe` suffixes, and git global flags before the subcommand. + */ +// Split on both separators so Windows-style paths classify correctly even +// when the classifier itself runs in a posix test environment. +function binaryName(command: string): string { + const leaf = command.split(/[\\/]/).pop() ?? command + return leaf.replace(/\.exe$/i, '').toLowerCase() +} + +export function classifySubprocessCommand(command: string, args: readonly string[]): string { + let binary = binaryName(command) + const rest = [...args] + if (binary === 'wsl') { + while (rest.length > 0) { + const arg = rest.shift() + if (arg === '--') { + break + } + } + const unwrapped = rest.shift() + if (!unwrapped) { + return 'wsl' + } + binary = binaryName(unwrapped) + } + if (!SUBCOMMAND_BINARIES.has(binary)) { + return binary + } + let subcommand: string | null = null + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] + if (!arg.startsWith('-')) { + subcommand = arg + break + } + // Why: only git's global flags take a separate value; e.g. rg's -C takes + // a number that must not be consumed as if it were a flag value. + if (binary === 'git' && GIT_VALUE_FLAGS.has(arg)) { + i++ + } + } + if (!subcommand) { + return binary + } + return `${binary} ${subcommand.slice(0, 40)}` +} + +export type SubprocessSpawnStats = { + count: number + // Cumulative and worst synchronous cost of initiating the spawn (the + // uv_spawn → posix_spawn call runs on the main thread before returning). + blockMsTotal: number + blockMsMax: number +} + +const spawnStatsByCommand = new Map() + +/** + * Record one subprocess spawn from the main process. `blockMs` is how long + * the synchronous spawn/execFile initiation call held the main thread. + * No-op unless ORCA_MAIN_THREAD_DIAGNOSTICS=1. + */ +export function recordSubprocessSpawn( + command: string, + args: readonly string[], + blockMs: number +): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + const key = classifySubprocessCommand(command, args) + const stats = spawnStatsByCommand.get(key) + if (stats) { + stats.count++ + stats.blockMsTotal += blockMs + stats.blockMsMax = Math.max(stats.blockMsMax, blockMs) + } else { + spawnStatsByCommand.set(key, { count: 1, blockMsTotal: blockMs, blockMsMax: blockMs }) + } +} + +export function drainSubprocessSpawnStats(): Record { + const drained: Record = {} + for (const [key, stats] of spawnStatsByCommand) { + drained[key] = { + count: stats.count, + blockMsTotal: Math.round(stats.blockMsTotal * 100) / 100, + blockMsMax: Math.round(stats.blockMsMax * 100) / 100 + } + } + spawnStatsByCommand.clear() + return drained +} + +/** + * Timestamped marker line for correlating a specific main-process activity + * (e.g. an updater check) with the probe's stall windows and with macOS + * Performance Diagnostics log entries in field captures. No-op unless + * ORCA_MAIN_THREAD_DIAGNOSTICS=1. + */ +export function writeMainThreadDiagnosticMarker(marker: string): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + writeStartupDiagnosticLine( + `[main-thread] ${JSON.stringify({ marker, t: Math.round(performance.now()) })}` + ) +} + +/** + * Long-running main-process jank probe for benchmarks and field diagnosis of + * issue #7576. Every 5s emits one `[main-thread] {json}` stderr line with the + * window's worst event-loop stall, stall counts over 50/250ms, and drained + * subprocess spawn stats. Unlike the startup stall probe this never stops: + * the churn it measures (git status polling, updater retries) is steady-state. + */ +export function startMainThreadChurnProbe(): void { + if (!isMainThreadDiagnosticsEnabled()) { + return + } + let last = performance.now() + let lastReport = last + let windowMaxGapMs = 0 + let gapsOver50Ms = 0 + let gapsOver250Ms = 0 + const timer = setInterval(() => { + const now = performance.now() + const gap = now - last - TICK_MS + last = now + if (gap > windowMaxGapMs) { + windowMaxGapMs = gap + } + if (gap > 50) { + gapsOver50Ms++ + } + if (gap > 250) { + gapsOver250Ms++ + } + if (now - lastReport < REPORT_EVERY_MS) { + return + } + lastReport = now + const spawns = drainSubprocessSpawnStats() + const report = { + t: Math.round(now), + maxGapMs: Math.max(0, Math.round(windowMaxGapMs)), + gapsOver50Ms, + gapsOver250Ms, + spawnCount: Object.values(spawns).reduce((sum, s) => sum + s.count, 0), + spawns + } + windowMaxGapMs = 0 + gapsOver50Ms = 0 + gapsOver250Ms = 0 + writeStartupDiagnosticLine(`[main-thread] ${JSON.stringify(report)}`) + }, TICK_MS) + timer.unref?.() +} diff --git a/src/main/git/gh-rate-limit-breaker.test.ts b/src/main/git/gh-rate-limit-breaker.test.ts new file mode 100644 index 00000000000..0975dd325ae --- /dev/null +++ b/src/main/git/gh-rate-limit-breaker.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + _resetGhRateLimitBreaker, + classifyGhRateLimitBucket, + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit, + recordGhPrimaryRateLimit, + registerGhRateLimitResetProbe +} from './gh-rate-limit-breaker' + +afterEach(() => { + _resetGhRateLimitBreaker() +}) + +describe('classifyGhRateLimitBucket', () => { + it('classifies search API endpoints, skipping flag values', () => { + expect( + classifyGhRateLimitBucket([ + 'api', + '--cache', + '120s', + 'search/issues?q=repo:a/b is:issue is:open&per_page=1', + '--jq', + '.total_count' + ]) + ).toBe('search') + expect(classifyGhRateLimitBucket(['search', 'prs', '--author', 'x'])).toBe('search') + }) + + it('classifies graphql and defaults everything else to core', () => { + expect(classifyGhRateLimitBucket(['api', 'graphql', '-f', 'query=…'])).toBe('graphql') + expect(classifyGhRateLimitBucket(['api', 'repos/a/b/pulls?per_page=36'])).toBe('core') + expect(classifyGhRateLimitBucket(['pr', 'list', '--limit', '36'])).toBe('core') + }) +}) + +describe('isGhRateLimitProbe', () => { + it('recognizes the exempt rate_limit endpoint only', () => { + expect(isGhRateLimitProbe(['api', 'rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', '/rate_limit'])).toBe(true) + expect(isGhRateLimitProbe(['api', 'search/issues?q=x'])).toBe(false) + expect(isGhRateLimitProbe(['pr', 'list'])).toBe(false) + }) +}) + +describe('isGhPrimaryRateLimitStderr', () => { + it('matches primary rate-limit 403s but not secondary limits', () => { + expect( + isGhPrimaryRateLimitStderr( + 'gh: API rate limit exceeded for user ID 1775218. If you reach out to GitHub Support… (HTTP 403)' + ) + ).toBe(true) + expect(isGhPrimaryRateLimitStderr('You have exceeded a secondary rate limit.')).toBe(false) + expect(isGhPrimaryRateLimitStderr('gh: Not Found (HTTP 404)')).toBe(false) + }) +}) + +describe('breaker state', () => { + it('blocks until the recorded time, then expires', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('search', now + 5_000) + expect(getGhRateLimitBlockedUntilMs('search', now)).toBe(now + 5_000) + expect(getGhRateLimitBlockedUntilMs('core', now)).toBeNull() + expect(getGhRateLimitBlockedUntilMs('search', now + 5_001)).toBeNull() + // Expiry is sticky — the entry was removed on the expired read. + expect(getGhRateLimitBlockedUntilMs('search', now)).toBeNull() + }) + + it('keeps the later of two recorded reset times', () => { + const now = 1_000_000 + recordGhPrimaryRateLimit('core', now + 60_000) + recordGhPrimaryRateLimit('core', now + 10_000) + expect(getGhRateLimitBlockedUntilMs('core', now)).toBe(now + 60_000) + }) + + it('notifyGhPrimaryRateLimit applies a fallback block and fires the reset probe', () => { + const probe = vi.fn() + registerGhRateLimitResetProbe(probe) + notifyGhPrimaryRateLimit('search') + expect(probe).toHaveBeenCalledWith('search') + expect(getGhRateLimitBlockedUntilMs('search')).toBeGreaterThan(Date.now()) + }) + + it('creates an error that classifies as rate_limited, not permission_denied', () => { + const error = createGhRateLimitBlockedError('search', Date.now() + 30_000) + expect(error.stderr.toLowerCase()).toContain('rate limit') + expect(error.stderr.toLowerCase()).not.toContain('403') + expect(error.ghRateLimitBlocked).toBe(true) + }) +}) diff --git a/src/main/git/gh-rate-limit-breaker.ts b/src/main/git/gh-rate-limit-breaker.ts new file mode 100644 index 00000000000..94bef46ff7a --- /dev/null +++ b/src/main/git/gh-rate-limit-breaker.ts @@ -0,0 +1,166 @@ +/** + * Global circuit breaker for gh CLI primary rate limits. + * + * Why: a primary rate-limit 403 means every further request in that bucket + * will fail until GitHub's reset time. Without shared state, a 90-repo + * Tasks-page fan-out keeps spawning gh subprocesses that each take a fresh + * 403 — burning main-thread spawn time and log noise while returning nothing + * (the Discord "countWorkItems failed ×90" storm). This module holds + * blocked-until state consulted by the runner before every gh spawn. + * + * Lives under git/ (not github/) with zero imports so both the runner and + * the github rate-limit prober can use it without an import cycle. + */ + +export type GhRateLimitBucket = 'core' | 'search' | 'graphql' + +// Why: a primary 403 does not carry the reset time. The search window resets +// every minute; core/graphql reset hourly but blocking a full hour on a guess +// would be too punishing, so pick a modest re-probe interval. The registered +// reset probe (gh api rate_limit — exempt from limits) refines these to the +// real reset time right after the breaker trips. +const FALLBACK_BLOCK_MS: Record = { + search: 70_000, + core: 5 * 60_000, + graphql: 5 * 60_000 +} + +const blockedUntilMsByBucket = new Map() +let resetProbe: ((bucket: GhRateLimitBucket) => void) | null = null + +// gh api flags that take a separate value, so the endpoint arg can be found. +const GH_API_VALUE_FLAGS = new Set([ + '--cache', + '--jq', + '-q', + '--method', + '-X', + '--field', + '-F', + '--raw-field', + '-f', + '--header', + '-H', + '--hostname', + '--input', + '--template', + '-t', + '--preview', + '-p' +]) + +function findGhApiEndpoint(args: readonly string[]): string | null { + for (let i = 1; i < args.length; i++) { + const arg = args[i] + if (!arg.startsWith('-')) { + return arg + } + if (GH_API_VALUE_FLAGS.has(arg)) { + i++ + } + } + return null +} + +export function classifyGhRateLimitBucket(args: readonly string[]): GhRateLimitBucket { + const command = args[0] + if (command === 'search') { + return 'search' + } + if (command !== 'api') { + return 'core' + } + const endpoint = findGhApiEndpoint(args) + if (!endpoint) { + return 'core' + } + const path = endpoint.replace(/^\//, '') + if (path.startsWith('search/')) { + return 'search' + } + if (path === 'graphql' || path.startsWith('graphql?')) { + return 'graphql' + } + return 'core' +} + +/** `gh api rate_limit` is exempt from rate limits and must never be blocked — + * it is the probe that refines the breaker's reset time. */ +export function isGhRateLimitProbe(args: readonly string[]): boolean { + if (args[0] !== 'api') { + return false + } + const endpoint = findGhApiEndpoint(args) + return endpoint === 'rate_limit' || endpoint === '/rate_limit' +} + +/** + * Primary rate-limit detection. Secondary limits ("secondary rate limit") + * carry Retry-After and are handled by the runner's transient retry logic. + */ +export function isGhPrimaryRateLimitStderr(stderr: string): boolean { + const s = stderr.toLowerCase() + return s.includes('api rate limit exceeded') && !s.includes('secondary rate limit') +} + +export function recordGhPrimaryRateLimit(bucket: GhRateLimitBucket, blockedUntilMs: number): void { + const existing = blockedUntilMsByBucket.get(bucket) ?? 0 + blockedUntilMsByBucket.set(bucket, Math.max(existing, blockedUntilMs)) +} + +export function clearGhRateLimitBlock(bucket: GhRateLimitBucket): void { + blockedUntilMsByBucket.delete(bucket) +} + +export function getGhRateLimitBlockedUntilMs( + bucket: GhRateLimitBucket, + nowMs: number = Date.now() +): number | null { + const blockedUntil = blockedUntilMsByBucket.get(bucket) + if (blockedUntil === undefined) { + return null + } + if (blockedUntil <= nowMs) { + blockedUntilMsByBucket.delete(bucket) + return null + } + return blockedUntil +} + +/** Register the (single) callback that refreshes precise reset times after a + * breaker trip. Registered by the github rate-limit prober at module load. */ +export function registerGhRateLimitResetProbe( + probe: ((bucket: GhRateLimitBucket) => void) | null +): void { + resetProbe = probe +} + +/** Called by the runner when a gh spawn came back with a primary 403. */ +export function notifyGhPrimaryRateLimit(bucket: GhRateLimitBucket): void { + recordGhPrimaryRateLimit(bucket, Date.now() + FALLBACK_BLOCK_MS[bucket]) + try { + resetProbe?.(bucket) + } catch { + // best-effort refinement; the fallback block stands + } +} + +export function createGhRateLimitBlockedError( + bucket: GhRateLimitBucket, + blockedUntilMs: number +): Error & { stderr: string; ghRateLimitBlocked: true } { + const resetsIn = Math.max(1, Math.ceil((blockedUntilMs - Date.now()) / 1000)) + // Why: "rate limit" (and no "HTTP 403") so classifyGhError maps this to + // rate_limited instead of permission_denied, matching a real gh failure. + const message = `GitHub API rate limit exceeded (${bucket}); retrying in ~${resetsIn}s without spawning gh.` + return Object.assign(new Error(message), { + stderr: message, + ghRateLimitBlocked: true as const + }) +} + +/** @internal — test-only */ +export function _resetGhRateLimitBreaker(): void { + blockedUntilMsByBucket.clear() + resetProbe = null +} diff --git a/src/main/git/runner-gh-rate-limit-breaker.test.ts b/src/main/git/runner-gh-rate-limit-breaker.test.ts new file mode 100644 index 00000000000..dc22bb54f4e --- /dev/null +++ b/src/main/git/runner-gh-rate-limit-breaker.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + execFileSyncMock: vi.fn(), + spawnMock: vi.fn() +})) + +vi.mock('child_process', () => ({ + execFile: execFileMock, + execFileSync: execFileSyncMock, + spawn: spawnMock +})) + +import { ghExecFileAsync } from './runner' +import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker' + +const PRIMARY_RATE_LIMIT_STDERR = + 'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)' + +function mockGhFailure(stderr: string): void { + execFileMock.mockImplementation((_binary, _args, options, callback) => { + const done = typeof options === 'function' ? options : callback + queueMicrotask(() => + done(Object.assign(new Error(`Command failed: gh\n${stderr}`), { stderr }), '', stderr) + ) + return { once: vi.fn() } + }) +} + +function mockGhSuccess(stdout: string): void { + execFileMock.mockImplementation((_binary, _args, options, callback) => { + const done = typeof options === 'function' ? options : callback + queueMicrotask(() => done(null, stdout, '')) + return { once: vi.fn() } + }) +} + +beforeEach(() => { + execFileMock.mockReset() +}) + +afterEach(() => { + _resetGhRateLimitBreaker() +}) + +describe('ghExecFileAsync rate-limit breaker', () => { + it('spawns once for a primary 403, then short-circuits same-bucket calls', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect( + ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:a/b&per_page=1']) + ).rejects.toThrow('rate limit') + expect(execFileMock).toHaveBeenCalledTimes(1) + + // The 90-repo storm case: every further search-bucket call must fail fast + // without a subprocess. + await expect( + ghExecFileAsync(['api', '--cache', '120s', 'search/issues?q=repo:c/d&per_page=1']) + ).rejects.toMatchObject({ ghRateLimitBlocked: true }) + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + + it('keeps other buckets working while one bucket is blocked', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'search/issues?q=x'])).rejects.toThrow() + mockGhSuccess('[]') + await expect(ghExecFileAsync(['pr', 'list', '--limit', '36'])).resolves.toEqual({ + stdout: '[]', + stderr: '' + }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('never blocks the exempt rate_limit probe', async () => { + mockGhFailure(PRIMARY_RATE_LIMIT_STDERR) + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).rejects.toThrow() + mockGhSuccess('{"resources":{}}') + await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toMatchObject({ + stdout: '{"resources":{}}' + }) + expect(execFileMock).toHaveBeenCalledTimes(2) + }) + + it('does not trip the breaker on secondary rate limits', async () => { + mockGhFailure('gh: You have exceeded a secondary rate limit. (HTTP 403)') + await expect( + ghExecFileAsync(['api', 'repos/a/b/pulls'], { idempotent: false }) + ).rejects.toThrow() + mockGhSuccess('[]') + await expect(ghExecFileAsync(['api', 'repos/a/b/pulls'])).resolves.toEqual({ + stdout: '[]', + stderr: '' + }) + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ec0d1d7b84d..c9009845369 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -19,6 +19,15 @@ import { } from 'node:child_process' import { StringDecoder } from 'node:string_decoder' import { withGitSpan } from '../observability/instrumentation' +import { recordSubprocessSpawn } from '../diagnostics/main-thread-churn-probe' +import { + classifyGhRateLimitBucket, + createGhRateLimitBlockedError, + getGhRateLimitBlockedUntilMs, + isGhPrimaryRateLimitStderr, + isGhRateLimitProbe, + notifyGhPrimaryRateLimit +} from './gh-rate-limit-breaker' import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl' import { getSpawnArgsForWindows, isWindowsBatchScript, resolveWindowsCommand } from '../win32-utils' import { @@ -381,6 +390,7 @@ function execFileCapture( } try { + const spawnStartedAt = performance.now() child = execFile( command, args, @@ -399,6 +409,7 @@ function execFileCapture( finish(error, stdout, stderr) } ) + recordSubprocessSpawn(command, args, performance.now() - spawnStartedAt) } catch (error) { finish(error instanceof Error ? error : new Error(String(error))) return @@ -441,12 +452,14 @@ async function spawnCommandCapture( let stderr = '' let stdoutBytes = 0 let stderrBytes = 0 + const spawnStartedAt = performance.now() const child = spawn(spawnCmd, spawnArgs, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) + recordSubprocessSpawn(spawnCmd, spawnArgs, performance.now() - spawnStartedAt) let timer: NodeJS.Timeout | null = null const onAbort = (): void => { killSpawnedCommandTree(child) @@ -1033,12 +1046,19 @@ export function gitExecFileSync( } ): string { const resolved = resolveCommand('git', args, options.cwd) - return execFileSync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: options.encoding ?? 'utf-8', - stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], - timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS - }) as string + const spawnStartedAt = performance.now() + try { + return execFileSync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: options.encoding ?? 'utf-8', + stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'], + timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS + }) as string + } finally { + // Sync exec holds the main thread for its whole duration, so the entire + // call is main-thread block time — the cost issue #7576 flags. + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + } } /** @@ -1053,10 +1073,13 @@ export function gitSpawn( const resolved = resolveCommand('git', args, options.cwd, wslDistro, { useWslLoginShell: Boolean(wslDistro) }) - return spawn(resolved.binary, resolved.args, { + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, cwd: resolved.cwd }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child } // ─── gh CLI runners ───────────────────────────────────────────────── @@ -1356,6 +1379,16 @@ export async function ghExecFileAsync( args: string[], options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { + // Why: while a bucket's primary rate limit is exhausted, every spawn would + // return the same 403 — fail fast without paying the subprocess cost. The + // rate_limit probe itself is exempt so the breaker can learn the reset time. + const rateLimitBucket = classifyGhRateLimitBucket(args) + if (!isGhRateLimitProbe(args)) { + const blockedUntilMs = getGhRateLimitBlockedUntilMs(rateLimitBucket) + if (blockedUntilMs !== null) { + throw createGhRateLimitBlockedError(rateLimitBucket, blockedUntilMs) + } + } let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) let lastError: unknown let attemptedHostFallback = false @@ -1375,6 +1408,9 @@ export async function ghExecFileAsync( } catch (err) { lastError = err const { stderr } = extractExecError(err) + if (isGhPrimaryRateLimitStderr(stderr)) { + notifyGhPrimaryRateLimit(rateLimitBucket) + } if ( process.platform === 'win32' && !attemptedDefaultWslFallback && @@ -1554,10 +1590,13 @@ export function wslAwareSpawn( const resolved = resolveCommand(command, args, options.cwd, wslDistro, { useWslLoginShell }) - return spawn(resolved.binary, resolved.args, { + const spawnStartedAt = performance.now() + const child = spawn(resolved.binary, resolved.args, { ...spawnOptions, cwd: resolved.cwd }) + recordSubprocessSpawn(resolved.binary, resolved.args, performance.now() - spawnStartedAt) + return child } // ─── Path translation helpers ─────────────────────────────────────── diff --git a/src/main/git/status-upstream-probe-churn.test.ts b/src/main/git/status-upstream-probe-churn.test.ts index f5e8f06c34b..49f6913e0af 100644 --- a/src/main/git/status-upstream-probe-churn.test.ts +++ b/src/main/git/status-upstream-probe-churn.test.ts @@ -186,6 +186,90 @@ describe('getStatus missing-upstream polling churn', () => { expect(sameNameOriginProbeCalls).toHaveLength(1) }) + // The pushed-but-untracked branch shape (issue #7576): the effective + // upstream resolves to the same-name origin ref, and before the resolved-name + // cache each 3s poll re-ran the whole resolution chain (~5 spawns/tick). + function mockResolvedSameNameOrigin(revList: { failOnCall?: number } = {}): void { + let revListCalls = 0 + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('status')) { + return { + stdout: '# branch.oid abcdef1234567890\n# branch.head bench/feature\n' + } + } + if (args[0] === 'symbolic-ref' && args.includes('HEAD')) { + return { stdout: 'bench/feature\n' } + } + if (args[0] === 'rev-parse' && args.includes('HEAD@{u}')) { + throw new Error("fatal: no upstream configured for branch 'bench/feature'") + } + if (isConfigListSnapshotCommand(args)) { + return emptyGitConfigSnapshot() + } + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature')) { + return { stdout: 'abcdef1234567890\n' } + } + if (args[0] === 'rev-list') { + revListCalls++ + if (revList.failOnCall === revListCalls) { + throw new Error('fatal: bad revision') + } + return { stdout: '1\t0\n' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + } + + function countCalls(predicate: (args: string[]) => boolean): number { + return gitExecFileAsyncMock.mock.calls.filter((call) => predicate(getGitArgs(call))).length + } + + it('revalidates a resolved same-name upstream with a single rev-list per poll', async () => { + mockResolvedSameNameOrigin() + + await getStatus('/repo') + await getStatus('/repo') + await getStatus('/repo') + + // Resolution chain ran once; the two later polls paid one rev-list each. + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(1) + expect(countCalls((args) => args[0] === 'rev-parse' && args.includes('HEAD@{u}'))).toBe(1) + expect(countCalls(isConfigListSnapshotCommand)).toBe(1) + expect( + countCalls( + (args) => args[0] === 'rev-parse' && args.includes('refs/remotes/origin/bench/feature') + ) + ).toBe(1) + expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) + }) + + it('re-resolves the upstream after the resolved-name cache TTL lapses', async () => { + vi.useFakeTimers() + vi.setSystemTime(0) + mockResolvedSameNameOrigin() + + await getStatus('/repo') + vi.setSystemTime(61_000) + await getStatus('/repo') + + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) + expect(countCalls((args) => args[0] === 'rev-list')).toBe(2) + }) + + it('falls back to a full re-resolve when the cached upstream rev-list fails', async () => { + // Second rev-list (the first revalidation attempt) fails, e.g. deleted ref. + mockResolvedSameNameOrigin({ failOnCall: 2 }) + + await getStatus('/repo') + const status = await getStatus('/repo') + + // Full chain ran twice: initial resolve + the fallback after the failure. + expect(countCalls((args) => args[0] === 'symbolic-ref')).toBe(2) + // rev-list: initial + failed revalidate + fallback's own rev-list. + expect(countCalls((args) => args[0] === 'rev-list')).toBe(3) + expect(status.upstreamStatus?.hasUpstream).toBe(true) + }) + it('does not cache a positive configured push target signal', async () => { gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { if (args.includes('status')) { diff --git a/src/main/git/status.ts b/src/main/git/status.ts index e7b0d95b2c8..862c8c9b76b 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -19,6 +19,7 @@ import type { import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import { getEffectiveGitUpstreamStatus, + getGitUpstreamStatusForUpstreamName, splitRemoteBranchName } from '../../shared/git-effective-upstream' import { createGitConfigSnapshotRunner } from '../../shared/git-config-snapshot-runner' @@ -66,6 +67,21 @@ const SUBMODULE_PATHS_CACHE_TTL_MS = 5_000 type SubmodulePathsCacheEntry = { paths: string[]; expiresAt: number } const submodulePathsCache = new Map() +// Why: the effective-upstream resolution chain (symbolic-ref + rev-parse ×2-3 +// + config snapshot) costs 4-5 subprocess spawns and only changes when branch +// or git config changes. Ahead/behind is a pure function of the two rev-list +// endpoints, so a recently-resolved name can be revalidated with one rev-list +// spawn per poll tick; a failed rev-list (deleted ref) falls back to a full +// re-resolve. Issue #7576: this path dominated idle main-process spawn churn. +const RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS = 60_000 + +type ResolvedUpstreamNameCacheEntry = { + upstreamName: string + expiresAt: number +} + +const resolvedUpstreamNameCache = new Map() + const effectiveUpstreamStatusCache = new Map() const effectiveUpstreamStatusInFlight = new Map>() const retiredEffectiveUpstreamStatusInFlight = new Map>() @@ -80,6 +96,7 @@ function clearGitReadInvalidationState(): void { gitDiffReadDedupe.clear() statusReadsInFlight.clear() submodulePathsCache.clear() + resolvedUpstreamNameCache.clear() } export function clearSubmodulePathsCacheForTests(): void { @@ -508,6 +525,7 @@ export function clearEffectiveUpstreamNegativeStatusCache(identity: { retireEffectiveUpstreamStatusProbe(cacheKey) effectiveUpstreamStatusCache.delete(cacheKey) effectiveUpstreamStatusInFlight.delete(cacheKey) + resolvedUpstreamNameCache.delete(cacheKey) effectiveUpstreamStatusWriteGeneration.set( cacheKey, (effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0) + 1 @@ -626,7 +644,13 @@ async function readOrProbeEffectiveUpstreamStatus( // Why: source-control mount and root git refresh can overlap during startup. // Coalesce the richer upstream probe so a stable missing ref fails once. const writeGeneration = effectiveUpstreamStatusWriteGeneration.get(cacheKey) ?? 0 - const probe = probeEffectiveUpstreamStatus(worktreePath, branchName, options).then((result) => { + const probe = probeOrRevalidateEffectiveUpstreamStatus( + cacheKey, + worktreePath, + branchName, + options, + bypassCache + ).then((result) => { rememberEffectiveUpstreamStatus( cacheKey, result.status, @@ -649,6 +673,46 @@ async function readOrProbeEffectiveUpstreamStatus( } } +async function probeOrRevalidateEffectiveUpstreamStatus( + cacheKey: string, + worktreePath: string, + branchName: string, + options: GitRuntimeOptions = {}, + bypassCache = false +): Promise<{ status: GitUpstreamStatus; probedSameNameOriginRef: boolean }> { + const now = Date.now() + const cached = resolvedUpstreamNameCache.get(cacheKey) + if (cached && (bypassCache || cached.expiresAt <= now)) { + resolvedUpstreamNameCache.delete(cacheKey) + } else if (cached) { + try { + const status = await getGitUpstreamStatusForUpstreamName( + (args) => gitExecFileAsync(args, gitOptionsForWorktree(worktreePath, options)), + cached.upstreamName + ) + return { status, probedSameNameOriginRef: false } + } catch { + // Ref deleted or repo state changed — fall through to a full re-resolve. + resolvedUpstreamNameCache.delete(cacheKey) + } + } + const result = await probeEffectiveUpstreamStatus(worktreePath, branchName, options) + if (result.status.hasUpstream && result.status.upstreamName) { + resolvedUpstreamNameCache.set(cacheKey, { + upstreamName: result.status.upstreamName, + expiresAt: Date.now() + RESOLVED_UPSTREAM_NAME_CACHE_TTL_MS + }) + while (resolvedUpstreamNameCache.size > MAX_EFFECTIVE_UPSTREAM_NEGATIVE_CACHE_ENTRIES) { + const oldest = resolvedUpstreamNameCache.keys().next() + if (oldest.done) { + break + } + resolvedUpstreamNameCache.delete(oldest.value) + } + } + return result +} + async function probeEffectiveUpstreamStatus( worktreePath: string, branchName: string, diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index 3daf66943c9..5312343224a 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -43,7 +43,8 @@ vi.mock('./gh-utils', async () => { vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) })) import { countWorkItems, getWorkItem, listWorkItems, _resetOwnerRepoCache } from './client' diff --git a/src/main/github/client-work-items.test.ts b/src/main/github/client-work-items.test.ts index 5a281ddfc80..66e728908df 100644 --- a/src/main/github/client-work-items.test.ts +++ b/src/main/github/client-work-items.test.ts @@ -63,7 +63,8 @@ vi.mock('../git/runner', () => ({ vi.mock('./rate-limit', () => ({ rateLimitGuard: rateLimitGuardMock, - noteRateLimitSpend: noteRateLimitSpendMock + noteRateLimitSpend: noteRateLimitSpendMock, + getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })) })) import { @@ -72,6 +73,7 @@ import { _resetMergeQueueCacheForTests, _resetOwnerRepoCache } from './client' +import { _resetGhCwdRepoNegativeCache } from './gh-cwd-repo-negative-cache' import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github-work-items-query-bounds' describe('listWorkItems', () => { @@ -99,6 +101,25 @@ describe('listWorkItems', () => { getOwnerRepoForRemoteMock.mockResolvedValue(null) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetGhCwdRepoNegativeCache() + }) + + it('stops re-spawning gh for a repo whose cwd resolution already failed with no remotes', async () => { + getIssueOwnerRepoMock.mockResolvedValue(null) + getOwnerRepoMock.mockResolvedValue(null) + ghExecFileAsyncMock.mockRejectedValue( + Object.assign(new Error('Command failed: gh pr list\nno git remotes found'), { + stderr: 'no git remotes found' + }) + ) + + await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') + // The first refresh pays the two cwd-fallback spawns (issue + pr list). + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + + await expect(listWorkItems('/no-remote-repo', 36)).rejects.toThrow('no git remotes found') + // The second refresh is served from the negative cache — zero new spawns. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => { @@ -537,6 +558,22 @@ describe('listWorkItems', () => { expect(apiPath).not.toContain('-is:merged') }) + it('returns zero without spawning gh when the search bucket is rate-limit blocked', async () => { + getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) + rateLimitGuardMock.mockReturnValue({ + blocked: true, + remaining: 0, + limit: 30, + resetAt: Math.floor(Date.now() / 1000) + 60 + } as unknown as { blocked: boolean }) + + await expect(countWorkItems('/repo-root', 'is:issue is:open')).resolves.toBe(0) + + expect(rateLimitGuardMock).toHaveBeenCalledWith('search') + expect(ghExecFileAsyncMock).not.toHaveBeenCalled() + }) + it('returns zero for oversized count queries before resolving repo sources', async () => { const secret = 'main-github-work-items-secret' const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 8d0030dfe65..3a308a3adfb 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -73,6 +73,12 @@ import { type HostedReviewExecutionOptions } from '../source-control/hosted-review-git-options' import { readLocalGitConfigSignature } from './local-git-config-signature' +import { + getRememberedGhCwdResolutionFailure, + isGhCwdRepoResolutionFailure, + rememberGhCwdResolutionFailure +} from './gh-cwd-repo-negative-cache' +import type { GitHubRepoContext } from './github-repository-identity' export { _resetOwnerRepoCache } from './gh-utils' export { getIssue, @@ -1005,6 +1011,32 @@ async function resolvePrWorkItemSource( return { source, originCandidate, upstreamCandidate } } +/** + * gh exec for calls that rely on gh's own cwd→repo resolution (no explicit + * owner/repo). Serves a remembered deterministic resolution failure without + * spawning, so a remote-less repo costs one gh spawn per config change/TTL + * instead of two per Tasks refresh. + */ +async function ghCwdResolvedExec( + context: GitHubRepoContext, + args: string[], + ghOptions: GhExecOptions +): Promise<{ stdout: string; stderr: string }> { + const remembered = await getRememberedGhCwdResolutionFailure(context) + if (remembered !== null) { + throw new Error(remembered) + } + try { + return await ghExecFileAsync(args, ghOptions) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (isGhCwdRepoResolutionFailure(message)) { + await rememberGhCwdResolutionFailure(context, message) + } + throw err + } +} + async function listRecentWorkItems( repoPath: string, issueOwnerRepo: OwnerRepo | null, @@ -1014,7 +1046,8 @@ async function listRecentWorkItems( noCache?: boolean, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) const restCacheArgs = noCache ? [] : ['--cache', '120s'] assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) @@ -1034,7 +1067,8 @@ async function listRecentWorkItems( ) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghExecFileAsync( + : ghCwdResolvedExec( + repoContext, [ 'issue', 'list', @@ -1058,7 +1092,8 @@ async function listRecentWorkItems( ) : requiresExplicitRepo ? Promise.resolve({ stdout: '[]' }) - : ghExecFileAsync( + : ghCwdResolvedExec( + repoContext, [ 'pr', 'list', @@ -1130,7 +1165,8 @@ 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([ - ghExecFileAsync( + ghCwdResolvedExec( + repoContext, [ 'issue', 'list', @@ -1143,7 +1179,8 @@ async function listRecentWorkItems( ], ghOptions ), - ghExecFileAsync( + ghCwdResolvedExec( + repoContext, [ 'pr', 'list', @@ -1180,7 +1217,8 @@ async function listQueriedWorkItems( connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) + const repoContext = githubRepoContext(repoPath, connectionId, localGitOptions) + const ghOptions = ghRepoExecOptions(repoContext) const requiresExplicitRepo = Boolean(connectionId) assertSshRepoHasResolvedGitHubSource({ connectionId, issueOwnerRepo, prOwnerRepo }) const hasPrOnlyFilter = @@ -1209,7 +1247,9 @@ async function listQueriedWorkItems( before }) try { - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = issueOwnerRepo + ? await ghExecFileAsync(args, ghOptions) + : await ghCwdResolvedExec(repoContext, args, ghOptions) return { items: (JSON.parse(stdout) as Record[]).map(mapIssueWorkItem) } @@ -1234,7 +1274,9 @@ async function listQueriedWorkItems( before }) try { - const { stdout } = await ghExecFileAsync(args, ghOptions) + const { stdout } = prOwnerRepo + ? await ghExecFileAsync(args, ghOptions) + : await ghCwdResolvedExec(repoContext, args, ghOptions) const mapped = (JSON.parse(stdout) as Record[]).map((item) => mapPullRequestWorkItem(item, prOwnerRepo) ) @@ -1398,6 +1440,9 @@ async function countWorkItemsForQuery( ], ghOptions ) + // Why: over-counts gh cache hits, which is the safe direction — the search + // bucket is only 30/min and the next probe corrects the estimate. + noteRateLimitSpend('search') return Number.parseInt(stdout.trim(), 10) || 0 } @@ -1453,6 +1498,15 @@ export async function countWorkItems( const parsedQuery = trimmedQuery ? parseTaskQuery(trimmedQuery) : null const effectiveQuery = parsedQuery ?? defaultOpenWorkItemQuery() + // Why: counts are decorative (pagination totals). The search bucket is only + // 30/min, so a multi-repo Tasks page must stop counting when the budget is + // gone instead of converting the remaining repos into 403 spawns. getRateLimit + // is 30s-cached and single-flight, so priming here is one spawn per window. + await getRateLimit() + if (rateLimitGuard('search').blocked) { + return 0 + } + await acquire() try { if (sameOwnerRepo(issueOwnerRepo, prOwnerRepo)) { diff --git a/src/main/github/gh-cwd-repo-negative-cache.test.ts b/src/main/github/gh-cwd-repo-negative-cache.test.ts new file mode 100644 index 00000000000..240711143f1 --- /dev/null +++ b/src/main/github/gh-cwd-repo-negative-cache.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +const { readLocalGitConfigSignatureMock } = vi.hoisted(() => ({ + readLocalGitConfigSignatureMock: vi.fn() +})) + +vi.mock('./local-git-config-signature', () => ({ + readLocalGitConfigSignature: readLocalGitConfigSignatureMock +})) + +import { + _resetGhCwdRepoNegativeCache, + getRememberedGhCwdResolutionFailure, + isGhCwdRepoResolutionFailure, + rememberGhCwdResolutionFailure +} from './gh-cwd-repo-negative-cache' + +const context = { repoPath: '/repo', connectionId: null } + +afterEach(() => { + _resetGhCwdRepoNegativeCache() + readLocalGitConfigSignatureMock.mockReset() +}) + +describe('isGhCwdRepoResolutionFailure', () => { + it('matches gh cwd-resolution failures only', () => { + expect(isGhCwdRepoResolutionFailure('Command failed: gh\nno git remotes found')).toBe(true) + expect( + isGhCwdRepoResolutionFailure( + 'none of the git remotes configured for this repository point to a known GitHub host' + ) + ).toBe(true) + expect(isGhCwdRepoResolutionFailure('gh: API rate limit exceeded (HTTP 403)')).toBe(false) + expect(isGhCwdRepoResolutionFailure('gh: Not Found (HTTP 404)')).toBe(false) + }) +}) + +describe('remembered failures', () => { + it('serves the remembered failure while the config signature is unchanged', async () => { + readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBe('no git remotes found') + }) + + it('invalidates when the git config signature changes (remote added)', async () => { + readLocalGitConfigSignatureMock.mockResolvedValueOnce('sig-1') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + readLocalGitConfigSignatureMock.mockResolvedValue('sig-2') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() + // The stale entry is gone, not just skipped. + readLocalGitConfigSignatureMock.mockResolvedValue('sig-1') + await expect(getRememberedGhCwdResolutionFailure(context)).resolves.toBeNull() + }) + + it('separates repos and runtimes by cache key', async () => { + readLocalGitConfigSignatureMock.mockResolvedValue('sig') + await rememberGhCwdResolutionFailure(context, 'no git remotes found') + await expect( + getRememberedGhCwdResolutionFailure({ repoPath: '/other-repo', connectionId: null }) + ).resolves.toBeNull() + await expect( + getRememberedGhCwdResolutionFailure({ repoPath: '/repo', connectionId: 'ssh-1' }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/github/gh-cwd-repo-negative-cache.ts b/src/main/github/gh-cwd-repo-negative-cache.ts new file mode 100644 index 00000000000..97a810f5e22 --- /dev/null +++ b/src/main/github/gh-cwd-repo-negative-cache.ts @@ -0,0 +1,83 @@ +import { readLocalGitConfigSignature } from './local-git-config-signature' +import type { GitHubRepoContext } from './github-repository-identity' + +/** + * Negative cache for repos where gh's cwd-based repo resolution fails + * ("no git remotes found" etc). Orca only falls back to cwd resolution when + * it could not resolve an owner/repo itself, so for a remote-less repo the + * failure is deterministic — yet the Tasks page re-spawned gh for every such + * repo on every refresh (×90 repos in the reported storm). Remember the + * failure and re-throw it without a subprocess until the repo's git config + * changes (signature check) or the TTL lapses. + */ +const NEGATIVE_CACHE_TTL_MS = 5 * 60_000 +const MAX_ENTRIES = 512 + +type NegativeCacheEntry = { + message: string + configSignature: string | undefined + expiresAt: number +} + +const entries = new Map() + +function negativeCacheKey(context: GitHubRepoContext): string { + const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}` + return `${runtimeKey}\0${context.repoPath}` +} + +export function isGhCwdRepoResolutionFailure(text: string): boolean { + const s = text.toLowerCase() + return ( + s.includes('no git remotes found') || + s.includes('not a git repository') || + s.includes('unable to determine base repository') || + s.includes('none of the git remotes configured') + ) +} + +export async function getRememberedGhCwdResolutionFailure( + context: GitHubRepoContext +): Promise { + const key = negativeCacheKey(context) + const entry = entries.get(key) + if (!entry) { + return null + } + if (entry.expiresAt <= Date.now()) { + entries.delete(key) + return null + } + // Why: adding a remote is the way out of this failure; the config signature + // (mtime/size of the repo's git config chain) changes when that happens. + const signature = await readLocalGitConfigSignature(context) + if (signature !== entry.configSignature) { + entries.delete(key) + return null + } + return entry.message +} + +export async function rememberGhCwdResolutionFailure( + context: GitHubRepoContext, + message: string +): Promise { + const configSignature = await readLocalGitConfigSignature(context) + entries.set(negativeCacheKey(context), { + message, + configSignature, + expiresAt: Date.now() + NEGATIVE_CACHE_TTL_MS + }) + while (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next() + if (oldest.done) { + break + } + entries.delete(oldest.value) + } +} + +/** @internal — test-only */ +export function _resetGhCwdRepoNegativeCache(): void { + entries.clear() +} diff --git a/src/main/github/gh-error-classification.ts b/src/main/github/gh-error-classification.ts index b7064ef0f6e..2de37919d25 100644 --- a/src/main/github/gh-error-classification.ts +++ b/src/main/github/gh-error-classification.ts @@ -4,6 +4,14 @@ import type { ClassifiedError } from '../../shared/types' // patterns to typed errors so callers can show user-friendly messages. export function classifyGhError(stderr: string): ClassifiedError { const s = stderr.toLowerCase() + // Why: primary rate-limit errors also carry "HTTP 403" — check rate limit + // first so they don't misclassify as a token-scope problem. + if (s.includes('rate limit')) { + return { + type: 'rate_limited', + message: 'GitHub rate limit hit. Try again in a few minutes.' + } + } if (s.includes('http 403') || s.includes('resource not accessible')) { return { type: 'permission_denied', @@ -19,12 +27,6 @@ export function classifyGhError(stderr: string): ClassifiedError { if (s.includes('http 422') || s.includes('validation failed')) { return { type: 'validation_error', message: `Invalid update — ${stderr.trim()}` } } - if (s.includes('rate limit')) { - return { - type: 'rate_limited', - message: 'GitHub rate limit hit. Try again in a few minutes.' - } - } if ( s.includes('timeout') || s.includes('no such host') || diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts index 1108b6bd219..ad140422488 100644 --- a/src/main/github/rate-limit.ts +++ b/src/main/github/rate-limit.ts @@ -20,6 +20,13 @@ import type { } from '../../shared/types' import { acquire, release } from './gh-utils' import { ghExecFileAsync } from '../git/runner' +import { + clearGhRateLimitBlock, + getGhRateLimitBlockedUntilMs, + recordGhPrimaryRateLimit, + registerGhRateLimitResetProbe, + type GhRateLimitBucket +} from '../git/gh-rate-limit-breaker' // Why: GitHub explicitly states `GET /rate_limit` does NOT count against // any bucket, so the only reason to cache is to avoid spawning a `gh` @@ -93,6 +100,18 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): limit: number resetAt: number } { + // Why: the runner-level breaker learns about exhaustion from actual 403s, + // which can happen long before (or without) a snapshot probe — e.g. quota + // burned by another tool on the same account. + const breakerBlockedUntilMs = getGhRateLimitBlockedUntilMs(bucket) + if (breakerBlockedUntilMs !== null) { + return { + blocked: true, + remaining: 0, + limit: cached?.[bucket].limit ?? 0, + resetAt: Math.ceil(breakerBlockedUntilMs / 1000) + } + } if (!cached) { return { blocked: false } } @@ -103,6 +122,11 @@ export function rateLimitGuard(bucket: RateLimitBucketKind): : bucket === 'graphql' ? MIN_REMAINING_GRAPHQL : MIN_REMAINING_SEARCH + // Why: a snapshot from before the bucket's reset time describes a window + // that has already ended — fail open rather than blocking on stale data. + if (b.resetAt * 1000 <= Date.now()) { + return { blocked: false } + } // Why: only block when we have a positive limit (limit:0 means "unknown" per // parseBucket fallback — don't block on missing data, that would brick the // app on a single bad rate_limit response). @@ -130,10 +154,63 @@ export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void } } +// Why: when the runner's breaker trips it only knows "blocked", not for how +// long. `gh api rate_limit` is exempt from limits, so one forced probe turns +// the fallback block into the bucket's real reset time (or clears a block +// that a stale fallback would otherwise keep alive). Single-flight: a 90-repo +// 403 burst must refine once, not 90 times. +let resetRefinementInFlight: Promise | null = null + +function refineBreakerFromSnapshot(): void { + if (resetRefinementInFlight) { + return + } + resetRefinementInFlight = (async () => { + try { + const result = await getRateLimit({ force: true }) + if (!result.ok) { + return + } + for (const bucket of ['core', 'search', 'graphql'] as GhRateLimitBucket[]) { + const b = result.snapshot[bucket] + if (b.limit > 0 && b.remaining <= 0) { + recordGhPrimaryRateLimit(bucket, b.resetAt * 1000) + } else if (b.limit > 0) { + clearGhRateLimitBlock(bucket) + } + } + } finally { + resetRefinementInFlight = null + } + })() +} + +registerGhRateLimitResetProbe(() => refineBreakerFromSnapshot()) + +// Why: a 90-repo fan-out that primes the guard concurrently must resolve to +// one `gh api rate_limit` spawn, not one per repo — the TTL cache alone can't +// dedupe calls that all start before the first probe lands. +let probeInFlight: Promise | null = null + export async function getRateLimit(options?: { force?: boolean }): Promise { if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) { return { ok: true, snapshot: cached } } + if (!options?.force && probeInFlight) { + return probeInFlight + } + const probe = fetchRateLimitSnapshot() + probeInFlight = probe + try { + return await probe + } finally { + if (probeInFlight === probe) { + probeInFlight = null + } + } +} + +async function fetchRateLimitSnapshot(): Promise { await acquire() try { const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) diff --git a/src/main/index.ts b/src/main/index.ts index 7d599f8dfdc..251ea69e67d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -92,6 +92,7 @@ import { shouldBypassSingleInstanceLock } from './startup/single-instance-lock' import { startEventLoopStallProbe } from './startup/event-loop-stall-probe' +import { startMainThreadChurnProbe } from './diagnostics/main-thread-churn-probe' import { isStartupDiagnosticsEnabled, logStartupDiagnostic, @@ -432,6 +433,9 @@ if (startupDiagnosticsEnabled) { }) startEventLoopStallProbe() } +// Self-gated on ORCA_MAIN_THREAD_DIAGNOSTICS; unlike the startup probe it +// runs for the whole session to catch steady-state churn (issue #7576). +startMainThreadChurnProbe() function focusExistingWindow(): void { focusExistingMainWindow({ diff --git a/src/main/updater.check-failure.test.ts b/src/main/updater.check-failure.test.ts index 2ecf0c855be..1b5c1dfc7d5 100644 --- a/src/main/updater.check-failure.test.ts +++ b/src/main/updater.check-failure.test.ts @@ -217,4 +217,54 @@ describe('updater check failure handling', () => { await vi.advanceTimersByTimeAsync(ONE_HOUR_MS - THIRTY_SECONDS_MS) expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) }) + + it('backs off consecutive failing background retries instead of re-checking hourly forever', async () => { + vi.useFakeTimers() + makeBenignCheckFailure('Unable to find latest version on GitHub') + + const sendMock = vi.fn() + const mainWindow = { webContents: { send: sendMock } } + + const { setupAutoUpdater, checkForUpdates } = await import('./updater') + + setupAutoUpdater(mainWindow as never, { getLastUpdateCheckAt: () => Date.now() }) + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + + // First retry keeps the fast 1h cadence (release-publishing windows). + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + + // Second retry doubles to 2h: nothing at +1h, fires by +2h. + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3) + + // Third retry doubles to 4h. + await vi.advanceTimersByTimeAsync(2 * ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(2 * ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(4) + + // A completed check resets the backoff to the fast retry. + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + queueMicrotask(() => { + autoUpdaterMock.emit('update-not-available', { version: '1.0.51' }) + }) + return Promise.resolve(null) + }) + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(5) + + makeBenignCheckFailure('Unable to find latest version on GitHub') + checkForUpdates() + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6) + await vi.advanceTimersByTimeAsync(ONE_HOUR_MS) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(7) + }) }) diff --git a/src/main/updater.ts b/src/main/updater.ts index 99553eadfb8..bc9c9d9b6f6 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -6,6 +6,7 @@ import type { UpdateCheckOptions, UpdateStatus } from '../shared/types' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' +import { writeMainThreadDiagnosticMarker } from './diagnostics/main-thread-churn-probe' import { beginMacUpdateDownload, deferMacQuitUntilInstallerReady, @@ -34,6 +35,12 @@ type ReleaseFeedPreflightResult = 'ready' | 'not-available' const AUTO_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000 const AUTO_UPDATE_RETRY_INTERVAL_MS = 60 * 60 * 1000 +// Why: a persistently-failing feed (blocked domain, proxy, GHE mirror) used +// to re-arm the retry at an exact 1h cadence forever — the recurring hourly +// macOS Performance Diagnostics signature in issue #7576. Double the retry +// delay per consecutive failure up to this cap; any completed check resets. +// Release-publishing windows resolve within the first (still 1h) retry. +const MAX_AUTO_UPDATE_RETRY_INTERVAL_MS = 6 * 60 * 60 * 1000 const NUDGE_POLL_INTERVAL_MS = 30 * 60 * 1000 const NUDGE_ACTIVATION_COOLDOWN_MS = 5 * 60 * 1000 const QUIT_AND_INSTALL_DELAY_MS = 100 @@ -384,6 +391,9 @@ function beginUpdateCheckAttempt(): number { updateCheckAttemptSequence += 1 activeUpdateCheckAttemptId = updateCheckAttemptSequence armUpdateCheckStallTimer(activeUpdateCheckAttemptId) + // Why: issue #7576's warnings recurred at the retry cadence; field captures + // need a timestamp for each check attempt to confirm or rule the updater out. + writeMainThreadDiagnosticMarker('updater-check-attempt') return activeUpdateCheckAttemptId } @@ -690,7 +700,20 @@ export function getUpdateStatus(): UpdateStatus { return currentStatus } +let consecutiveAutomaticRetrySchedules = 0 + function scheduleAutomaticUpdateCheck(delayMs: number): void { + let effectiveDelayMs = delayMs + // All retry-cadence callers (here and updater-events) pass exactly this + // constant, so keying the backoff on it keeps one choke point instead of + // threading a flag through seven schedule sites. + if (delayMs === AUTO_UPDATE_RETRY_INTERVAL_MS) { + effectiveDelayMs = Math.min( + AUTO_UPDATE_RETRY_INTERVAL_MS * 2 ** consecutiveAutomaticRetrySchedules, + MAX_AUTO_UPDATE_RETRY_INTERVAL_MS + ) + consecutiveAutomaticRetrySchedules += 1 + } if (autoUpdateCheckTimer) { clearTimeout(autoUpdateCheckTimer) } @@ -700,10 +723,11 @@ function scheduleAutomaticUpdateCheck(delayMs: number): void { // background attempt scheduled in the main process instead of tying checks // to relaunches or renderer lifetime. runBackgroundUpdateCheck() - }, delayMs) + }, effectiveDelayMs) } function recordCompletedUpdateCheck(): void { + consecutiveAutomaticRetrySchedules = 0 persistLastUpdateCheckAt?.(Date.now()) } diff --git a/src/renderer/src/store/slices/github.ts b/src/renderer/src/store/slices/github.ts index bc935dcba9b..7f980ac6a2e 100644 --- a/src/renderer/src/store/slices/github.ts +++ b/src/renderer/src/store/slices/github.ts @@ -2839,6 +2839,10 @@ export const createGitHubSlice: StateCreator = (s } const counts = await Promise.all( repos.map(async (r) => { + // Why: same stampede cap as the item-fetch paths — without a slot, + // a 90-repo selection dispatches 90 concurrent count IPCs before the + // main-side rate-limit guard can see the first 403. + await acquireWorkItemSlot() try { const requestState = get() const repo = findRepoForGitHubOwner(requestState, r.repoId, r.path) @@ -2857,6 +2861,8 @@ export const createGitHubSlice: StateCreator = (s return await countGitHubWorkItemsForRepo(requestContext, { query: query || undefined }) } catch { return 0 + } finally { + releaseWorkItemSlot() } }) ) diff --git a/src/shared/git-effective-upstream.ts b/src/shared/git-effective-upstream.ts index 2b99a951fa5..8292a9299c9 100644 --- a/src/shared/git-effective-upstream.ts +++ b/src/shared/git-effective-upstream.ts @@ -206,12 +206,25 @@ export async function getEffectiveGitUpstreamStatus( } } - const { stdout } = await runGit([ - 'rev-list', - '--left-right', - '--count', - `HEAD...${upstream.upstreamName}` - ]) + return getGitUpstreamStatusForUpstreamName( + runGit, + upstream.upstreamName, + getBehindCommitsArePatchEquivalent + ) +} + +/** + * Ahead/behind status for an already-resolved upstream name. Split out so + * callers that cached the resolution (a pure function of branch/config state) + * can refresh the counts with a single rev-list spawn instead of re-running + * the whole resolution chain. + */ +export async function getGitUpstreamStatusForUpstreamName( + runGit: GitCommandRunner, + upstreamName: string, + getBehindCommitsArePatchEquivalent?: (upstreamName: string) => Promise +): Promise { + const { stdout } = await runGit(['rev-list', '--left-right', '--count', `HEAD...${upstreamName}`]) const counts = parseGitRevListAheadBehindCounts(stdout) if (counts.status === 'unexpected-field-count') { throw new Error(`Unexpected git rev-list output: ${JSON.stringify(stdout)}`) @@ -222,12 +235,12 @@ export async function getEffectiveGitUpstreamStatus( const behindCommitsArePatchEquivalent = counts.ahead > 0 && counts.behind > 0 && getBehindCommitsArePatchEquivalent - ? await getBehindCommitsArePatchEquivalent(upstream.upstreamName) + ? await getBehindCommitsArePatchEquivalent(upstreamName) : undefined return { hasUpstream: true, - upstreamName: upstream.upstreamName, + upstreamName, ahead: counts.ahead, behind: counts.behind, ...(behindCommitsArePatchEquivalent !== undefined ? { behindCommitsArePatchEquivalent } : {}) diff --git a/tools/benchmarks/main-thread-jank-bench.mjs b/tools/benchmarks/main-thread-jank-bench.mjs new file mode 100644 index 00000000000..4b2aeebd041 --- /dev/null +++ b/tools/benchmarks/main-thread-jank-bench.mjs @@ -0,0 +1,419 @@ +#!/usr/bin/env node +/** + * Orca main-thread jank / subprocess-churn benchmark (issue #7576). + * + * Reproduces the reporter's setup: a running app with an active git worktree + * and the Source Control sidebar open, measured at steady state. The app is + * launched with ORCA_MAIN_THREAD_DIAGNOSTICS=1 so the main process emits one + * `[main-thread] {json}` stderr line every 5s containing: + * - worst event-loop stall in the window (maxGapMs) and stall counts + * - subprocess spawns since the last report, keyed by command + * ("git status", "gh api", …) with the synchronous spawn-initiation cost + * that held the main thread (blockMsTotal/blockMsMax) + * On macOS the bench also tails the unified log for the exact + * "Performance Diagnostics" warnings from the issue and records which process + * emitted them, so spawn churn and OS warnings can be correlated by timestamp. + * + * Usage: + * node tools/benchmarks/main-thread-jank-bench.mjs --label baseline + * [--duration-s 120] [--warmup-s 20] [--fixture-dir ] + * [--exe ] [--headless] [--no-log-stream] + * + * The window must stay VISIBLE during the run: git-status polling is gated on + * document visibility, so a hidden/minimized window measures nothing. + * --headless exists only for smoke-testing the harness itself. + * + * Prereq (when not using --exe): `pnpm build:electron-vite` so out/ exists. + * Results: tools/benchmarks/results/main-thread-jank-