feat(desktop,service,features): add and remove a reaction in the Pull request tab's Conversation

Merged
DA
1d ago
+1410-101

C3 of the "Conversation filters and reactions" stack. It stacks on C2 (#3286, feat/conversation-reactions), which shows reactions read-only.

What

Reactions under the description, comments, reviews and review comments can now be pressed:

  • Pressing a reaction takes back the member's own if it is highlighted, and adds it if not.
  • Add reaction at the end of the row opens one compact row of GitHub's eight emoji, as GitHub and stage-cli draw it. Each emoji is a button named by its reaction (label and tooltip), pressed where it is the member's own; the arrow keys move along the row, Home and End to its ends.
  • Once GitHub makes a change, the row shows what it did until a fresh read replaces it, so a second press in that gap takes back what the first gave rather than repeating it.

Writes go through the member-writes seam (W1)

  • The wire has two new actions, add-reaction and remove-reaction, each with { subject, content }. subject is the GraphQL node id that the conversation read now carries on every entry, thread comment and the description.
  • actionWrite sends both through memberPullRequestWrite. They run on the member's own GitHub token and never fall back to the Luke GitHub App.
  • reactOnPullRequest first checks that the subject belongs to this pull request. This follows the same node-id comparison that reply and resolve use. Anything else gets the reply "Could not resolve to a comment on this pull request." and nothing is changed.

Pending state is on the reaction, not the state pill

  • Each reaction row sends its own change through its own usePullRequestWrites, the same way a review thread's controls do (via ThreadWritesProvider). The state pill is never involved.
  • While a change is out:
    • the pressed pill pulses (held still under reduced motion);
    • a reaction nobody had given yet shows as the member's own;
    • the row's other buttons wait.
  • If GitHub refuses, its words appear under the row. Once GitHub answers, the conversation is read again.

The member's own reactions are read on their own token

  • viewerHasReacted is read on the member's token (memberReadToken), never the App's.

  • The read covers the same scope as the conversation read: the description, the newest 100 comments and reviews, and the thread comments. Highlights on thread comments, which C2 had to leave out, come back this way.

  • It runs alongside the App's reads. Once those land, the conversation waits at most 750 ms for it.

  • If the member has no sign-in, or the read fails or is late, the answer goes out without mine. The client then falls back to C2's check of whether the member is among the named reactors.

  • Caching: I checked every cache on this path:

    • The only one in the service is the published-answer cache. It is keyed by userId and holds no reactions.
    • #3235's caches hold repository reaches and tokens, not answers.
    • The desktop keeps no cross-account store.

    To be safe, the overview responses are now sent private, no-store.

Screenshots

Fake data from a throwaway harness. From left to right:

  1. The member's own reactions highlighted, including the thread comment.
  2. Add reaction.
  3. A removal in flight: the 👀 pill pulses, the row waits, and the pill still reads Open.

Reactions Add reaction Pending

Wire and goldens

These goldens were re-recorded with LUKE_UPDATE_FIXTURES=1:

  • pullRequestActionRequestSchema and codingAgentPullRequestActionParamsSchema gain the reaction branch.
  • The conversation answer gains an optional subject, an optional descriptionSubject, and an optional mine on each reaction group.

PRIVACY.md now covers the member-token read and reaction writes.

Tests

  • coding-agents-app.test.ts:
    • Two members read the same conversation and each gets their own mine, each read on their own token. The response is private, no-store.
    • A teammate adds and removes a reaction on their own token. A subject from another pull request is refused, with no mutation sent.
  • pull-request-overview.test.ts:
    • Every entry, thread comment and the description carries its subject, and the member's read marks mine.
    • A member read that never answers leaves the conversation answered without mine (TestClock).
  • agent-pull-request-tab.test.ts: pressing to remove, the pending reaction with the state pill unchanged, the reaction as GitHub left it once read again; a second press before the re-read lands reverses the first; the compact row of eight, its arrow keys, a pick, and GitHub's refusal under the row.

pnpm check:affected ran out of memory here before, so I ran the per-package checks instead: tsc for desktop, service, features and host; vitest for features, host, service github and agents app, desktop tasks and main ipc (190 files); ./scripts/oxlint.sh; pnpm knip; ./scripts/repository-checks.sh; and pnpm discarded-effect. All pass.

Made with Luke


Devin Review


Summary by cubic

Makes the reactions under the pull request conversation interactive: pressing a reaction adds it, or takes back the member's own, and Add reaction opens one compact row of GitHub's eight.

Member-write seam

  • New add-reaction and remove-reaction actions go through the member-write seam, running on the member's own GitHub token, never the Luke app.
  • The service verifies the subject belongs to this pull request, refusing anything else.
  • Each reaction row has its own pending state: the pressed pill pulses, a new reaction shows as the member's, and the row's other buttons wait.
  • GitHub's refusal appears under the row, and the conversation is re-read once GitHub answers.
  • Add reaction opens one compact row of GitHub's eight, drawn with the shared icon button and wrapping where narrow; a pick keeps who else gave the reaction until the re-read replaces it.

Reading the member's own reactions

  • viewerHasReacted is read on the member's token alongside the app's reads, waited on up to 750 ms.
  • If the member has no sign-in or the read fails, the client falls back to checking named reactors.
  • Thread comment reactions light this way too, which the read-only pass had to leave out.
  • Every hosted answer is sent private, no-store, so a member's own reactions stay theirs.