feat(service,features,desktop): reply on, resolve, and unresolve a review thread as the member who presses

Merged
DA
3d ago
+905-70

B2 of the "Review and comment" stack, after #3292 (B1, merged). The task: on review threads, reply with a Markdown comment, and Resolve / Unresolve, as stage-cli's review cards do. Wherever a thread is drawn, it should carry those controls.

Wire

  • The ReviewThread in the conversation's read now includes id, GitHub's GraphQL node id for the thread. It is optional, so a reply comes back from an older service without it, and the tab then offers no controls for that thread.
  • The service reads the id in the same reviewThreads query it already runs (github/pull-requests.ts).
  • Three new members on the action union in pull-request-write-wire.ts:
    • reply-to-thread { threadId, body }: the body can't be blank and is capped at GitHub's 65,536 characters.
    • resolve-thread { threadId }
    • unresolve-thread { threadId }

Service

These run through the same seam as before (memberPullRequestWrite), on the member's own token and never the App's.

  • threadOnPullRequest first reads the thread's pull request and checks it is this one. A thread from another pull request, or one the member can't see, is refused with "Could not resolve to a review thread on this pull request." and nothing is changed.
  • Then the service sends addPullRequestReviewThreadReply, which is published at once, or resolveReviewThread / unresolveReviewThread.

UI

pull-request-thread-actions.tsx puts the controls inside ThreadCard, under the thread's comments:

  • A field-shaped Reply… opens a Markdown reply with Cancel and Reply. ⌘↩ sends it and Escape cancels.
  • Beside it is Resolve conversation, or Unresolve conversation when the thread is already resolved.
  • Each thread has its own usePullRequestWrites, so GitHub's refusal shows under that thread, in GitHub's words. The conversation is read again after every answer.

The controls reach a thread through ThreadWritesProvider, which the tab provides. #3245 draws threads in Files changed through portals inside the tab, so threads drawn there will carry the controls as soon as they render ThreadActions. #3245 isn't on main yet. When it lands, <ThreadActions thread={thread} /> belongs in its shared ThreadComments, so its inline threads get the controls too. Until then the controls show in the Conversation.

Replying on a thread
GitHub's refusal under the thread

Tests

  • Service (coding-agents-app.test.ts, "changing an agent's pull request"):
    • a teammate replies, resolves and unresolves, each on their own token, with the exact mutations and variables;
    • a thread from another pull request is refused in words and nothing is changed;
    • a blank reply gets invalid-request and never reaches GitHub.
  • Service (pull-request-overview.test.ts): each thread carries GitHub's id.
  • Renderer (agent-pull-request-tab.test.ts, in the Conversation):
    • reply, then resolve, then unresolve go to the service by thread id, and the conversation is read again after each;
    • a refusal shows under its thread, and the reply's text stays;
    • a thread with no id offers nothing to press.
  • Goldens: four re-recorded with LUKE_UPDATE_FIXTURES=1: the conversation answer (wire and view) gains the optional id, and the action request and the view's params gain the three thread actions.

Evidence

pnpm check:affected runs out of memory in this sandbox, so I ran the parts separately and all passed:

  • tsc for service and desktop
  • the affected vitest files
  • ./scripts/oxlint.sh, biome, pnpm knip and ./scripts/repository-checks.sh
  • the desktop build, within its bundle budget

The screenshots come from a throwaway esbuild harness with a fake window.luke, captured with Playwright. The harness has been deleted. The empty thread preview in them is an artifact of the harness, not of this change.

Made with Luke


Devin Review


Summary by cubic

Adds reply, resolve, and unresolve controls to every review thread the Pull request tab draws, plus a Review entry to its tab bar. On GitHub it is the member who presses, on their own sign-in, never the app's.

  • Each thread's controls reply in Markdown and toggle resolution, named by GitHub's id, which the conversation's read now carries. A thread sends one change at a time; while one is out, a reply stands still, neither sent nor let go, so nothing is posted twice.
  • Threads drawn in Files changed carry the controls too, through a context the tab provides.
  • The service checks a thread is one of this pull request's before changing it; GitHub's refusals show under the thread in GitHub's words, and the conversation is read again after every write.
  • The review panel closes on outside presses, keeps its words when reopened, and bounds itself to a short window's room.