diff --git a/.changeset/pr-comment-actions.md b/.changeset/pr-comment-actions.md new file mode 100644 index 00000000000..7aa5f83393d --- /dev/null +++ b/.changeset/pr-comment-actions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Add Agent Manager PR comment actions: resolve/unresolve review threads, jump to comments section, and scroll-to-top for PR diff view. diff --git a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts index d94a4511fc2..171baccec0a 100644 --- a/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/PRStatusPoller.ts @@ -5,8 +5,8 @@ import { execWithShellEnv } from "./shell-env" import { execGhRead } from "./gh" import { classifyPRError } from "./git-import" import type { Semaphore } from "./semaphore" -import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./am-pr-utils" -import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" +import { parsePRResult, checkStatus, formatCheckDuration, parseComments, parseReviewers } from "./pr/am-pr-utils" +import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./pr/am-pr-types" interface PRStatusPollerOptions { getWorktrees: () => Worktree[] @@ -485,7 +485,9 @@ export class PRStatusPoller { repository(owner: $owner, name: $repo) { pullRequest(number: $number) { reviewThreads(first: 100) { + totalCount nodes { + id isResolved comments(first: 1) { nodes { @@ -496,6 +498,7 @@ export class PRStatusPoller { line url createdAt + diffHunk } } } @@ -520,8 +523,10 @@ export class PRStatusPoller { { cwd, timeout: 15_000 }, ) const pr = JSON.parse(stdout)?.data?.repository?.pullRequest - const comments = parseComments((pr?.reviewThreads?.nodes ?? []) as GhThread[]) - return { total: comments.length, unresolved: comments.filter((c) => !c.resolved).length, comments } + const threads = pr?.reviewThreads + const comments = parseComments((threads?.nodes ?? []) as GhThread[]) + const totalCount = threads?.totalCount ?? comments.length + return { total: totalCount, unresolved: comments.filter((c) => !c.resolved).length, comments } } catch (err) { this.options.log("Failed to fetch PR comments:", err) return { total: 0, unresolved: 0, comments: [] } diff --git a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts index 42370a5e721..40328a63c26 100644 --- a/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/pr-status-bridge.ts @@ -9,6 +9,7 @@ import type { AgentManagerOutMessage, PRStatus } from "./types" import type { Disposable } from "./host" import type { Semaphore } from "./semaphore" import { PRStatusPoller } from "./PRStatusPoller" +import { resolveComment, unresolveComment } from "./pr/PRActions" interface PRBridgeHost { getWorktrees(): Worktree[] @@ -85,6 +86,49 @@ export class PRStatusBridge { if (url) this.host.openExternal(url) return true } + const isResolve = m.type === "agentManager.resolveComment" + const isUnresolve = m.type === "agentManager.unresolveComment" + if (isResolve || isUnresolve) { + const id = m.worktreeId as string + const threadId = m.threadId as string + const wt = this.host.getWorktrees().find((w: Worktree) => w.id === id) + const cwd = wt?.path ?? this.host.getWorkspaceRoot() + const resultType = isResolve ? "agentManager.resolveCommentResult" : "agentManager.unresolveCommentResult" + if (!cwd) { + this.host.log("resolveComment: no cwd for worktree", id) + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: false, + }) + return true + } + const action = isResolve ? resolveComment : unresolveComment + action(threadId, cwd).then( + () => { + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: true, + }) + // Refresh PR data after successful mutation to get updated comment state + this.poller.refresh(id) + }, + (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + this.host.log(`${resultType} failed: ${msg}`) + this.host.postToWebview({ + type: resultType, + worktreeId: id, + threadId, + success: false, + }) + }, + ) + return true + } return false } diff --git a/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts b/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts new file mode 100644 index 00000000000..3dd92b9ff7c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr/PRActions.ts @@ -0,0 +1,30 @@ +import { execGhRead } from "../gh" +import { GH_MUTATION_TIMEOUT } from "./pr-constants" + +export async function resolveComment(threadId: string, cwd: string): Promise { + const mutation = `mutation($id: ID!) { resolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }` + try { + await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], { + cwd, + timeout: GH_MUTATION_TIMEOUT, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const stderr = (err as Record).stderr + throw new Error(`Could not resolve thread: ${msg}${stderr ? ` — ${stderr}` : ""}`) + } +} + +export async function unresolveComment(threadId: string, cwd: string): Promise { + const mutation = `mutation($id: ID!) { unresolveReviewThread(input: { threadId: $id }) { thread { isResolved } } }` + try { + await execGhRead(["api", "graphql", "-f", `query=${mutation}`, "-F", `id=${threadId}`], { + cwd, + timeout: GH_MUTATION_TIMEOUT, + }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const stderr = (err as Record).stderr + throw new Error(`Could not unresolve thread: ${msg}${stderr ? ` — ${stderr}` : ""}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts similarity index 88% rename from packages/kilo-vscode/src/agent-manager/am-pr-types.ts rename to packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts index 4cd818d7a09..506e0c727ab 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-types.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-types.ts @@ -1,4 +1,4 @@ -import type { PRState, ReviewDecision } from "./types" +import type { PRState, ReviewDecision } from "../types" // Raw shapes returned by `gh pr view --json` @@ -14,8 +14,10 @@ export interface GhComment { line?: number url?: string createdAt?: string + diffHunk?: string } export interface GhThread { + id?: string isResolved?: boolean comments?: { nodes?: GhComment[] } } diff --git a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts similarity index 97% rename from packages/kilo-vscode/src/agent-manager/am-pr-utils.ts rename to packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts index 5fe6e0fad5a..f0521a1a613 100644 --- a/packages/kilo-vscode/src/agent-manager/am-pr-utils.ts +++ b/packages/kilo-vscode/src/agent-manager/pr/am-pr-utils.ts @@ -1,4 +1,4 @@ -import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "./types" +import type { CheckStatus, PRComment, PRReviewer, ReviewerState } from "../types" import type { PRResult, GhThread, GhReviewRequest, GhReview } from "./am-pr-types" export function parsePRResult(json: string): PRResult | null { @@ -73,6 +73,7 @@ export function parseComments(threads: GhThread[]): PRComment[] { if (!first) continue items.push({ id: first.id, + threadId: thread.id ?? first.id, author: first.author?.login ?? "unknown", avatar: first.author?.avatarUrl, body: first.body ?? "", @@ -81,6 +82,7 @@ export function parseComments(threads: GhThread[]): PRComment[] { url: first.url, resolved: thread.isResolved ?? false, createdAt: first.createdAt ? new Date(first.createdAt).getTime() : undefined, + diffHunk: first.diffHunk, }) } return items diff --git a/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts b/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts new file mode 100644 index 00000000000..813a99b26ad --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/pr/pr-constants.ts @@ -0,0 +1,2 @@ +// Timeouts for gh CLI and GraphQL calls in PR actions +export const GH_MUTATION_TIMEOUT = 15_000 // 15 seconds — gh api graphql mutations diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 01c88ff1581..1d76a49abfe 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -61,6 +61,7 @@ export interface PRCheck { export interface PRComment { id: string + threadId: string author: string avatar?: string body: string @@ -69,6 +70,7 @@ export interface PRComment { url?: string resolved: boolean createdAt?: number + diffHunk?: string } export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" @@ -394,6 +396,14 @@ interface PRErrorOutMessage { error: "gh_missing" | "gh_auth" | "fetch_failed" } +interface CommentActionResultMessage { + type: "agentManager.resolveCommentResult" | "agentManager.unresolveCommentResult" + worktreeId: string + threadId: string + success: boolean + error?: string +} + interface ActionOutMessage { type: "action" action: string @@ -434,6 +444,7 @@ export type AgentManagerOutMessage = | DiffBranchesMessage | PRStatusOutMessage | PRErrorOutMessage + | CommentActionResultMessage | ActionOutMessage | RunStatusMessage | TerminalCreatedMessage @@ -758,6 +769,12 @@ interface OpenPRIn { url?: string } +interface CommentActionIn { + type: "agentManager.resolveComment" | "agentManager.unresolveComment" + worktreeId: string + threadId: string +} + interface OpenSessionsIn { type: "agentManager.openSessions" sessionIDs: string[] @@ -1050,6 +1067,7 @@ export type AgentManagerInMessage = | SetDiffBaseBranchIn | RefreshPRIn | OpenPRIn + | CommentActionIn | OpenSessionsIn | VisibleSessionIn | OpenFileIn diff --git a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts index 734b3128f6e..0b03bfd8379 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-status-bridge.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it } from "bun:test" +import { describe, expect, it, mock, beforeEach } from "bun:test" + +const resolveComment = mock(async (_threadId: string, _cwd: string) => {}) +const unresolveComment = mock(async (_threadId: string, _cwd: string) => {}) + +mock.module("../../src/agent-manager/pr/PRActions", () => ({ resolveComment, unresolveComment })) + import { PRStatusBridge } from "../../src/agent-manager/pr-status-bridge" import type { AgentManagerOutMessage, PRStatus } from "../../src/agent-manager/types" @@ -9,6 +15,7 @@ const pr: PRStatus = { state: "open", review: null, checks: { status: "none", total: 0, passed: 0, failed: 0, pending: 0, checks: [] }, + reviewers: [], additions: 0, deletions: 0, files: 0, @@ -16,7 +23,7 @@ const pr: PRStatus = { function harness(opts: { hasPersisted?: boolean } = {}) { const sent: AgentManagerOutMessage[] = [] - const worktrees: { id: string; prUrl?: string }[] = [] + const worktrees: { id: string; path: string; prUrl?: string }[] = [{ id: "wt1", path: "/repo/wt1" }] const bridge = PRStatusBridge.create({ getWorktrees: () => worktrees as never, getWorkspaceRoot: () => "/repo", @@ -186,3 +193,78 @@ describe("PRStatusBridge.reset", () => { expect(sent).toHaveLength(1) }) }) + +// --- resolveComment / unresolveComment message handling --- + +describe("PRStatusBridge.handleMessage resolveComment", () => { + beforeEach(() => { + resolveComment.mockReset() + unresolveComment.mockReset() + }) + + it("returns true for agentManager.resolveComment", () => { + const { bridge } = harness() + resolveComment.mockResolvedValueOnce(undefined) + expect(bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe( + true, + ) + }) + + it("returns true for agentManager.unresolveComment", () => { + const { bridge } = harness() + unresolveComment.mockResolvedValueOnce(undefined) + expect(bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" })).toBe( + true, + ) + }) + + it("posts resolveCommentResult with success:true on resolve success", async () => { + const { bridge, sent } = harness() + resolveComment.mockResolvedValueOnce(undefined) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.resolveCommentResult") + expect(result).toEqual( + expect.objectContaining({ + type: "agentManager.resolveCommentResult", + worktreeId: "wt1", + threadId: "PRT_1", + success: true, + }), + ) + }) + + it("posts unresolveCommentResult with success:true on unresolve success", async () => { + const { bridge, sent } = harness() + unresolveComment.mockResolvedValueOnce(undefined) + bridge.handleMessage({ type: "agentManager.unresolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.unresolveCommentResult") + expect(result).toEqual(expect.objectContaining({ success: true })) + }) + + it("posts resolveCommentResult with success:false on failure", async () => { + const { bridge, sent } = harness() + resolveComment.mockRejectedValueOnce(new Error("gh: Not Found")) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt1", threadId: "PRT_1" }) + await Promise.resolve() + const result = sent.find((m) => m.type === "agentManager.resolveCommentResult") + expect(result).toEqual(expect.objectContaining({ success: false })) + }) + + it("logs and returns early when no cwd found", () => { + const logged: unknown[] = [] + const bridge = PRStatusBridge.create({ + getWorktrees: () => [] as never, + getWorkspaceRoot: () => undefined, + postToWebview: () => {}, + updateWorktreePR: () => {}, + hasPersistedPR: () => false, + openExternal: () => {}, + log: (...args) => logged.push(args), + }) + bridge.handleMessage({ type: "agentManager.resolveComment", worktreeId: "wt-missing", threadId: "PRT_1" }) + expect(resolveComment).not.toHaveBeenCalled() + expect(logged.length).toBeGreaterThan(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts index ba2d8d80070..21bf23b56c2 100644 --- a/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/am-pr-utils.test.ts @@ -5,8 +5,8 @@ import { formatCheckDuration, parseComments, parseReviewers, -} from "../../src/agent-manager/am-pr-utils" -import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/am-pr-types" +} from "../../src/agent-manager/pr/am-pr-utils" +import type { GhThread, GhReviewRequest, GhReview } from "../../src/agent-manager/pr/am-pr-types" // --- parsePRResult --- @@ -220,6 +220,7 @@ describe("parseComments", () => { it("parses a resolved thread", () => { const threads: GhThread[] = [ { + id: "PRT_thread1", isResolved: true, comments: { nodes: [ @@ -239,6 +240,7 @@ describe("parseComments", () => { expect(parseComments(threads)).toEqual([ { id: "c1", + threadId: "PRT_thread1", author: "alice", avatar: "https://avatar", body: "looks good", @@ -247,10 +249,30 @@ describe("parseComments", () => { url: "https://url", resolved: true, createdAt: new Date("2024-01-01T00:00:00Z").getTime(), + diffHunk: undefined, }, ]) }) + it("uses comment id as threadId fallback when thread has no id", () => { + const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] + const result = parseComments(threads) + expect(result[0]?.threadId).toBe("c2") + }) + + it("parses diffHunk when present", () => { + const threads: GhThread[] = [ + { + id: "PRT_t1", + isResolved: false, + comments: { + nodes: [{ id: "c3", body: "fix this", diffHunk: "@@ -1,3 +1,4 @@\n context\n+new line" }], + }, + }, + ] + expect(parseComments(threads)[0]?.diffHunk).toBe("@@ -1,3 +1,4 @@\n context\n+new line") + }) + it("defaults missing author to 'unknown'", () => { const threads: GhThread[] = [{ isResolved: false, comments: { nodes: [{ id: "c2", body: "note" }] } }] expect(parseComments(threads)[0]?.author).toBe("unknown") @@ -259,6 +281,7 @@ describe("parseComments", () => { it("only uses the first comment of each thread", () => { const threads: GhThread[] = [ { + id: "PRT_t2", isResolved: false, comments: { nodes: [ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 80c6314e6c8..8b2742313e7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -539,7 +539,14 @@ const AgentManagerContent: Component = () => { const togglePRPanel = () => { setHistory(false) if (reviewActive()) closeReviewTab() + const opening = sidePanel() !== SidePanel.PR setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) + // Trigger an immediate refresh when opening so the panel shows fresh data + // rather than waiting for the next poll cycle + if (opening) { + const sel = selection() + if (sel && sel !== LOCAL) vscode.postMessage({ type: "agentManager.refreshPR", worktreeId: sel }) + } } const openSelectedPR = () => { @@ -2632,23 +2639,19 @@ const AgentManagerContent: Component = () => { /> - {(() => { - const data = activePR()! - return ( - setSidePanel(null)} - onOpenExternal={() => - vscode.postMessage({ - type: "agentManager.openPR", - worktreeId: data.selected, - url: data.pr.url, - }) - } - /> - ) - })()} + setSidePanel(null)} + onOpenExternal={() => + vscode.postMessage({ + type: "agentManager.openPR", + worktreeId: activePR()!.selected, + url: activePR()!.pr.url, + }) + } + /> }) { +function DiffHunk(props: { hunk: string }) { + const lines = () => props.hunk.split("\n") + return ( +
+ + {(line) => { + const text = line() + const cls = text.startsWith("+") + ? "am-pr-diff-line-add" + : text.startsWith("-") + ? "am-pr-diff-line-del" + : text.startsWith("@@") + ? "am-pr-diff-line-meta" + : "am-pr-diff-line-ctx" + return
{text || " "}
+ }} +
+
+ ) +} + +function CommentCard(props: { comment: PRComment; worktreeId: string }) { + const vscode = useVSCode() + + // Track pending action and any error from the result + const [pendingResolved, setPendingResolved] = createSignal(undefined) + const [actionError, setActionError] = createSignal(undefined) + + // Resolved shows pending state if exists, otherwise server state + const resolved = createMemo(() => pendingResolved() ?? props.comment.resolved) + + // Clear pending when server state matches (action confirmed by poll) + createMemo(() => { + const pending = pendingResolved() + if (pending !== undefined && pending === props.comment.resolved) { + setPendingResolved(undefined) + setActionError(undefined) + } + }) + + onMount(() => { + function handler(ev: MessageEvent) { + const msg = ev.data + const isResult = + (msg?.type === "agentManager.resolveCommentResult" || msg?.type === "agentManager.unresolveCommentResult") && + msg.worktreeId === props.worktreeId && + msg.threadId === props.comment.threadId + if (!isResult) return + if (!msg.success) { + // Only clear on error - success waits for poll to update props.comment.resolved + setPendingResolved(undefined) + setActionError( + msg.type === "agentManager.resolveCommentResult" + ? "Failed to resolve thread." + : "Failed to unresolve thread.", + ) + } + } + window.addEventListener("message", handler) + onCleanup(() => window.removeEventListener("message", handler)) + }) + + function toggle() { + setActionError(undefined) + const next = !resolved() + setPendingResolved(next) + vscode.postMessage({ + type: next ? "agentManager.resolveComment" : "agentManager.unresolveComment", + worktreeId: props.worktreeId, + threadId: props.comment.threadId, + } as never) + } + + return ( +
+ {(hunk) => } +
+ {props.comment.author} + + + {props.comment.file} + {`:${props.comment.line}`} + + + + Resolved + + +
+ {(err) =>
{err()}
}
+
+ +
+
+ + + Loading +
+ } + > + + +
+ + ) +} + +export function PRComments(props: { comments: NonNullable; worktreeId: string }) { const [open, setOpen] = createSignal(true) return ( <> @@ -21,28 +135,9 @@ export function PRComments(props: { comments: NonNullable />
- - {(comment: PRComment) => ( -
-
- {comment.author} - - - {comment.file} - {`:${comment.line}`} - - - - Resolved - - -
-
- -
-
- )} -
+ + {(comment) => } +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx index ec37c45940a..b37d72948d7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRPanel.tsx @@ -1,5 +1,5 @@ /** @jsxImportSource solid-js */ -import { Component, Show } from "solid-js" +import { Component, Show, createSignal } from "solid-js" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import type { WorktreeState } from "../../src/types/messages" @@ -16,11 +16,29 @@ import "./pr-panel.css" interface PRPanelProps { pr: PRStatus worktree?: WorktreeState + worktreeId: string onClose: () => void onOpenExternal: () => void } export const PRPanel: Component = (props) => { + let bodyRef: HTMLDivElement | undefined + let commentsRef: HTMLDivElement | undefined + const [showScrollTop, setShowScrollTop] = createSignal(false) + + function onScroll(e: Event) { + const el = e.target as HTMLDivElement + setShowScrollTop(el.scrollTop > 100) + } + + function scrollToTop() { + bodyRef?.scrollTo({ top: 0, behavior: "smooth" }) + } + + function jumpToComments() { + commentsRef?.scrollIntoView({ behavior: "smooth", block: "start" }) + } + return (
@@ -44,18 +62,31 @@ export const PRPanel: Component = (props) => {
-
- - - 0}> - - - {(body) => } - 0}> - - - - {(comments) => } +
+
+ + + 0}> + + + {(body) => } + 0}> + + + + {(comments) => ( +
+ +
+ )} +
+
+ + + +
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx index 48d9d2ee2e8..0b8ee91307a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/PRSummary.tsx @@ -5,9 +5,10 @@ import type { PRStatus } from "../../src/types/messages" interface PRSummaryProps { pr: PRStatus + onJumpToComments?: () => void } -function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string }> { +function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: string; isComments?: boolean }> { const rows = [] if (pr.checks.total > 0) { @@ -28,11 +29,18 @@ function summaryRows(pr: PRStatus): Array<{ icon: string; label: string; status: }) } - if (pr.comments && pr.comments.unresolved > 0) { + if (pr.comments && pr.comments.total > 0) { + const unresolved = pr.comments.unresolved + const total = pr.comments.total + const label = + unresolved > 0 + ? `${unresolved} unresolved comment${unresolved > 1 ? "s" : ""}` + : `${total} comment${total > 1 ? "s" : ""}` rows.push({ icon: "comment", - label: `${pr.comments.unresolved} unresolved comment${pr.comments.unresolved > 1 ? "s" : ""}`, - status: "warning", + label, + status: unresolved > 0 ? "warning" : "success", + isComments: true, }) } @@ -59,12 +67,28 @@ export function PRSummary(props: PRSummaryProps) {
- {rows().map((row) => ( -
- - {row.label} -
- ))} + {rows().map((row) => { + const isClickable = !!(row.isComments && props.onJumpToComments) + const rowProps = { + class: "am-pr-summary-row am-pr-row", + classList: { "am-pr-summary-row-link": isClickable }, + "data-status": row.status, + } + const content = ( + <> + + {row.label} + {row.isComments && props.onJumpToComments && Jump to comments ↓} + + ) + return isClickable ? ( + + ) : ( +
{content}
+ ) + })}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css index e505989acee..55c14665088 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-panel.css @@ -67,10 +67,39 @@ flex-shrink: 0; } -.am-pr-panel-body { +.am-pr-panel-body-wrap { flex: 1; + position: relative; + overflow: hidden; +} + +.am-pr-panel-body { + height: 100%; overflow-y: auto; padding: 8px 0; + overflow-anchor: none; +} + +.am-pr-scroll-top { + position: absolute; + bottom: 16px; + right: 16px; + width: 28px; + height: 28px; + border-radius: 50%; + border: 1px solid var(--vscode-foreground); + background: var(--vscode-editor-background); + color: var(--vscode-foreground); + cursor: pointer; + font-size: var(--kilo-font-size-14); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.6; +} + +.am-pr-scroll-top:hover { + opacity: 1; } .am-pr-panel-section { @@ -255,6 +284,87 @@ margin-left: auto; } +/* Diff hunk preview inside comment cards */ +.am-pr-diff-hunk { + font-family: var(--font-mono, monospace); + font-size: var(--kilo-font-size-11); + border-radius: 3px; + overflow: hidden; + margin-bottom: 6px; + border: 1px solid var(--vscode-panel-border); +} + +.am-pr-diff-line { + padding: 1px 6px; + white-space: pre; + overflow: hidden; + text-overflow: ellipsis; +} + +.am-pr-diff-line-add { + background: color-mix(in lab, var(--syntax-diff-add, #318430) 15%, transparent); + color: var(--syntax-diff-add, #318430); +} + +.am-pr-diff-line-del { + background: color-mix(in lab, var(--syntax-diff-delete, #da3319) 15%, transparent); + color: var(--syntax-diff-delete, #da3319); +} + +.am-pr-diff-line-meta { + color: var(--text-weaker); +} + +.am-pr-diff-line-ctx { + color: var(--vscode-foreground); +} + +/* Resolve button */ +.am-pr-resolve-row { + display: flex; + justify-content: flex-start; + margin-top: 15px; +} + +.am-pr-resolve-btn { + background: none; + border: 1px solid var(--vscode-panel-border); + border-radius: 3px; + color: var(--text-weak); + cursor: pointer; + font-size: var(--kilo-font-size-14); + padding: 4px 16px; +} + +.am-pr-resolve-btn:hover { + color: var(--vscode-foreground); + border-color: var(--vscode-foreground); +} +.am-pr-resolve-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.am-pr-resolve-error { + font-size: var(--kilo-font-size-11); + color: var(--vscode-testing-iconFailed, #f87171); + padding: 2px 0 4px; +} + +.am-pr-resolve-loading { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 16px; + font-size: var(--kilo-font-size-14); + color: var(--text-weak); +} + +.am-pr-resolve-spinner { + width: 14px; + height: 14px; +} + .am-pr-panel-comment-body [data-component="markdown"] { font-size: var(--kilo-font-size-12); color: var(--vscode-foreground); @@ -455,3 +565,39 @@ .am-pr-summary-label { color: var(--vscode-foreground); } + +.am-pr-summary-jump { + margin-left: auto; + font-size: var(--kilo-font-size-12); + color: var(--text-weaker); +} + +.am-pr-summary-row-link { + cursor: pointer; + border-radius: 3px; + padding-left: 2px; + padding-right: 2px; +} + +button.am-pr-summary-row-link { + all: unset; + display: flex; + width: 100%; + gap: 7px; + padding: 2px; + font-size: var(--kilo-font-size-12); + box-sizing: border-box; + cursor: pointer; +} + +button.am-pr-summary-row-link:focus-visible { + outline: 1px solid var(--vscode-focusBorder); +} + +.am-pr-summary-row-link:hover { + background: var(--vscode-list-hoverBackground); +} + +.am-pr-summary-row-link:hover .am-pr-summary-jump { + color: var(--vscode-foreground); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts index d5d0a89d6c6..99bf2d6146e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/pr/pr-types.ts @@ -15,6 +15,7 @@ export interface PRCheck { export interface PRComment { id: string + threadId: string author: string avatar?: string body: string @@ -23,6 +24,7 @@ export interface PRComment { url?: string resolved: boolean createdAt?: number + diffHunk?: string } export type ReviewerState = "approved" | "changes_requested" | "pending" | "commented" diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index d444f7fb881..eabae483720 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -991,6 +991,12 @@ export interface OpenPRMessage { url?: string } +export interface CommentActionMessage { + type: "agentManager.resolveComment" | "agentManager.unresolveComment" + worktreeId: string + threadId: string +} + export interface ApplyWorktreeDiffMessage { type: "agentManager.applyWorktreeDiff" worktreeId: string @@ -1532,6 +1538,7 @@ export type WebviewMessage = | SetDiffBaseBranchMessage | RefreshPRMessage | OpenPRMessage + | CommentActionMessage // legacy-migration start | RequestMigrationDataMessage | StartMigrationMessage