Repository navigation
Let the server push notifications to clients, starting with a heartbeat - #303
Merged
Merged
Conversation
Until now every message was a reply to a client request. A client can now call RPCHandler.Subscribe and the server writes JSON-RPC 1.0 notifications (id null, named by method) to the same stdout as its replies: a Server.Heartbeat every interval (default 10 s, 1 s to 5 min), and anything later sent through Notifier.Notify. Replies and notifications share one locked writer, so a line is never spliced into another. Unsubscribe stops them. A client that never subscribes never sees one, so the TUI, which reads one line per call, is unaffected. The web bridge subscribes at startup (CRS_HEARTBEAT_SECONDS overrides the interval), keeps notifications out of its reply map, and relays them to the browser as Server-Sent Events on GET /api/events. The page shares one EventSource among its listeners; the top bar shows a dot while heartbeats arrive and "Server not responding" after three missed ones or a dropped stream. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M3QMq6pKobmhf6euQvZf7T
This was referenced Oct 26, 2026
C-Hipple
added a commit
that referenced
this pull request
Oct 26, 2026
* Refresh the web client live when a webhook delivery changes a PR 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 * Follow #305's change events in the live refresh - 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Until now, the server only ever sent replies to client requests. This PR gives it a way to send messages first, without the client polling: a client calls
RPCHandler.Subscribe, and the server writes JSON-RPC 1.0 notifications (id: null, named bymethod) to the same stdout as its replies. The first notification isServer.Heartbeat, sent every interval. The web client relays these to the browser and shows whether the server is still alive.Clients must opt in. A client that never subscribes never gets a notification, so the TUI, which reads exactly one line per call, keeps working. Emacs and the Chrome extension also don't subscribe and are unchanged.
Changes
server/notify.go:Notifiersends heartbeats (seq,time,started_at,interval_seconds) every interval: 10 s by default, clamped to 1 s – 5 min.Notifier.Notify(method, payload)is the hook for future events.Subscribestarts the heartbeats or changes their interval.Unsubscribestops them, and nothing is sent after its reply. If a write fails, the client is unsubscribed instead of failing on every tick.server/server.go: replies and notifications go through one locked writer (syncWriter), so two lines can't get mixed together.bun_client/server.ts+server_events.ts: the bridge subscribes at startup (CRS_HEARTBEAT_SECONDSoverrides the interval) and sends any message with amethodtoServerEventHubinstead of the reply map. The hub relays each one as a Server-Sent Event onGET /api/events. That connection has Bun's idle timeout turned off. A newly opened stream gets the latest heartbeat right away if the next one isn't due yet.server_events.tskeeps oneEventSourcethat all listeners on the page share (serverEvents.on(method, cb)).useServerHeartbeatandServerStatusshow a small green dot in the top bar while heartbeats arrive. After three missed heartbeats, or if the stream drops, it shows Server not responding until the next one arrives. Nothing shows before the first heartbeat, so an oldercrsthat doesn't send them isn't reported as down.crsanswersSubscribeand sends a heartbeat every second.backend.setHeartbeat(false)stops them. The newheartbeat.e2e.tscovers live → lost → live, plus the raw SSE relay.Subscribe/Unsubscribeindocs/protocol.md;building_clients.md,web_client_features.md, the e2e README andCLAUDE.md.Possible follow-ups: the plugin and AI-output polling hooks (
usePluginOutputs,useAIBackgroundRuns,useAIReport) could switch to pushed events throughNotify/serverEvents.on. Emacs could also subscribe.Test Plan
go build ./...passesgo test ./...passes. Newserver/notify_test.goruns under-raceand includes a test of the real JSON-RPC codec, with 200 large replies interleaved with 1 ms heartbeats through a writer that splits writes into chunks. With the lock bypassed, that test fails.bun run test(449 pass),bun run type-check,bun run type-check:e2e,bun run lint,bun run format:checkbun run test:e2e: 83 passed, 2 skipped (the opt-in real-LSP specs). Run against the preinstalled Chromium because the sandbox's build doesn't match the pinned Playwright.crs --serverover stdio: theSubscribereply,Server.Heartbeateach second, theUnsubscribereply, then no more output.Generated by Claude Code