feat(service,features,panel): the member's pending review comments on their lines, with Edit and Delete

Merged
DA
10d ago
+1675-50

Summary

The orchestrator asked for the member's own pending review comments to show in Files changed on their lines, as GitHub shows them: a card marked Pending, in the member's own name, with Edit and Delete. They should use PR 1's placement (#4008, merged) and appear in the minimized chip's counts. Adding a comment should refresh them, and Submit or Discard should clear them. They should also show in the Conversation where that's cheap: here, a list in the Review panel.

This changes the hosted service, so it needs a ./scripts/run.sh --preview run before merging. Auto-merge is held until that's done.

Read (member token, parallel, dropped on failure, never cached)

  • viewerPendingReviewOf still runs beside the conversation read on the member's own token. It is still one GraphQL query, and a failure still leaves pendingReview out.

  • Beside reviews(states: PENDING), it now also reads pullRequest.reviewThreads(last: 100). Each thread brings id path line startLine diffSide startDiffSide, its first comment's state (opening), and its last 50 comments (id state body bodyHTML createdAt author pullRequestReview { id }). The read keeps the comments that are PENDING and belong to the member's own pending review.

  • Why not REST? I checked on GitHub with a throwaway pending review on #4008, deleted straight after:

    • GET pulls/{n}/reviews/{id}/comments answers line, side, start_line and start_side as null for a pending comment.
    • GET pulls/{n}/comments leaves pending comments out entirely.
    • GraphQL reviewThreads includes the author's own pending threads, with sides.
  • Rate limit: GitHub counts one request for reviews, one for reviewThreads, and two per thread for its first comment and its last comments (up to 200). That's 202 requests, divided by 100, so the query costs 2 points (up to ~5,200 nodes, far under the 500,000 cap). The first comment is read so a thread whose last 50 comments are all pending replies still counts as published (Devin's finding).

  • More than 100 threads: a pending comment on a thread older than the newest 100 is missed, so it isn't drawn. The Review count is still right, because it comes from the review's own comments.totalCount. A pending comment the member just made starts a new thread, which is among the newest, so in practice only an old pending reply on a long-lived PR could be missed.

  • Wire: pendingReview stays { comments }, plus an optional drafts (at most MAX_PENDING_COMMENTS = 100). Each draft carries:

    • id, plus threadId for a reply on a published thread
    • by, named as the conversation's people are (the member)
    • path, line, startLine, side, startSide
    • html, body, at

    It's an optional key, so a newer client reading an older service decodes. Goldens were updated with LUKE_UPDATE_FIXTURES=1; the diff only adds lines: the drafts items in the two conversation answer schemas, and the two new actions in the two action request schemas.

