plans/agent-manager-pr-comments-ux.md
The PR panel in Agent Manager renders every GitHub review thread as one flat, always-expanded card. The card has weak actions and no way to hand a comment to the agent. Nine concrete defects:
opacity: 0.5 on the whole card
(pr-panel.css:248),
which dims the enabled Unresolve comment button
(PRComments.tsx:96).
No disabled attribute is ever set, so the control works but reads as dead.
This is the reported bug, and dimming is the wrong fix for "this thread is
done".PRComments.tsx:118-124).
On a PR with 20 resolved threads, the 2 threads that still need work are
buried behind hundreds of pixels of settled discussion.PRComments.tsx:80),
and resolve is a low-contrast bordered text button at the bottom
(pr-panel.css:302).
The local review comment cards are the opposite: a real action row with a
primary send action.Send to chat per comment and Send all to chat for the batch
(review-annotations.ts:425-432,
DiffPanel.tsx:443-459).
GitHub PR comments have no equivalent, so the user copies text by hand and
pastes it into the prompt as raw text.comments(first: 1) in
PRStatusPoller.ts:492
and the matching parser
(am-pr-utils.ts:69-89)
drop every reply. The reply usually holds the decision ("agreed, guard it"),
so both the UI and any send-to-agent payload lose the important half of the
conversation.isOutdated is not queried, so a comment against code
that no longer exists looks identical to a live one.gh error and
posts success: false without the error field that the message type
already declares
(pr-status-bridge.ts:120-130,
types.ts:399-405).
The card shows Failed to resolve thread. for a permission error, a network
error, and a stale thread ID alike.createMemo is used for a side effect
(PRComments.tsx:23)
to reconcile the optimistic state with the poll result.The target is GitHub's own review-thread model, not a new invention.
| GitHub behavior | Detail |
|---|---|
| Resolved thread collapses | In Files changed, a resolved thread renders as one compact row: avatar, author, the first line of the comment truncated, and a Resolved label. The body, the diff context, and the reply box are hidden. |
| The row is the disclosure | Clicking the collapsed row expands the full thread in place. Nothing is dimmed after it expands. |
| Unresolve is always live | The expanded thread keeps a normal, fully enabled Unresolve conversation button. GitHub never greys it out. |
| Resolve collapses immediately | Resolve conversation collapses the thread as soon as the mutation succeeds, which is the feedback that the action worked. |
| Outdated threads collapse too | A thread whose code changed gets an Outdated badge and starts collapsed. |
| Timeline summary | In Conversation, a resolved thread shows <author> marked this conversation as resolved with a Show resolved button. |
| Counts are visible | The header shows unresolved conversation counts, and the filter menu offers Unresolved / Resolved / All. |
| Replies stay in the thread | A collapsed row hints at thread size; expanding shows every reply in order. |
Two GitHub affordances are deliberately not copied: the conversation filter dropdown (a 320px inspector panel does not have room for it, and two grouped sections carry the same information), and reply composition (out of scope, see Non-goals).
──────────────────────────────────────────────
▾ COMMENTS 3 unresolved
┌────────────────────────────────────────┐
│ ➤ Send 3 unresolved to agent │ primary, full width
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ src/agent-manager/gh.ts:42 │ meta row
│ ┌──────────── diff hunk ─────────────┐ │
│ │ - const x = 1 │ │
│ │ + const x = 2 │ │
│ └────────────────────────────────────┘ │
│ @alice │
│ This throws when gh is missing. │ Markdown body
│ ▸ Show 2 replies │
│ ────────────────────────────────────── │
│ [➤ Send] [Resolve] ⧉ ↗ ⇥ │ action row
└────────────────────────────────────────┘
┌────────────────────────────────────────┐
│ src/pr/PRActions.ts:8 [Outdated] │ outdated: collapsed by default
│ ▸ @bob this mutation needs a timeout │
└────────────────────────────────────────┘
▸ Resolved (5)
With the resolved group open:
▾ Resolved (5)
▸ ✓ @bob nit: rename this variable
▸ ✓ @alice can we extract this helper?
▸ ✓ @bob good catch, fixed in a9f21c3
Resolved (N) group that starts closed. The section
heading keeps the existing N unresolved count.Outdated badge (GitHub parity).<button> with
aria-expanded, showing a check icon, @author, and the first line of the
body truncated to one line. Clicking it expands the same card the unresolved
threads use, with Unresolve in place of Resolve..am-pr-panel-comment-resolved { opacity: 0.5 }. Resolved state is
communicated by the collapsed row, the check icon, and a muted Resolved
badge. Muting applies only to the row preview text, never to a button.Comment resolved with an Undo
action that calls unresolve on the same thread. The resolved group is not
auto-expanded, so the panel does not jump.Loading line as today.Open on GitHub as the escape
hatch.Show N replies disclosure. Replies render with the same Markdown component,
indented, and are always included in a send-to-agent payload even when the
disclosure is closed.Order, left to right, always visible (not hover-only, because a hidden action in a narrow inspector is an undiscovered action):
| Action | Control | Behavior |
|---|---|---|
| Send to agent | Button variant="primary" size="small" with the send glyph | Sends this thread, with replies, to the active session or the active side terminal |
| Resolve / Unresolve | Button variant="secondary" size="small" | GraphQL mutation, unchanged transport |
| Copy | IconButton icon="copy" variant="ghost" | Copies the full thread as Markdown, not only the first body as today |
| Open on GitHub | IconButton icon="square-arrow-top-right" variant="ghost" | Uses comment.url, currently fetched and unused |
| Open file | IconButton icon="go-to-file" variant="ghost" | agentManager.openFile with the worktree session and line; hidden when the thread has no file |
This mirrors the local review comment card, which pairs text buttons for the primary verbs with ghost icon buttons for the utilities.
Send 3 unresolved to agent. It only renders when unresolved > 0.Send 3 unresolved to terminal, following the same destination rule the diff
panel already uses via activeTerminalId.Sent badge for the rest of the session, kept in a
per-worktree Set<threadId> in AgentManagerApp, next to the existing
reviewCommentsByContext state. Without it, a second click looks identical to
the first and silently duplicates the prompt.Sent 3 comments to the agent.LIMIT in the shared review payload), the diff hunk truncated to 40
lines, each body truncated to 4000 characters, and each thread limited to 10
replies. When anything is dropped, the toast says
Only the first 100 comments were sent.Do not paste raw text into the prompt. Convert a PR thread into the same review
comment payload the local diff review already uses, so the message renders as a
comment chip with a detail dialog
(ReviewComments.tsx)
and survives in session history through the part metadata
(review-comments.ts).
The chip gains two PR-specific affordances: a github icon instead of the
comment icon, and @author next to the file name. The detail dialog renders
the body with the shared Markdown component and shows the diff hunk in the
existing snippet slot.
Extend fetchComments in
PRStatusPoller.ts:478-529:
reviewThreads(first: 100) {
totalCount
nodes {
id
isResolved
isOutdated
diffSide
resolvedBy { login }
comments(first: 20) {
totalCount
nodes { id author { login avatarUrl } body path line originalLine url createdAt diffHunk }
}
}
}
originalLine gives a usable line for outdated threads where line is null.
diffSide maps LEFT to deletions and RIGHT to additions, which the
review payload needs. Thread pagination stays at 100 with no cursor loop, same
as today.
PRCommentExtend the three mirrored declarations
(src/agent-manager/types.ts:62-74,
webview-ui/src/types/messages/agent-manager.ts,
webview-ui/agent-manager/pr/pr-types.ts:16-28):
outdated: boolean
side?: "additions" | "deletions"
resolvedBy?: string
replies?: { id: string; author: string; body: string; createdAt?: number }[]
replyCount?: number // thread totalCount - 1, so ">20 replies" stays truthful
unresolved count semantics do not change.
Add a discriminated PR variant to
src/shared/review-comments.ts
rather than loosening the local shape:
export interface PRReviewCommentData {
id: string // GitHub thread node id
origin: "pr"
author: string
body: string
file?: string
line?: number
side?: "additions" | "deletions"
diffHunk?: string
url?: string
outdated?: boolean
replies?: { author: string; body: string }[]
}
export type ReviewCommentEntry = ReviewCommentData | PRReviewCommentData
Rules that keep this safe:
version stays 1. An entry without origin follows the existing strict
local validation, so every historical message keeps parsing.view() compares the message text against Markdown regenerated from parsed
data. The PR formatter must therefore be fully deterministic and every PR
field must round-trip through parseComment, or old and new messages fail the
prefix check and lose their chips.file and line are optional only for origin: "pr", because GitHub allows
a thread with no resolvable line. The formatter omits the missing fragment..., no NUL) applies to PR entries too.
The path comes from an API response, but it still reaches openFile.Markdown produced for a PR entry:
## Review Comments
**src/agent-manager/gh.ts** (line 42), PR comment by @alice:
```
@@ -39,7 +39,7 @@
- const x = 1
+ const x = 2
```
This throws when gh is missing.
> @bob: agreed, guard it
A thread with no file or line degrades to PR comment by @alice:.
The change landed as one pass over the files below.
| File | Change |
|---|---|
src/agent-manager/PRStatusPoller.ts | Query isOutdated, originalLine, and comments(first: 10) per review thread |
src/agent-manager/pr/am-pr-types.ts | isOutdated, originalLine on the raw gh shapes |
src/agent-manager/pr/am-pr-utils.ts | Map replies from the thread tail, carry outdated, fall back to originalLine, and add ghErrorReason plus commentsSig |
src/agent-manager/types.ts, webview-ui/agent-manager/pr/pr-types.ts | outdated, replies on PRComment |
src/agent-manager/pr-status-bridge.ts | Send the real gh failure reason in the existing error field |
The poll deduplication hash now includes a comment signature. Thread and unresolved counts alone do not change when a reply is added or a body is edited, so without the signature the panel would render replies it can never refresh.
resolvedBy and diffSide were not added. Neither is used by the UI or the
payload, so fetching them would only grow the query.
src/shared/review-comments.ts gained a PRReviewCommentData variant of
ReviewCommentEntry, guarded by origin: "pr". version stays 1, entries
without origin keep the old strict local validation, and every PR field
round-trips so the markdown prefix regenerates byte for byte.
webview-ui/agent-manager/pr/pr-comment-payload.ts converts a PRComment into
that payload with the caps, and also produces the copy text, the collapsed row
preview, and the githubUrl guard that keeps non-https urls away from both the
payload and openExternal. Hunk truncation keeps the @@ header and the tail,
and caps total characters, because a generated file can put a whole hunk on one
line and exceed the shared payload limit.
The payload deliberately carries no url and no reply identifiers. Nothing reads them, and unused metadata in a persisted message format only invites drift.
| File | Change |
|---|---|
pr/PRComments.tsx | Section, unresolved and resolved groups, optimistic resolve state, one result listener, bulk send |
pr/PRCommentCard.tsx | New: collapsed row and expanded card with replies and the action row |
pr/pr-panel.css | Row, group, badge, and action styles; the opacity: 0.5 rule is gone |
pr/PRPanel.tsx, AgentManagerApp.tsx | Pass activeTerminalId, onOpenFile, and onOpenUrl through |
chat/ReviewComments.tsx | Render PR entries: github icon, author, markdown body, hunk snippet |
webview-ui/src/stories/agent-manager.stories.tsx | PR panel - review comments story for visual review |
The card owns its collapsed row instead of a separate row component, and replies render inline when a card is open instead of behind a second disclosure. Both choices keep the component count low for the amount of behavior involved.
The list keeps Solid's Index, not For. Each poll allocates fresh PRComment
objects, so identity keying would remount every card and repeat the Pierre diff
and Markdown work on every status push. Sending reuses the existing
sendReviewComments helper instead of a second copy of the event envelope.
Resolved group, so the thread is visibly moved rather than gone,
and unresolve is one click away inside that group.Sent badge is component-local state, not lifted into AgentManagerApp.
It resets when the panel closes, which is enough to stop a double send from
looking identical to the first one.agentManager.pr.comment.* and agentManager.review.metaAuthor were added to
webview-ui/agent-manager/i18n/en.ts and all 20 other locales, which
tests/unit/i18n-keys.test.ts and agent-manager-i18n-split.test.ts require.
| Case | Handling |
|---|---|
| Thread with no file or line | Card shows the author only, no Open file, payload omits the location fragment |
| Outdated thread | Outdated badge, collapsed by default, originalLine used for the payload |
| More than 10 replies | Only the first 10 thread comments are fetched; the rest stay on GitHub, one click away from the card |
| Single-line hunk of a generated file | Truncated by characters so the payload stays inside the shared limit and keeps its chips |
| Comment url with a non-https scheme | Dropped by githubUrl, so it never reaches openExternal |
| More than 100 threads | Threads past 100 are not fetched today; keep that limit and do not silently claim completeness in the bulk label |
| No write access to the repo | Resolve fails; show the reason and keep Open on GitHub |
| Bulk send with an empty prompt | Existing autoSend semantics apply: it sends; with a draft present it appends to the draft |
| Bulk send while the agent is busy | PromptInput already skips auto-send when disabled, so the comments stay in the draft |
| Worktree switch mid-send | The payload targets the destination captured at click time |
| Poll arrives during a pending mutation | Existing optimistic reconciliation, moved to createEffect |
| Resolved group open state | Local signal, not persisted, same as the other PR sections |
| Level | Coverage |
|---|---|
tests/unit/am-pr-utils.test.ts | Reply mapping, outdated, originalLine fallback, ghErrorReason, commentsSig change detection |
tests/unit/review-comments-pr.test.ts (new) | PR payload format and parse round-trip, mixed payloads, malformed rejection, legacy compatibility, hunk line and character caps, url guard |
tests/unit/pr-comments-render.test.ts | Hunk still renders; the resolved thread sits collapsed behind the group; the row expands into a card; the unresolve control is enabled; send dispatches appendReviewComments with an origin: "pr" entry including replies |
tests/unit/am-pr-status-bridge.test.ts | The failure result carries the gh error message |
| Storybook | AgentManager / PR panel - review comments renders all four thread states for visual review and picks up a CI visual-regression baseline |
Commands: bun run typecheck, bun run lint, bun run test:unit from
packages/kilo-vscode/, plus bun run knip because new exports are added.
One minor changeset: resolved PR comments collapse like GitHub, PR comments
can be sent to the agent individually or in bulk, and PR comment cards gain
prominent copy, resolve, and open actions.