Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
af2c45a
fix(jetbrains): make gh/PR focus sync responsive without overwhelming…
kirillk Aug 31, 2026
23007e1
fix(jetbrains): keep PR lookups to one at a time
kirillk Aug 31, 2026
c35dc1c
test(jetbrains): wait for both update cycles in the session hook test
kirillk Aug 31, 2026
c9a01dc
test(jetbrains): wait for dropped run state after async termination
kirillk Aug 31, 2026
7e5ddc0
feat(jetbrains): show PR review and CI status on worktree rows
kirillk Aug 31, 2026
aebd040
feat(jetbrains): show PR detail on worktree row hover
kirillk Aug 31, 2026
2eac9d5
fix(jetbrains): keep a PR visible when gh refuses review fields
kirillk Aug 31, 2026
f14bc5e
fix(jetbrains): stop remeasuring worktree rows on every poll
kirillk Aug 31, 2026
6d7939f
fix(jetbrains): release a popup body that could not be placed
kirillk Aug 31, 2026
12c0aff
fix(jetbrains): quiet the PR indicators and line them up on worktree …
kirillk Aug 31, 2026
3f13f5f
fix(jetbrains): stack the worktree row popup one fact per line
kirillk Aug 31, 2026
59c68e1
fix(jetbrains): wrap the popup PR title and shrink the running dot
kirillk Aug 31, 2026
3e4f957
fix(jetbrains): scroll the popup PR title instead of wrapping it
kirillk Aug 31, 2026
52c8af5
fix(jetbrains): give the popup scrollbar its own room and double the …
kirillk Aug 31, 2026
cad7d81
fix(jetbrains): measure a popup row's real width and pad it further
kirillk Aug 31, 2026
8d0c414
Merge remote-tracking branch 'origin/main' into brave-dune
kirillk Aug 31, 2026
6e33fdf
chore(jetbrains): accept a dev-sha suffix on a local build version
kirillk Aug 31, 2026
fc8077b
fix(jetbrains): report a spent GitHub budget instead of blanking the …
kirillk Aug 31, 2026
b6d02ec
fix(jetbrains): double the dwell before a worktree row popup opens
kirillk Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-gh-focus-freshness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Show pull request and gh authorization changes right after you come back to the IDE. Returning from a long absence now reloads immediately instead of waiting out the poll, refreshes that arrive during a burst of window or tab switches are no longer dropped, and closing a dialog no longer triggers a needless lookup. A newly created worktree also gets its pull request badge without waiting for the cache to expire.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-worktree-review-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Show pull request review and CI status on each worktree row in the Agent Manager. The title line now carries a review verdict (approved or changes requested) followed by a build verdict (passed, failed, or running), with counts in the tooltip and a click through to the pull request or its checks.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-worktree-row-popup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Hovering a worktree row in the Agent Manager now shows its full pull request detail beside the row: title, number and state, review and CI verdicts with counts, and both committed and uncommitted change counts. The popup opens on the side with more room and never covers the list.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import ai.kilocode.rpc.dto.BranchStatusDto
import ai.kilocode.rpc.dto.CreateWorktreeRequestDto
import ai.kilocode.rpc.dto.CreateWorktreeResultDto
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.GhChecks
import ai.kilocode.rpc.dto.GhChecksDto
import ai.kilocode.rpc.dto.GhReview
import ai.kilocode.rpc.dto.GhState
import ai.kilocode.rpc.dto.MoveProgressDto
import ai.kilocode.rpc.dto.MoveStage
Expand Down Expand Up @@ -50,8 +53,10 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
Expand Down Expand Up @@ -194,21 +199,23 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
return synced.filter { Files.isDirectory(Path.of(it.path)) }
}

override suspend fun ghStatus(directory: String, github: Boolean): GhAvailability = withContext(Dispatchers.IO) {
probeGh(Path.of(directory).normalize(), "rpc", github)
override suspend fun ghStatus(directory: String, github: Boolean, maxAge: Long?): GhAvailability = withContext(Dispatchers.IO) {
probeGh(Path.of(directory).normalize(), "rpc", github, maxAge)
}

override suspend fun prStatus(directory: String): WorktreePrListDto = withContext(Dispatchers.IO) {
override suspend fun prStatus(directory: String, maxAge: Long?): WorktreePrListDto = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
prs[directory]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value }
prs[directory]?.takeIf { usable(it.time, now, PR_TTL, maxAge) }?.let { return@withContext it.value }
val root = Path.of(directory).normalize()
// A gone directory reports nothing and is not cached, so a real availability problem found
// from a live directory still reaches the UI.
if (!Files.isDirectory(root)) {
LOG.info("pr status skipped, directory does not exist: $root")
return@withContext WorktreePrListDto()
}
val available = ghAvailable(root)
// A caller that rejected the cached PR list would not accept a cached availability verdict
// from the same moment either, so the ceiling carries into the gh probe.
val available = ghAvailable(root, maxAge = maxAge)
if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) }
// Sync the worktree list before the per-worktree lookups so a worktree that was added and
// then deleted on disk is pruned instead of resolved from a directory that no longer exists.
Expand All @@ -229,21 +236,21 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
dto
}

override suspend fun branchStatus(directory: String, github: Boolean): BranchStatusDto = withContext(Dispatchers.IO) {
override suspend fun branchStatus(directory: String, github: Boolean, maxAge: Long?): BranchStatusDto = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
// Keyed by directory + mode so flipping the GitHub integration setting cannot serve a
// cross-mode entry (a disabled-mode entry always has a null pr; an enabled-mode entry may
// not) for up to PR_TTL after the flip.
val key = "$directory|$github"
branches[key]?.takeIf { now - it.time < PR_TTL }?.let { return@withContext it.value }
branches[key]?.takeIf { usable(it.time, now, PR_TTL, maxAge) }?.let { return@withContext it.value }
val root = Path.of(directory).normalize()
if (!Files.isDirectory(root)) {
LOG.info("branch status skipped, directory does not exist: $root")
return@withContext BranchStatusDto()
}
val branch = runGit(root, "branch", "--show-current").stdout.trim()
val worktree = isLinkedWorktree(root)
val availability = ghAvailable(root, github)
val availability = ghAvailable(root, github, maxAge)
val lookup = if (github && availability == GhAvailability.OK && branch.isNotBlank()) {
resolver.resolve(directory, branch, baseBranch(root))
} else {
Expand Down Expand Up @@ -661,17 +668,19 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
* Resolves git/gh availability. When [github] is false, only git presence is checked and `gh`
* is never spawned — used while the user has turned off the GitHub integration setting.
*/
private fun ghAvailable(root: Path, github: Boolean = true): GhAvailability {
private fun ghAvailable(root: Path, github: Boolean = true, maxAge: Long? = null): GhAvailability {
if (!Files.isDirectory(root)) {
LOG.info("gh availability skipped dir=$root missing=true")
return GhAvailability.OK
}
val status = probeGh(root, "availability", github)
val status = probeGh(root, "availability", github, maxAge)
// A git-only probe can only return GIT_MISSING or OK, so the gh-binary re-check below is
// unreachable while github is false; the guard documents that explicitly.
if (!github || status != GhAvailability.MISSING) return status
val now = System.currentTimeMillis()
ghProbe?.takeIf { now - it.time < GH_PROBE_TTL }?.let { return it.value }
// Installing gh is the transition this long-lived entry hides, so a caller demanding
// freshness must be able to re-check the binary and not just the auth verdict.
ghProbe?.takeIf { usable(it.time, now, GH_PROBE_TTL, maxAge) }?.let { return it.value }
val res = runGh(root, "--version")
val value = if (res.ok) GhAvailability.OK else GhAvailability.MISSING
ghProbe = Timed(now, value)
Expand All @@ -683,7 +692,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
* spawns `gh` or touches [ghCache] (the cache mixes gh-auth verdicts with a boolean this probe
* would otherwise poison with a git-only "OK").
*/
private fun probeGh(root: Path, reason: String, github: Boolean = true): GhAvailability = synchronized(ghLock) {
private fun probeGh(root: Path, reason: String, github: Boolean = true, maxAge: Long? = null): GhAvailability = synchronized(ghLock) {
// A stale/removed worktree directory makes the process spawn fail, which would be
// misreported as GIT_MISSING. Treat a missing directory as "nothing to report" and
// don't cache it, so the next probe on a real directory still runs.
Expand All @@ -693,13 +702,13 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {
}
val now = System.currentTimeMillis()
if (github) {
ghCache?.takeIf { now - it.time < GH_STATUS_TTL }?.let {
LOG.info("gh probe cache hit reason=$reason value=${it.value}")
ghCache?.takeIf { usable(it.time, now, GH_STATUS_TTL, maxAge) }?.let {
LOG.info("gh probe cache hit reason=$reason value=${it.value} ageMs=${now - it.time}")
return@synchronized it.value
}
}
val start = System.currentTimeMillis()
LOG.info("gh probe start reason=$reason dir=$root github=$github")
LOG.info("gh probe start reason=$reason dir=$root github=$github maxAge=${maxAge ?: "default"}")
val git = runGit(root, "--version")
if (!git.ok) {
// The directory can disappear between the check above and the spawn; a failed working
Expand Down Expand Up @@ -730,6 +739,17 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi {

}

/**
* Whether a cache entry written at [time] may still be served. [maxAge] is the caller's own ceiling
* on staleness: it can only tighten [ttl], never extend it, so an event-driven request can reject an
* entry the poll would have accepted while no caller is able to pin stale data in place past the TTL.
* A [maxAge] of 0 (or below) rejects every entry and forces the work to run.
*/
internal fun usable(time: Long, now: Long, ttl: Long, maxAge: Long?): Boolean {
val limit = maxAge?.coerceIn(0, ttl) ?: ttl
return now - time < limit
}

/** True when a process failed because its working directory is gone, not because the tool is absent. */
internal fun badDir(text: String): Boolean {
val msg = text.lowercase()
Expand Down Expand Up @@ -757,7 +777,71 @@ internal fun parsePr(path: String, raw: String): WorktreePrDto? {
"CLOSED" -> GhState.CLOSED
else -> GhState.OPEN
}
return WorktreePrDto(path, number, state, url, title)
return WorktreePrDto(path, number, state, url, title, parseReview(obj), parseChecks(obj))
}

/**
* Reads GitHub's `reviewDecision`. Absent when the field was not requested, when the repository asks
* for no review, or when this `gh` could not answer it — all of which mean "nothing to show".
*/
internal fun parseReview(obj: JsonObject): GhReview {
return when (obj["reviewDecision"]?.jsonPrimitive?.contentOrNull?.uppercase()) {
"APPROVED" -> GhReview.APPROVED
"CHANGES_REQUESTED" -> GhReview.CHANGES_REQUESTED
"REVIEW_REQUIRED" -> GhReview.PENDING
else -> GhReview.NONE
}
}

/**
* Rolls GitHub's `statusCheckRollup` up into counts and one verdict.
*
* A rollup entry is either a check run (`conclusion`, still empty while it runs, plus `status`) or a
* legacy commit status (`state`), so the verdict is read from whichever the entry carries. Skipped
* checks are excluded from [GhChecksDto.total] because GitHub does not count them either, and a single
* failure outranks anything still running: a red build stays red however many jobs are queued behind it.
*/
internal fun parseChecks(obj: JsonObject): GhChecksDto {
val items = obj["statusCheckRollup"] as? JsonArray ?: return GhChecksDto()
var total = 0
var passed = 0
var failed = 0
var pending = 0
for (item in items) {
val entry = item as? JsonObject ?: continue
val raw = entry["conclusion"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: entry["state"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
?: entry["status"]?.jsonPrimitive?.contentOrNull
when (checkState(raw)) {
CheckState.SKIPPED -> continue
CheckState.PASSED -> passed++
CheckState.FAILED -> failed++
CheckState.PENDING -> pending++
}
total++
}
val state = when {
total == 0 -> GhChecks.NONE
failed > 0 -> GhChecks.FAILED
pending > 0 -> GhChecks.PENDING
else -> GhChecks.PASSED
}
return GhChecksDto(state, total, passed, failed, pending)
}

/** One rollup entry's verdict. [SKIPPED] is tracked only so it can be left out of the totals. */
internal enum class CheckState { PASSED, FAILED, PENDING, SKIPPED }

/**
* Maps one rollup entry's conclusion, commit-status state, or run status to a verdict. An unknown or
* missing value counts as pending: a check nobody recognises has not reported success, and calling it
* a failure would paint rows red on GitHub's next new status name.
*/
internal fun checkState(raw: String?): CheckState = when (raw?.uppercase()) {
"SUCCESS", "NEUTRAL" -> CheckState.PASSED
"FAILURE", "ERROR", "ACTION_REQUIRED", "CANCELLED", "TIMED_OUT", "STALE", "STARTUP_FAILURE" -> CheckState.FAILED
"SKIPPED" -> CheckState.SKIPPED
else -> CheckState.PENDING
}

/** Head of a pull request being imported. */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package ai.kilocode.backend.rpc

import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.dto.GhAvailability
import ai.kilocode.rpc.dto.WorktreePrDto
import kotlinx.serialization.json.Json
Expand All @@ -16,8 +17,30 @@ internal data class CmdOut(val exit: Int, val stdout: String, val stderr: String
/** PR for one checkout, plus the gh availability observed while resolving it. */
internal data class PrLookup(val pr: WorktreePrDto? = null, val availability: GhAvailability = GhAvailability.OK)

/** Scalar fields every supported `gh` release and token can answer. */
internal const val PR_FIELDS = "number,state,isDraft,url,title"

/**
* [PR_FIELDS] plus the review verdict and CI rollup. Both are GraphQL sub-queries rather than scalars,
* so an older `gh` rejects the field names outright and a restricted token is refused the data. See
* [richUnsupported] for how that is detected and [PrResolver] for the fallback.
*/
internal const val PR_RICH_FIELDS = "$PR_FIELDS,reviewDecision,statusCheckRollup"

/**
* Whether a failing `gh pr` command was rejected for asking about review or CI state, rather than for
* any of the ordinary reasons (no PR, no auth, no network).
*
* This has to be distinguished because [prError] treats everything non-auth as "no PR here", so an
* unsupported field would otherwise make a checkout with a perfectly good PR report no PR at all.
*/
internal fun richUnsupported(stderr: String): Boolean {
val text = stderr.lowercase()
// Old gh rejects the field name; a restricted token is refused the underlying GraphQL node.
if (text.contains("unknown json field")) return true
return text.contains("resource not accessible by integration")
Comment thread
kirillk marked this conversation as resolved.
Resolved
}

/**
* Resolves the pull request a checkout belongs to. A worktree can reach a PR in several ways —
* Kilo's PR import, `gh pr checkout`, a hand-made `git worktree add`, a branch renamed locally, a
Expand All @@ -36,6 +59,11 @@ internal class PrResolver(
private val gh: (Path, List<String>) -> CmdOut,
private val git: (Path, List<String>) -> CmdOut,
) {
// Volatile because prStatus resolves several checkouts concurrently. Two threads racing to clear it
// is harmless: both observed the same unsupported field and both write false.
@Volatile
private var rich = true
Comment thread
kirillk marked this conversation as resolved.
Resolved

/**
* Resolves the PR for the checkout at [path] on [branch]. [base] is the repository's base
* branch; a PR headed by it is not worth a search query, so strategy 3 is skipped there.
Expand All @@ -50,25 +78,41 @@ internal class PrResolver(

/** Null means "no PR here, keep looking"; a value is terminal (a PR, or gh being unusable). */
private fun view(dir: Path, path: String, branch: String?): PrLookup? {
val args = buildList {
add("pr")
add("view")
branch?.let { add(it) }
add("--json")
add(PR_FIELDS)
val out = query(dir) { fields ->
buildList {
add("pr")
add("view")
branch?.let { add(it) }
add("--json")
add(fields)
}
}
val out = gh(dir, args)
if (!out.ok) return unusable(out.stderr)
return parsePr(path, out.stdout)?.let { PrLookup(it) }
}

/**
* Runs a `gh pr` command with the richest field list this `gh` and token have proven they can
* answer, dropping to [PR_FIELDS] and retrying once when they turn out they cannot.
*
* The downgrade latches, so a release or token without review/CI support costs one extra call in
* total rather than one per checkout on every poll.
*/
private fun query(dir: Path, command: (String) -> List<String>): CmdOut {
val wanted = if (rich) PR_RICH_FIELDS else PR_FIELDS
val out = gh(dir, command(wanted))
if (out.ok || wanted == PR_FIELDS || !richUnsupported(out.stderr)) return out
rich = false
LOG.info("gh cannot answer review/CI fields, falling back to scalars: ${out.stderr.trim()}")
return gh(dir, command(PR_FIELDS))
}

private fun search(dir: Path, path: String): PrLookup? {
val head = git(dir, listOf("rev-parse", "HEAD")).stdout.trim()
if (head.isEmpty()) return null
val out = gh(
dir,
listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$PR_FIELDS,headRefOid"),
)
val out = query(dir) { fields ->
listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$fields,headRefOid")
}
if (!out.ok) return unusable(out.stderr)
val items = runCatching { json.parseToJsonElement(out.stdout) as? JsonArray }.getOrNull() ?: return null
for (item in items) {
Expand Down Expand Up @@ -100,3 +144,5 @@ internal fun prError(stderr: String): GhAvailability {
}

private val json = Json { ignoreUnknownKeys = true }

private val LOG = KiloLog.create(PrResolver::class.java)
Loading
Loading