Skip to content

Refresh the web client live when a webhook delivery changes a PR - #304

Merged
C-Hipple merged 4 commits into
mainfrom
ccr-a49991e5-oalk19
Oct 26, 2026
Merged

C-Hipple merged 4 commits into
mainfrom
ccr-a49991e5-oalk19

Conversation

@C-Hipple

@C-Hipple C-Hipple commented Oct 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

With [Webhooks] configured, the server already patches its caches from each GitHub delivery, but an open page only found out on a manual Sync or a reload. This uses the server → client notifications from #303: for every PR a delivery changes, the server sends a Server.PRUpdated notification, and the web client re-reads that PR in place and shows a short toast saying what happened ("alice approved", "bob commented on server/notify.go", "Checks failed: build").

The aim is for this to be as seamless as possible:

  • The refresh reads from the caches the delivery just wrote, so it never waits on GitHub, and it shows no loading state.
  • Open threads, expanded hunks, the comment being written and the feedback being typed are all kept.
  • A new diff (after a push) waits while an inline comment is open, since the comment is anchored to a diff row. A toast says the page will refresh once the comment is done, and closing the form applies the update.
  • Bursts are batched: a push sets off one delivery per check, so updates are collected for 400 ms from the first one and the page refreshes once.
  • If the /api/events stream drops and reconnects, the review and the list re-read quietly, since anything sent while it was down is lost (as Push plugin and AI result changes to clients instead of having them poll #305 does for plugin and AI changes).
  • CI is announced only when it settles (passing, or newly failing). Checks starting update the CI line without a toast.
  • The user's own changes (own: the actor is the configured GithubUsername) refresh without a toast, since the UI already reported them.
  • On the review list, a changed row refreshes in place and gets a brief accent highlight. The toast names the PR (widgets#42: alice edited the title).
  • With a filtered diff on screen (Serve the diff since my last review or interaction, precomputed and cached #306's diff-range select), the new full diff is kept for comment positions. The filtered diff is asked for again quietly after a push or the user's own review or comment, either of which can move it, and is swapped in only if the answer changed.

Changes

  • workflows/webhook_changes.go (new): PRChange, SetPRChangedHook, and the summary helpers (pullRequestSummary, reviewSummary, reviewCommentSummary, reviewThreadSummary, issueCommentSummary, ciOutcome / ciSummaryChange).
  • workflows/webhook_events.go: each apply function's heldPR now carries changes (the cache fields it wrote, head after a refetch, pr for a pull_request payload) and a summary. applyWebhookEvent hands each changed PR to the hook. patchCachedCI returns what it wrote, plus a summary when the head's CI settles. A delivery that changed nothing (PR not held, cache missing) sends nothing.
  • workflows/webhook_membership.go: when the re-check rewrites section rows, it sends a sections change so the list can pick up adds and drops.
  • server/notify.go: PRUpdatedMethod, the PRUpdated payload, and NotifyPRChanged, alongside Push plugin and AI result changes to clients instead of having them poll #305's plugin and AI notifications. main.go wires it up in server mode.
  • bun_client/frontend/src/server_events.ts: parsePRUpdate, changesPR, prUpdateMessage (newest three per burst, …and N more), onPRUpdates (the batching), and onReconnect (a reconnect after a drop, skipping the first connect). Matching a PR uses Push plugin and AI result changes to clients instead of having them poll #305's isChangeFor. hooks/usePRUpdates.ts wraps them for React.
  • Review.tsx: refreshLive, which holds a new diff while a comment is being written, keeps unchanged state identity, and drops a stale response if a newer refresh has started. Plugin output isn't reloaded after a push, since with Push plugin and AI result changes to clients instead of having them poll #305 the re-run plugins announce themselves. It builds on Serve the diff since my last review or interaction, precomputed and cached #306's fullDiffRef / applyFullDiff, and loadFilteredDiff gains a quiet mode (no spinner, no error toast, and the diff left alone when the answer hasn't changed). PRList.tsx: refreshes its rows in place, skips updates about PRs it doesn't show (unless it's a section change), and highlights changed rows (.crs-row-updated, a steady tint under prefers-reduced-motion). App.tsx passes the toast to the list.
  • e2e: E2E.UpdatePR in the fake backend and backend.updatePR in the harness simulate a delivery. live_updates.e2e.ts covers seven cases: in-place review refresh, the user's own change, a diff held for an open comment, a sections-only update, a push with a filtered diff showing, another user's comment with a filtered diff showing, and the list.
  • Docs: protocol.md (Server.PRUpdated), webhooks.md ("Clients Follow Deliveries"), web_client_features.md §3.18, building_clients.md, CLAUDE.md, e2e/README.md.

Since #305 the Emacs client and the Chrome extension subscribe too, but they only act on the plugin and AI notifications and ignore other methods, so Server.PRUpdated doesn't affect them.

Test Plan

  • go build ./... passes
  • go test ./... passes (new: workflows/webhook_changes_test.go, TestNotifyPRChangedSendsAPRUpdated; also under -race for the new tests)
  • bun run lint, bun run format:check, bun run type-check, tsc -p e2e in bun_client/
  • bun run test in bun_client/ (new server_events.test.tsx cases for parsing, messages, batching and reconnects)
  • Full bun_client e2e suite after merging main (97 passed, 2 opt-in real-LSP specs skipped), including the seven live_updates.e2e.ts specs. I confirmed the sections-only spec fails when the review's changesPR filter is removed.
  • Manual: with [Webhooks] on, open a review and comment / approve / push from github.com, and watch the page update with a toast.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU

With [Webhooks] configured the server already patches its caches from each
delivery, but an open page only found out on a manual sync or a reload. Now
the server pushes a Server.PRUpdated notification (over the channel #303 added)
for every PR a delivery changed, and the web client re-reads the PR in place
and says what happened.

- workflows: each apply function reports what it rewrote for each PR
  (cache fields, "head" after a refetch, "pr" for a pull_request payload)
  and a few-word summary ("alice approved", "bob commented on x.go",
  "alice pushed new commits"). CI is announced only as it settles (passing,
  or failing where it wasn't); a check starting refreshes quietly.
  applyWebhookEvent hands each changed PR to a hook registered with
  SetPRChangedHook, and the membership re-check sends a "sections" change
  when it rewrote rows. Deliveries that changed nothing send nothing.
- server: NotifyPRChanged sends Server.PRUpdated (owner, repo, number, event,
  changes, head_sha, actor, own, summary, time) to a subscribed client;
  main.go wires it in server mode. "own" marks the configured user's changes.
- web client: onPRUpdates/usePRUpdates batch a burst (400 ms from the first
  update). The review re-calls GetPR without a loading state and takes in
  comments, reviews, metadata and a changed diff, keeping open threads,
  expanded hunks, drafts and the feedback being typed; a new diff waits
  while an inline comment is being written. The list re-reads its rows in
  place and highlights the changed ones. A toast says what happened; the
  user's own changes and quiet ones refresh silently.
- e2e: E2E.UpdatePR / backend.updatePR play a delivery; live_updates.e2e.ts
  covers the review, the held diff, the user's own change, sections-only
  updates and the list.
- docs: protocol.md (Server.PRUpdated), webhooks.md, web_client_features.md
  §3.18, building_clients.md, CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU
Conflicts in Review.tsx and CLAUDE.md. #306 keeps the PR's full diff in
fullDiff/fullDiffRef/applyFullDiff, which did the job of this branch's
serverDiff/applyServerDiff, so the live refresh now builds on those: it
compares the new payload with fullDiffRef and takes the new diff in through
applyFullDiff, which leaves a filtered diff on screen. A filtered diff is then
asked for again (loadFilteredDiff's new quiet mode: no spinner, no error
toast, and the diff left alone when the answer hasn't changed) after a push or
the user's own review or comment, either of which can move it. Two e2e specs
cover a push with a filtered diff showing and another user's comment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU
Conflicts were additive on both sides: server/notify.go keeps main's
Server.PluginOutputChanged / Server.AIOutputChanged beside this branch's
Server.PRUpdated (NotifyPRChanged now copies notifier the way main's helpers
do); server_events.test.tsx imports both sets of helpers; protocol.md,
building_clients.md, web_client_features.md and CLAUDE.md describe all three
notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU
- Read again, quietly, when the event stream comes back after dropping
  (onReconnect, used by usePRUpdates), since a Server.PRUpdated sent while it
  was down is lost, as #305 does for plugin and AI changes. Unlike
  useServerChanges it skips the first connect, which only repeats the read
  the page made on load.
- Stop reloading the plugin output after a push: the plugins the push
  re-runs now announce themselves, and the reload put the panel through a
  loading state.
- Use #305's isChangeFor in place of the identical isUpdateFor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AMPYZg3x4vXfFzqgvJ1AbU
@C-Hipple
C-Hipple merged commit f55aa04 into main Oct 26, 2026
8 checks passed
@C-Hipple
C-Hipple deleted the ccr-a49991e5-oalk19 branch October 26, 2026 19:22
C-Hipple pushed a commit that referenced this pull request Oct 26, 2026
Brings in the live refresh (#304) and the pushed plugin/AI changes (#305).

Conflicts:
- CLAUDE.md: kept main's notification and live-refresh text, with this
  branch's comment positions on filtered diffs in place of "a filtered
  diff doesn't share" them / "skips inline comments while one is displayed".
- Review.tsx: kept main's unlessSame / sameFilteredDiff helpers and this
  branch's LINE_NOT_IN_FULL_DIFF; COMMENTS_NEED_FULL_DIFF and NO_COMMENTS
  are gone, as nothing uses them since comments show on filtered diffs.

main's live_updates e2e specs counted one file in #42's filtered diff and
pushed a change to a line it no longer shows. The fake's filtered diff now
takes a PR's ReplaceInDiff pushes as the full diff does (both end at the
head), and the specs count its two files and push a line both diffs show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSunxLHtpVEQmGaGyiP217
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants