Skip to content

fix(jetbrains): refresh PR badges app-wide on IDE focus gain - #13760

Merged
kirillk merged 2 commits into
mainfrom
rapid-acorn
Sep 3, 2026
Merged

kirillk merged 2 commits into
mainfrom
rapid-acorn

Conversation

@kirillk

@kirillk kirillk commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Issue

Fixes #

No tracked issue — reported directly in chat: the JetBrains Agent Manager PR badge did not update after returning focus to the IDE, and the log gave no evidence a focus-driven check ever ran.

Context

Worktree PR badges are meant to refresh when the IDE regains focus (WorktreeStatusService.focus()), so a PR opened or merged elsewhere shows up without waiting on the 120s poll. In practice this almost never fired:

  • applicationActivated was filtered on ideFrame.project === project, but IdeFrame.getProject() is nullable and the welcome screen answers a default project, so activations from those frames matched nothing. A second open project also never consumed its recorded absence, since its own frame never regained focus.
  • Even when it fired, a forced (cache-bypassing) lookup only happened past a 30s absence (Away.ceiling(gone, PR_THROTTLE)), so a quick trip to a browser to open/merge a PR usually took the unforced path and was answered from the backend's 90s PR cache.
  • None of the drop/throttle paths logged anything, so the log could not tell "the listener never fired" apart from "it fired and was silently dropped".

Implementation

  • Removed the frame→project filter: both applicationActivated/applicationDeactivated are now unfiltered, so any real app focus gain refreshes PR state for every project with an attached Agent Manager surface, not just the one whose frame happened to report it.
  • Lowered the force bar from PR_THROTTLE (30s) to Away.FRESH (10s), matching the bar the gh-availability probe already uses.
  • Split "does this return deserve fresh data" (the bar) from "may we afford to pay for it yet" (a new spend floor). A return blocked by the floor is not dropped — hold()/flush() fold it into a single trailing lookup with a fixed deadline (a continuing burst can't push it out) that keeps the strictest maxAge seen. Worst case a blocked return now lands within 30s instead of waiting out the full 120s poll.
  • Added a rate-limit backstop: while gh is RATE_LIMITED, focus-driven refreshes are suppressed entirely (spending a fan-out to confirm a spent budget teaches nothing), and the 120s poll remains the one path that notices the reset.
  • Logged every previously silent drop/defer/resume path plus the lookup result, so a repeat of this report is diagnosable from the log.

refreshPr(force, maxAge) itself is unchanged — AgentManagerPanel's post-worktree-create refresh (force = true, maxAge = 0) still runs immediately and unfloored.

Cost check (measured, not guessed): PrResolver.resolve costs 1–3 gh calls per worktree (a worktree without a PR pays the full ladder), 4-way concurrent. Worst case after this change is ~3,600 gh calls/hour against the 5,000/hour authenticated budget — only reachable by alt-tabbing out >10s and back every 30s for a solid hour — and the rate-limit backstop cuts that off before it matters. Two projects on the same repo root share one fan-out via the backend's directory-keyed cache.

Deliberately out of scope: a PR created by an agent session inside the IDE still waits for the poll, since focus never left and the Agent Manager tab-switch path calls refreshPr() unforced (answered from the 90s backend cache). Fixing that means invalidating the cache on a gh pr tool run, or forcing on tab switch — a separate change with its own cost tradeoff.

Screenshots / Video

N/A — logging/timing behavior change, no UI change.

How to Test

Manual/local verification

  • Agent performed: ./gradlew typecheck clean.
  • Agent performed: ./gradlew test (full JetBrains suite) green.
  • Agent performed: ./gradlew :frontend:test --tests '*WorktreeStatusServiceTest*' --tests '*GhStatusCoordinatorTest*' --tests '*AwayTest*' — 58/58 passing, including 4 new tests covering the spend-floor deferral, burst coalescing, and rate-limit suppression, and 3 rewritten tests covering the inverted app-wide activation and the new 10s bar.
  • Agent performed: bun run lint from repo root — 0 errors.
  • Agent performed: pre-push hook ran bun turbo typecheck across all packages (including @kilocode/kilo-jetbrains via Gradle) — all green.

Reviewer test steps

  1. Run a sandbox IDE with the CLI backend (./gradlew runIdeSplitMode from packages/kilo-jetbrains/), open a repo with gh configured and the Agent Manager tool window attached to at least one worktree.
  2. Open or merge a PR for one of the worktrees from a browser (not from inside the IDE).
  3. Switch focus away from the IDE for at least ~10s, then back.
  4. Confirm the PR badge updates without waiting for the 120s poll, and that kilo.log shows worktree PR focus / worktree PR refresh done lines for the return.
  5. Repeat the return within 30s of the first — confirm the log shows worktree PR focus deferred followed by worktree PR focus resumed once the floor clears, rather than a second immediate gh fan-out.

Blocked checks and substitute verification

  • Manual sandbox-IDE verification (steps above) was not run in this environment (no attached IDE/gh session); substitute verification was the full automated test suite above, which exercises the exact timing/coalescing/suppression logic through TestUiTimers and activateIde/deactivateIde.

Checklist

  • Issue linked above, or exception explained
  • Tests/verification described
  • Screenshots/video included for visual changes, or marked N/A
  • Changeset considered for user-facing changes — not needed: @kilocode/kilo-jetbrains is private and versioned outside changesets; JetBrains release notes come from the changelog on the release PR.
  • I personally reviewed the diff and can explain the changes, including any AI-assisted work.

Get in Touch

Worktree PR badges only refreshed on frame focus for the project whose
IdeFrame.getProject() matched the service instance. That filter dropped every
activation from a frame that reports a null or default project (e.g. the
welcome screen), and left a second open project's absence unconsumed since its
frame never regained focus. Combined with a 30s force bar that made most quick
returns fall back to the backend's 90s PR cache, and zero logging on any of
the drop paths, a real focus-gain return could look identical in the logs to
one that never fired at all.

- Make the ApplicationActivationListener callbacks unfiltered: any app
  activation now refreshes every attached project's PR state, not just the
  focused frame's project.
- Lower the force bar from PR_THROTTLE (30s) to Away.FRESH (10s) so a quick
  trip to a browser to open/merge a PR is enough to bypass the backend cache.
- Add a spend floor separate from the bar: a focus-driven forced lookup may
  run at most once per PR_THROTTLE per project. A return blocked by the floor
  is deferred as a single trailing lookup (fixed deadline, strictest maxAge
  wins) rather than dropped, so it still lands within 30s instead of waiting
  out the full 120s poll.
- Suppress focus-driven refreshes entirely while gh is RATE_LIMITED; the poll
  remains the only probe that notices the budget resetting.
- Log every previously silent drop/defer/resume path plus the lookup result,
  so a repeat of this report is diagnosable from the log alone.

Cost is bounded: PrResolver.resolve costs 1-3 gh calls per worktree, 4-way
concurrent. Worst case after this change is ~3,600 gh calls/hour against the
5,000/hour budget, only reachable by alt-tabbing out >10s and back every 30s
for a solid hour, and the rate-limit backstop cuts that off before it matters.

refreshPr(force, maxAge) semantics are unchanged for deliberate callers (e.g.
AgentManagerPanel's post-worktree-create refresh), which still run immediately
and unfloored.
@kilo-code-bot

kilo-code-bot Bot commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/Away.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt
Previous Review Summary (commit 27de3c6)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit 27de3c6)

Status: No Issues Found | Recommendation: Merge

Files Reviewed (4 files)
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/Away.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinator.kt
  • packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusService.kt
  • packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeStatusServiceTest.kt

Reviewed by grok-4.6 · Input: 151.3K · Output: 14K · Cached: 432.6K

Review guidance: REVIEW.md from base branch main

Review catch: the focus path deferred a return blocked by the spend floor but
dropped one blocked by the in-flight guard, so the request was silently lost
and the next automatic retry was the 120s poll. That is the worse of the two
cases — the running lookup may have started before the departure, so its answer
can predate the very change the return came back to observe, and dropping the
request leaves that stale answer standing.

Unify both blockers behind one held request: hold() records the strictest
ceiling and spend() decides whether it can run now, leaving it held for
whichever trigger clears first — the floor timer or the completion of the
running lookup. loadPr hooks invokeOnCompletion so every way a lookup can end
(answered, failed, cancelled, early return on an unresolved root) drains a held
return, and a superseded generation defers to the newer lookup that owns the
loop.

spend() re-checks disposal, refs, the integration flag, and RATE_LIMITED at
fire time rather than only at focus time, since a lookup landing while the
return was held can be the one that reports the budget spent.

Adds four tests: a return held behind a running lookup is spent when that
lookup ends (carrying its maxAge), the same return is dropped when that lookup
reports a rate limit, it does not outlive a detach, and the existing
no-stacking test now asserts the trailing lookup lands once the floor clears
instead of stopping at the skip.
@kirillk
kirillk merged commit 5861e40 into main Sep 3, 2026
23 checks passed
@kirillk
kirillk deleted the rapid-acorn branch September 3, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants