Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/pr-reviewer-avatars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Show reviewer avatars and GitHub-style review states in the Agent Manager PR sidebar, and remove the redundant review decision row.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 36 additions & 7 deletions packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ export class PRStatusPoller {
private activeWorktreeId: string | undefined
private cachedRepo: { owner: string; name: string; root: string } | undefined
private prCache = new Map<string, { result: PRResult | null; expires: number }>()
/** Reviewer avatars are stable, so look them up once per login and reuse them. */
private readonly avatars = new Map<string, string>()
private readonly resolvedAvatars = new Set<string>()
private lastFullSync = 0 // timestamp of last full (all-worktree) sync
private readonly intervalMs: number
private readonly semaphore: Semaphore | undefined
Expand Down Expand Up @@ -138,6 +141,8 @@ export class PRStatusPoller {
this.rich = true
this.cachedRepo = undefined
this.prCache.clear()
this.avatars.clear()
this.resolvedAvatars.clear()
this.lastFullSync = 0
}

Expand Down Expand Up @@ -317,7 +322,28 @@ export class PRStatusPoller {
}

private extras(pr: PRResult, cwd: string) {
return [pr.checks ?? this.fetchChecks(pr.number, cwd), pr.reviewers ?? this.fetchReviewers(pr.number, cwd)] as const
return [pr.checks ?? this.fetchChecks(pr.number, cwd), this.reviewers(pr, cwd)] as const
}

/**
* `gh pr view --json reviews` returns reviewer logins without avatars, so
* merge avatar URLs from the GraphQL query and cache them per login. States
* from `gh pr view` stay authoritative.
*/
private async reviewers(pr: PRResult, cwd: string): Promise<PRReviewer[]> {
if (pr.reviewers === undefined) return (await this.fetchReviewers(pr.number, cwd)).items
const list = pr.reviewers
if (list.length === 0 || list.every((item) => item.avatar || this.resolvedAvatars.has(item.login)))
return list.map((item) => (item.avatar ? item : { ...item, avatar: this.avatars.get(item.login) }))
const fetched = await this.fetchReviewers(pr.number, cwd)
if (fetched.ok) {
for (const item of list) this.resolvedAvatars.add(item.login)
for (const item of fetched.items) {
this.resolvedAvatars.add(item.login)
if (item.avatar) this.avatars.set(item.login, item.avatar)
}
}
return list.map((item) => (item.avatar ? item : { ...item, avatar: this.avatars.get(item.login) }))
}

private handleError(worktreeId: string, branch: string | undefined, cwd: string, err: unknown): void {
Expand Down Expand Up @@ -459,7 +485,7 @@ export class PRStatusPoller {
return info
}

private async fetchReviewers(prNumber: number, cwd: string): Promise<PRReviewer[]> {
private async fetchReviewers(prNumber: number, cwd: string): Promise<{ items: PRReviewer[]; ok: boolean }> {
try {
const repo = await this.getRepoInfo(cwd)
const query = `query($owner: String!, $repo: String!, $number: Int!) {
Expand Down Expand Up @@ -490,13 +516,16 @@ export class PRStatusPoller {
{ cwd, timeout: 15_000 },
)
const pr = JSON.parse(stdout)?.data?.repository?.pullRequest
return parseReviewers(
(pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[],
(pr?.reviews?.nodes ?? []) as GhReview[],
)
return {
items: parseReviewers(
(pr?.reviewRequests?.nodes ?? []) as GhReviewRequest[],
(pr?.reviews?.nodes ?? []) as GhReview[],
),
ok: true,
}
} catch (err) {
this.options.log("Failed to fetch PR reviewers:", err)
return []
return { items: [], ok: false }
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ export function signature(pr: PRStatus): string {
pr.checks.total,
pr.checks.checks.map((check) => [check.name, check.status, check.url ?? "", check.duration ?? ""]),
],
pr.reviewers.map((r) => [r.login, r.state]),
pr.reviewers.map((r) => [r.login, r.state, r.avatar ?? ""]),
pr.body ?? "",
[
pr.comments?.total ?? null,
Expand Down
40 changes: 40 additions & 0 deletions packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,46 @@ describe("PRStatusPoller batched GitHub queries", () => {
poller.stop()
})

it("merges reviewer avatars from the GraphQL query and caches them per login", async () => {
const poller = new PRStatusPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => "/repo",
onStatus: () => undefined,
log: () => undefined,
})
const internal = poller as unknown as {
reviewers: (
pr: { number: number; reviewers?: Array<{ login: string; state: string; avatar?: string }> },
cwd: string,
) => Promise<Array<{ login: string; state: string; avatar?: string }>>
fetchReviewers: (
number: number,
cwd: string,
) => Promise<{
items: Array<{ login: string; state: string; avatar?: string }>
ok: boolean
}>
}
const fetches: number[] = []
internal.fetchReviewers = async (number) => {
fetches.push(number)
return {
items: [{ login: "eshurakov", state: "commented", avatar: "https://avatar/eshurakov" }],
ok: true,
}
}
const result = [{ login: "eshurakov", state: "approved", avatar: "https://avatar/eshurakov" }]

expect(
await internal.reviewers({ number: 7, reviewers: [{ login: "eshurakov", state: "approved" }] }, "/repo"),
).toEqual(result)
expect(
await internal.reviewers({ number: 7, reviewers: [{ login: "eshurakov", state: "approved" }] }, "/repo"),
).toEqual(result)
expect(fetches).toEqual([7])
poller.stop()
})

it("forwards the actual branch for null PR results", async () => {
const values: Array<{ pr: PRStatus | null; branch?: string }> = []
const branches: string[] = []
Expand Down
7 changes: 7 additions & 0 deletions packages/kilo-vscode/tests/unit/am-pr-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,13 @@ describe("PR signature", () => {
expect(signature({ ...pr, title: "A:B", body: "C" })).not.toBe(signature({ ...pr, title: "A", body: "B:C" }))
})

it("changes when a reviewer avatar becomes available", () => {
const before = signature(pr)
expect(signature({ ...pr, reviewers: [{ login: "alice", state: "pending", avatar: "https://avatar" }] })).not.toBe(
before,
)
})

it("changes when either captured PR ref changes", () => {
const refs = { baseRefOid: "a".repeat(40), headRefOid: "b".repeat(40) }
const before = signature({ ...pr, ...refs })
Expand Down
14 changes: 0 additions & 14 deletions packages/kilo-vscode/webview-ui/agent-manager/pr/PROverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,6 @@ const STATE_LABEL: Record<PRStatus["state"], string> = {
closed: "Closed",
}

const REVIEW_LABEL: Partial<Record<NonNullable<PRStatus["review"]>, string>> = {
approved: "Approved",
changes_requested: "Changes Requested",
pending: "Review Pending",
}

export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) {
return (
<div class="am-pr-panel-section">
Expand All @@ -38,14 +32,6 @@ export function PROverview(props: { pr: PRStatus; worktree?: WorktreeState }) {
{STATE_LABEL[props.pr.state]}
</span>
</div>
<Show when={props.pr.review}>
{(review) => (
<div class="am-pr-panel-row am-pr-row">
<span class="am-pr-panel-label">Review</span>
<span class="am-pr-panel-value">{REVIEW_LABEL[review()]}</span>
</div>
)}
</Show>
</div>
)
}
14 changes: 10 additions & 4 deletions packages/kilo-vscode/webview-ui/agent-manager/pr/PRReviewers.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/** @jsxImportSource solid-js */
import { For, Show, createSignal } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import type { PRReviewer, ReviewerState } from "./pr-types"
import { PRAvatar } from "./PRAvatar"
import { SectionHeading } from "./SectionHeading"

const REVIEWER_ICON: Record<ReviewerState, string> = {
approved: "circle-check",
changes_requested: "refresh",
commented: "edit",
changes_requested: "circle-x-outline",
commented: "comment",
pending: "dash",
}

Expand All @@ -30,9 +32,13 @@ export function PRReviewers(props: { reviewers: PRReviewer[] }) {
<For each={props.reviewers}>
{(reviewer) => (
<div class="am-pr-panel-reviewer am-pr-row" data-state={reviewer.state}>
<Icon name={REVIEWER_ICON[reviewer.state]} size="small" class="am-pr-reviewer-icon" />
<PRAvatar author={reviewer.login} avatar={reviewer.avatar} />
<span class="am-pr-reviewer-login">{reviewer.login}</span>
<span class="am-pr-reviewer-state">{REVIEWER_LABEL[reviewer.state]}</span>
<Tooltip value={REVIEWER_LABEL[reviewer.state]} placement="top" class="am-pr-reviewer-state">
<span role="img" aria-label={REVIEWER_LABEL[reviewer.state]}>
<Icon name={REVIEWER_ICON[reviewer.state]} size="small" class="am-pr-reviewer-icon" />
</span>
</Tooltip>
</div>
)}
</For>
Expand Down
13 changes: 10 additions & 3 deletions packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -768,11 +768,15 @@
}

.am-pr-panel-reviewer {
gap: 6px;
gap: 8px;
padding: 3px 0;
font-size: var(--kilo-font-size-12);
}

.am-pr-panel-reviewer [data-component="avatar"] {
border-radius: 50%;
}

.am-pr-reviewer-icon {
flex-shrink: 0;
}
Expand All @@ -781,7 +785,7 @@
color: var(--vscode-testing-iconPassed, #34d399);
}
.am-pr-panel-reviewer[data-state="changes_requested"] .am-pr-reviewer-icon {
color: var(--vscode-charts-yellow, #fbbf24);
color: var(--vscode-testing-iconFailed, #f87171);
}
.am-pr-panel-reviewer[data-state="commented"] .am-pr-reviewer-icon {
color: var(--text-weak);
Expand All @@ -793,11 +797,14 @@
.am-pr-reviewer-login {
flex: 1;
color: var(--vscode-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

.am-pr-reviewer-state {
color: var(--text-weak);
flex-shrink: 0;
color: var(--text-weak);
}

.am-pr-panel-checks {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ import { DeferredPopover } from "../components/shared/DeferredPopover"
import { ProjectSelect } from "../../agent-manager/ProjectSelect"
import { PRComments } from "../../agent-manager/pr/PRComments"
import { PRConversation } from "../../agent-manager/pr/PRConversation"
import type { PRComment, PRTimelineItem } from "../../agent-manager/pr/pr-types"
import { PRPanel } from "../../agent-manager/pr/PRPanel"
import { PRReviewers } from "../../agent-manager/pr/PRReviewers"
import type { PRComment, PRReviewer, PRTimelineItem } from "../../agent-manager/pr/pr-types"
import { For, createSignal, onCleanup, onMount, type JSX } from "solid-js"
import type {
AgentProjectSnapshot,
Expand Down Expand Up @@ -2068,6 +2069,24 @@ const prComments: NonNullable<PRStatus["comments"]> = {
],
}

const prReviewers: PRReviewer[] = [
{ login: "marius-kilocode", state: "approved" },
{ login: "reviewer-changes", state: "changes_requested" },
{ login: "reviewer-comment", state: "commented" },
{ login: "reviewer-pending", state: "pending" },
]

export const PRPanelReviewers: Story = {
name: "PR panel — reviewers",
render: () => (
<StoryProviders noPadding>
<div style={{ background: "var(--vscode-editor-background)", width: "320px" }}>
<PRReviewers reviewers={prReviewers} />
</div>
</StoryProviders>
),
}

export const PRPanelComments: Story = {
name: "PR panel — review comments",
render: () => (
Expand Down
Loading