Draw (shares PR 1's placement)

  • pendingThreadsOf(threads, drafts) (tasks/pending-comments.tsx):
    • A pending reply on a published thread becomes that thread's last comment, drawn inside its card, as GitHub does.
    • Every other draft becomes a ReviewThread of its own, keyed by the comment's own id.
  • The merged list is what Files changed is handed as its threads. So threadsOn, gapLines (PR 1's gap placement), the minimized chip and its counts, the tree's thread counts, hover lighting and the lit range all apply unchanged.
  • PendingCommentsProvider tells each comment whether it's pending. A pending one draws the member's head, a Pending badge, its words, and Edit and Delete. A thread a draft starts wears Pending in its head and has no ⋯ menu (it has no page or id yet) and no Reply or Resolve.

Act

  • There are two new actions: EDIT_REVIEW_COMMENT {commentId, body} → updatePullRequestReviewComment, and DELETE_REVIEW_COMMENT {commentId} → deletePullRequestReviewComment. Both go through memberPullRequestWrite, on the member's token, never the App's.
  • Check before each mutation (ownPendingComment): it reads node(id) { ... on PullRequestReviewComment { viewerDidAuthor state pullRequest { id } } } on the member's token. It refuses, with "That comment is not one of your pending review's on this pull request.", unless:
    • viewerDidAuthor is true,
    • state is PENDING,
    • and the pull request is this one.
  • Edit opens the shared MarkdownField in place. Cancel and Escape keep nothing, ⌘↩ saves, and GitHub's refusal stands under the comment in its words.
  • Delete asks first with the destructive ConfirmDialog.
  • Adding a line comment, Edit, Delete, Submit and Discard all re-read the conversation, so the drafts refresh, and they clear once the review is submitted or discarded.

Conversation

  • The Review panel lists the pending comments under "N pending comments will be published". Each shows its file and lines, then the same Pending comment with Edit and Delete. A Delete's question over the window leaves the panel open.

Screenshots

Real AgentPullRequestTab with the real diff bundle and fixture reads.

lightdark
A new pending thread on line 5, and a pending reply inside the thread on line 22 (a gap line, so it stands above until opened)
The gap opened: the thread with its pending reply moves onto line 22 (PR 1's placement)
The Review panel's Pending list
Edit open in place

Evidence

  • pnpm check:affected runs out of memory in this sandbox, so I ran the per-package checks instead:
    • tsc --noEmit for panel, service and features, and the desktop typecheck: clean
    • @luke/web typecheck (5.5 GB heap): clean
    • ./scripts/oxlint.sh: 0 errors
    • biome check .: clean
    • pnpm knip: clean
    • ./scripts/repository-checks.sh: passed
    • pnpm discarded-effect: clean
  • vitest run --project panel: 233 files, 2508 tests passed. vitest run packages/service/src/agents packages/service/src/github packages/features/src: 130 files, 1516 tests passed (the hosted-wire goldens among them).
  • cd apps/desktop && node scripts/build.mjs: built within the bundle budget.
  • New and updated tests:
    • coding-agents-app.test.ts:
      • The pending read, now with threads. A pending reply on a published thread names that thread, a new pending thread on a left-side range keeps its range, another member's pending comment and published comments are left out, and the member's own token reads it.
      • New: edit and delete of one's own pending comment reach GitHub on the member's token. Each is refused in words for someone else's comment, a published one, one on another pull request, and an unknown id.
    • pending-comments.test.ts (new, 2):
      • A reply goes into its thread, and a reply on a thread the conversation didn't hold stands on its own line.
      • A pending comment in a gap waits above the diff and moves onto its line once the gap opens.
    • agent-pull-request-tab.test.ts (new, 1): drafts stand on their lines, with the reply adding no card of its own; Edit sends new words, while Cancel sends nothing; Delete asks first; the Review panel lists both.

Invariants

  • ./scripts/check.sh green. (CI)
  • JSON Schema goldens changed with LUKE_UPDATE_FIXTURES=1; the diff only adds lines (explained above).
  • Every behavior change has, in this PR, the test that fails without it; no change-detector test added.
  • Gateway envelope goldens unchanged.
  • No new as type assertion.
  • No Effect.runPromise/runSync/runFork outside the runtime edges.
  • No new setTimeout/setInterval/new Promise/AbortController/fs.watch.
  • No Effect, Stream, or Layer written as a statement and dropped.
  • Test count: +4 new tests (1 service, 2 pending-comments, 1 pull request tab), 1 service test extended.
  • No hand-rolled file replaced.
  • No CLAUDE.md/AGENTS.md sentence names a changed part.
  • PRIVACY.md unchanged: the pending comments are the member's own, read on their own token for them alone, as the count already was.
  • Package deps unchanged.
  • Barrel/door rule unaffected.

Notes

  • Devin's three findings are fixed in d249be3b1, each with a test. ⌘↩ in a listed comment's edit saves only that comment and never submits the review. Pending replies stay inside long threads. A Delete question asked in Files changed goes when another view is chosen.

  • Preview run needed before merging (./scripts/run.sh --preview, docs/WORKFLOW.md step 3).

  • If the member deletes their review's last comment, the pending review itself remains, as GitHub keeps it. Review then counts 0 comments, and Discard still clears it.

Made with Luke