feat(desktop,service,features): add and remove a reaction in the Pull request tab's Conversation
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-reactionandremove-reaction, each with{ subject, content }.subjectis the GraphQL node id that the conversation read now carries on every entry, thread comment and the description. actionWritesends both throughmemberPullRequestWrite. They run on the member's own GitHub token and never fall back to the Luke GitHub App.reactOnPullRequestfirst 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 (viaThreadWritesProvider). 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
-
viewerHasReactedis 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
userIdand 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. - The only one in the service is the published-answer cache. It is keyed by
Screenshots
Fake data from a throwaway harness. From left to right:
- The member's own reactions highlighted, including the thread comment.
- Add reaction.
- A removal in flight: the 👀 pill pulses, the row waits, and the pill still reads Open.

Wire and goldens
These goldens were re-recorded with LUKE_UPDATE_FIXTURES=1:
pullRequestActionRequestSchemaandcodingAgentPullRequestActionParamsSchemagain the reaction branch.- The conversation answer gains an optional
subject, an optionaldescriptionSubject, and an optionalmineon 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 isprivate, 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.
- Two members read the same conversation and each gets their own
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).
- Every entry, thread comment and the description carries its subject, and the member's read marks
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
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-reactionandremove-reactionactions 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
viewerHasReactedis 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.