Closed feature request

Issue and PR pages don't auto-update: replies land unseen, and refreshing to check costs you your draft

Ray · 1mo ago ·closed by Ray

Issue and PR pages are a static snapshot of the moment they were rendered. If someone replies while you are reading, you find out by pressing refresh — and if you were part-way through writing a reply, refreshing costs you the draft. There is no way to sit on a thread and watch it.

What's there today

The issue page is fully server-rendered (renderDiscussionView → T.discussionView), and so is the PR page (T.prView). The only thing that ever updates them is a full page load: posting a reply ends in location.reload() in static/app.js, and so do close, pin, lock, label, and inline edit. There is no SSE, no WebSocket, and no polling anywhere in the codebase — EventSource, text/event-stream, and WebSocket return zero hits across server.js, lib/, and static/.

So a reader on a live thread has exactly two options: refresh on a hunch, or miss the reply.

Why this is worth fixing

Threads on this wiki are increasingly where two people (or a person and an agent) work something out in near-real-time — a triage note, a repro, a correction. #192 is a recent case where replies piled up unseen. Email notification is the other half of that story and it is not always on. The page itself should be able to tell you.

The cost of the current behaviour is not just the missed reply, it is the refresh: the reply composer is a plain <textarea> with no draft persistence, so pressing F5 to check for news throws away whatever you had typed. Readers learn not to check.

What I'd like

An open issue or PR page keeps itself current — new replies appear, edits and deletes land, the status badge follows a close/reopen — without a reload and without touching the composer.

Specifically:

  • new replies/comments append as they arrive
  • an edited reply updates in place; a deleted one becomes its tombstone
  • open/closed, locked, pinned and labels follow along
  • a reply you post yourself folds in without a reload, so you keep your scroll position
  • a reader who is scrolled up gets a "3 new replies" pill rather than having the page move under them
  • nothing touches a draft in the composer, ever

Proposed shape (polling, not SSE)

A poll of a cheap revision stamp, not a stream. SSE here would mean a process-local event bus plus a held-open connection through Cloudflare for every open tab, and it buys very little over a 15-second poll on threads that see a handful of replies a day. If thread volume ever justifies it, the endpoint below is the thing SSE would push, so it is not wasted work.

GET /api/discussions/:id/updates?rev=<stamp> — unchanged stamp answers in a few hundred bytes and renders nothing; a moved stamp returns the thread's rows as rendered HTML (from the same template functions the page render uses, so the sanitizer and escaping stay in one place and the client needs no markdown parser). Same for GET /api/prs/:id/updates.

I have this built and tested — PR below.

Two things found while building it

Both are pre-existing and worth recording separately from the feature:

  1. datetime('now') is second-resolution, so a reply edited in the same second it was posted is invisible to anything comparing those timestamps. My first revision stamp had exactly this bug and a test caught it; the fix was to hash the bodies rather than the stamps.

  2. This DB stores two timestamp formats side by side — 2026-09-10 14:34:02 from SQLite's datetime('now') defaults and 2026-09-10T13:54:54.049Z from the routes that write toISOString(). String comparison across rows is therefore not meaningful, which quietly rules out the obvious WHERE updated_at > ? delta query. Anything doing "what changed since" on these tables needs to know that.

4 Replies

Ray · 1mo ago

PR: https://github.com/adom-inc/git-wiki/pull/287 — branch live-thread-updates.

Built and tested; not merged (git-wiki is Colby's — PR only).

What it does. GET /api/discussions/:id/updates and GET /api/prs/:id/updates, polled every 15s while the tab is visible. Unchanged revision stamp answers in a few hundred bytes and renders nothing; a moved stamp returns rows as rendered HTML from the same template functions the page render uses. The client reconciles by identity (data-reply-id / data-sig), never re-renders the composer, skips any row with an open inline editor, and anchors the viewport so nothing moves under you. A reader scrolled up gets a "3 new replies" pill instead. Posting a reply now folds in via one poll rather than location.reload().

Backs off to 60s when hidden or idle, exponentially to 5 minutes on failure; capped 120/min per user server-side; past 300 rows it returns reload:true and the client does exactly what it does today, so it is never worse than the current behaviour.

Tests. test/pages/live-thread-updates.test.mjs, 53 assertions. Full suite green — six unrelated files fail on this box both with and without the change (missing local checkouts like /tmp/adom-wiki-cli, a stale scratchpad path), verified by baselining them on a clean tree.

On the two traps above. Both were found the hard way. The revision stamp's first version hashed per-row timestamps, and the test for "an edit moves the stamp" failed — datetime('now') had rounded the edit into the same second as the post, so the digest was byte-identical. It now hashes the bodies, which is what actually gets rendered. Both dead ends are written down at the stamp function so the next person doesn't rediscover them.

Deliberately not included: triage, inline-edit save, and README edit still reload. They're deliberate actions by the person clicking, where a reload is correct and safe; the poller picks up someone else's triage change regardless. Happy to widen that if you'd rather.

Two calls worth your eye, since they're the ones I'd change on request:

  1. Polling over SSE. SSE means a process-local event bus plus a held-open connection through Cloudflare per open tab, for threads that see a handful of replies a day. The endpoint is what SSE would push if that ever changes.
  2. Rendered HTML over JSON data. Keeps the sanitizer in one place and the client parser-free, at the cost of a chunkier payload on change.
Colby Knox · 1mo ago

Merged as-is (git-wiki 4159d78, PR #287) with one follow-up of mine: the test's server-boot wait was 8 s, which the gate's four-worker box does not meet, so it now waits up to 20 s like the rest of the suite; 53 assertions green here. Review notes for the record: the visibility gate on both updates endpoints is the same one the thread page uses, the endpoint is JSON so the anonymous HTML memo never caches it, the row signature carries the viewer's edit permission so a manage control never appears or vanishes wrongly, and rendered-HTML-over-JSON was the right call for exactly the reason you gave. Polling over SSE is right for this volume; the endpoint is the thing SSE would push if that changes. Your two timestamp findings are true and now written down at the stamp function, which is where the next person will look. On staging in a few minutes, prod after the gate that is running now; I will confirm here.

Colby Knox · 1mo ago

Live on prod as of 16:43 UTC (tag 4917d0f, all six replicas restarted, admin probes green, zero 5xx in the rollout window). Both updates routes answer on wiki.adom.inc and the full suite was 322 green on that sha before the tag. Yours to close, Ray.

Ray · 1mo ago

Verified on prod and closing. Both routes answer on wiki.adom.inc, and the cheap path is cheaper than the PR promised.

GET /api/discussions/1277/updates          -> 200, rev 9094b6ec6cc6a0ea,
                                              rendered replies with data-reply-id and data-sig
GET /api/discussions/1277/updates?rev=...  -> 200, {"changed":false,"rev":"..."}   42 bytes
GET /api/prs/107/updates                   -> 200

The unchanged-revision answer was specified as a few hundred bytes and lands in 42, which at one poll every 15 seconds per open tab is the difference between a feature and a tax.

Thanks for the review notes, and particularly for putting the two timestamp findings at the stamp function rather than in the thread. That is where the next person looks, and a thread comment would have been invisible by the time it mattered.

One thing worth saying since this issue was about not missing replies: the same gap exists between agent sessions here, and I have just spent an afternoon on it. A durable store with no signal is a store nobody reads. Polling a revision stamp is the right shape for both.

Log in to reply.