Commitb584e52unknown_key
feat(sse): live "new comment" banner on issue + PR detail pages
feat(sse): live "new comment" banner on issue + PR detail pages
Closes the SSE round-trip: the publisher side landed in the prior
commit, this commit adds the consumer. Issue and PR detail pages now
open an EventSource on their topic and reveal a banner ("X new
comment(s) — reload to view") whenever a remote tab posts a comment.
The banner click reloads the same path so the server-rendered HTML
picks up the new comment naturally — no client-side DOM patching
needed, no risk of state drift between tabs.
New helper:
- src/lib/sse-client.ts liveCommentBannerScript({topic, bannerElementId})
— additive export. Generates a tiny IIFE (~700 bytes) that:
- opens EventSource on /live-events/<topic>
- increments an n counter on each message
- reveals the banner element + updates .js-live-count text
- sets .js-live-link href to window.location for one-click reload
- reconnects with 1s backoff on error
- no-ops when EventSource is unavailable
Pure string output — JSON-escaped against </script> injection like
the existing liveSubscribeScript helper.
Wiring:
- src/routes/issues.tsx renders a hidden #live-comment-banner div +
the script on every issue detail. Topic: repo:<repoId>:issue:<n>.
- src/routes/pulls.tsx mirrors it for PRs. Topic: repo:<repoId>:pr:<n>.
Tests: +3 (banner-script shape, </script> injection-resistance, and
window.location reload-link contract). Total suite 1146 pass / 8 skip /
0 fail (was 1143 / 8 / 0).
Note: in-process broadcaster only, per src/lib/sse.ts TODO(scale).
Cross-node fanout (Redis / NATS) still future work; users on the same
Bun process see live updates, multi-instance deploys won't until that
lands.
https://claude.ai/code/session_0163vJChUuZqqtBBznUW6xBU4 files changed+116−1b584e52ca18e32b6f4a952d2fa6d8db289e848a1
4 changed files+116−1
Modifiedsrc/__tests__/sse-client.test.ts+42−1View fileUnifiedSplit
@@ -1,5 +1,8 @@
11import { describe, it, expect } from "bun:test";
2import { liveSubscribeScript } from "../lib/sse-client";
2import {
3 liveSubscribeScript,
4 liveCommentBannerScript,
5} from "../lib/sse-client";
36
47describe("liveSubscribeScript", () => {
58 it("returns a string containing EventSource and the topic path", () => {
@@ -37,3 +40,41 @@ describe("liveSubscribeScript", () => {
3740 expect(js).toContain("EventSource");
3841 });
3942});
43
44describe("liveCommentBannerScript", () => {
45 it("emits a self-invoking IIFE that opens an EventSource", () => {
46 const js = liveCommentBannerScript({
47 topic: "repo:r1:issue:7",
48 bannerElementId: "live-comment-banner",
49 });
50 expect(typeof js).toBe("string");
51 expect(js).toContain("EventSource");
52 expect(js).toContain("/live-events/");
53 expect(js).toContain('"repo:r1:issue:7"');
54 expect(js).toContain('"live-comment-banner"');
55 // Counter increment + show contract.
56 expect(js).toContain("n++");
57 expect(js).toContain("js-live-count");
58 expect(js).toContain("js-live-link");
59 });
60
61 it("escapes topic strings to prevent </script> injection", () => {
62 const malicious = '</script><script>alert(1)</script>';
63 const js = liveCommentBannerScript({
64 topic: malicious,
65 bannerElementId: "banner",
66 });
67 expect(js).not.toContain("</script>");
68 expect(js).not.toContain("<script>alert(1)");
69 expect(js).toContain("EventSource");
70 });
71
72 it("uses the current location for the reload link", () => {
73 const js = liveCommentBannerScript({
74 topic: "repo:r1:pr:9",
75 bannerElementId: "banner",
76 });
77 // Must reference window.location so a click reloads the same page.
78 expect(js).toContain("window.location");
79 });
80});
Modifiedsrc/lib/sse-client.ts+36−0View fileUnifiedSplit
@@ -66,6 +66,42 @@ export function liveSubscribeScript(args: {
6666 );
6767}
6868
69/**
70 * Live comment-banner script: subscribe to a topic and increment a
71 * counter inside a banner element when new events arrive. Use on
72 * issue / PR detail pages to nudge the user to reload when remote
73 * comments land while their tab is open.
74 *
75 * Banner contract: the page renders a hidden element with the given
76 * id and an inner `<a class="js-live-count">` and `<a class="js-live-link">`.
77 * The script reveals the banner on the first event, updates the count
78 * text, and points the link at the current URL so a click reloads the
79 * page (cleanest way to pick up the new comment HTML server-side).
80 */
81export function liveCommentBannerScript(args: {
82 topic: string;
83 bannerElementId: string;
84}): string {
85 const topic = safeJsonForScript(args.topic);
86 const id = safeJsonForScript(args.bannerElementId);
87 return (
88 "(function(){try{" +
89 "if(typeof EventSource==='undefined')return;" +
90 "var t=" + topic + ",bid=" + id + ";" +
91 "var b=document.getElementById(bid);if(!b)return;" +
92 "var n=0;var es,delay=1000;" +
93 "function show(){if(b.style)b.style.display='';" +
94 "var c=b.querySelector('.js-live-count');" +
95 "if(c)c.textContent=String(n);" +
96 "var lk=b.querySelector('.js-live-link');" +
97 "if(lk)lk.setAttribute('href',window.location.pathname+window.location.search);}" +
98 "function connect(){try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
99 "es.onmessage=function(){n++;show();};" +
100 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
101 "}connect();}catch(e){}})();"
102 );
103}
104
69105/**
70106 * Live log-tail script: subscribe to a workflow-run topic, append step-log
71107 * chunks to a <pre>, and auto-close when 'run-done' arrives.
Modifiedsrc/routes/issues.tsx+19−0View fileUnifiedSplit
@@ -19,6 +19,7 @@ import { ReactionsBar } from "../views/reactions";
1919import { summariseReactions } from "../lib/reactions";
2020import { loadIssueTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
22import { liveCommentBannerScript } from "../lib/sse-client";
2223import { softAuth, requireAuth } from "../middleware/auth";
2324import type { AuthEnv } from "../middleware/auth";
2425import { requireRepoAccess } from "../middleware/repo-access";
@@ -341,6 +342,24 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
341342 >
342343 <RepoHeader owner={ownerName} repo={repoName} />
343344 <IssueNav owner={ownerName} repo={repoName} active="issues" />
345 <div
346 id="live-comment-banner"
347 class="alert"
348 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
349 >
350 <strong class="js-live-count">0</strong> new comment(s) —{" "}
351 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
352 reload to view
353 </a>
354 </div>
355 <script
356 dangerouslySetInnerHTML={{
357 __html: liveCommentBannerScript({
358 topic: `repo:${resolved.repo.id}:issue:${issue.number}`,
359 bannerElementId: "live-comment-banner",
360 }),
361 }}
362 />
344363 <div class="issue-detail">
345364 <h2>
346365 {issue.title}{" "}
Modifiedsrc/routes/pulls.tsx+19−0View fileUnifiedSplit
@@ -19,6 +19,7 @@ import { ReactionsBar } from "../views/reactions";
1919import { summariseReactions } from "../lib/reactions";
2020import { loadPrTemplate } from "../lib/templates";
2121import { renderMarkdown } from "../lib/markdown";
22import { liveCommentBannerScript } from "../lib/sse-client";
2223import { softAuth, requireAuth } from "../middleware/auth";
2324import type { AuthEnv } from "../middleware/auth";
2425import { requireRepoAccess } from "../middleware/repo-access";
@@ -448,6 +449,24 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
448449 >
449450 <RepoHeader owner={ownerName} repo={repoName} />
450451 <PrNav owner={ownerName} repo={repoName} active="pulls" />
452 <div
453 id="live-comment-banner"
454 class="alert"
455 style="display:none;margin:12px 0;padding:10px 14px;border-radius:6px;background:var(--accent);color:var(--bg);font-size:14px"
456 >
457 <strong class="js-live-count">0</strong> new comment(s) —{" "}
458 <a class="js-live-link" href="#" style="color:inherit;text-decoration:underline">
459 reload to view
460 </a>
461 </div>
462 <script
463 dangerouslySetInnerHTML={{
464 __html: liveCommentBannerScript({
465 topic: `repo:${resolved.repo.id}:pr:${pr.number}`,
466 bannerElementId: "live-comment-banner",
467 }),
468 }}
469 />
451470 <div class="issue-detail">
452471 <h2>
453472 {pr.title}{" "}
454473