Commit25b1ff7unknown_key
feat: real-time collaborative PR review — Figma-style presence in diffs
feat: real-time collaborative PR review — Figma-style presence in diffs
Adds a WebSocket-based presence layer to PR diff views so multiple
reviewers see each other's cursor position and typing state live, in the
same Figma-style approach used by design tools.
- src/lib/pr-presence.ts: in-memory room map (prId → sessionId →
PresenceUser) with stable colour assignment (hash userId → 8 colours),
broadcastToRoom, registerSocket/unregisterSocket, and a 15-second
background sweep that evicts stale sessions (>30 s silent).
- src/routes/pulls.tsx: GET /:owner/:repo/pulls/:number/presence WebSocket
upgrade endpoint (auth-gated). On connect sends {type:"init"} snapshot
to the joiner and {type:"join"} to existing peers. Handles cursor/typing/
ping messages and cleans up on close. Also injects PRESENCE_STYLES,
the presence avatar bar (#presence-bar above the diff), and the
PR_PRESENCE_SCRIPT vanilla-JS WebSocket client into the PR detail page.
- src/index.ts: adds `websocket: presenceWebsocket` to the Bun.serve
export so Bun routes WS upgrade frames through the Hono handler.
Client features (vanilla JS, no npm):
- Reconnects with exponential backoff
- Coloured avatar pills per line on mouseover of .diff-row rows
- Typing indicator when a textarea inside a diff row is focused
- Toast on join/leave events
- Ping every 10 s to keep session alive
https://claude.ai/code/session_01DzJMTFASjMHt2f5ze4cNLR3 files changed+706−025b1ff72667a61755b4eb247f3732df6c18e1311
3 changed files+706−0
Modifiedsrc/index.ts+6−0View fileUnifiedSplit
@@ -1,6 +1,7 @@
11import { mkdir } from "fs/promises";
22import app from "./app";
33import { config } from "./lib/config";
4import { presenceWebsocket } from "./routes/pulls";
45import { startWorker } from "./lib/workflow-runner";
56import { startWebhookDeliveryWorker } from "./lib/webhook-delivery";
67import { startAutopilot } from "./lib/autopilot";
@@ -121,4 +122,9 @@ void notifySystemdReady().catch((err) => {
121122export default {
122123 port: config.port,
123124 fetch: app.fetch,
125 // WebSocket handler for real-time PR presence (Figma-style diff cursors).
126 // Bun.serve forwards WS upgrade requests through the same fetch pipeline;
127 // the `upgradeWebSocket` middleware in pulls.tsx calls server.upgrade() and
128 // returns a null response. This `websocket` object handles the ws lifecycle.
129 websocket: presenceWebsocket,
124130};
Addedsrc/lib/pr-presence.ts+214−0View fileUnifiedSplit
@@ -0,0 +1,214 @@
1/**
2 * Real-time collaborative PR review presence.
3 *
4 * Maintains an in-memory room map keyed by PR id. Each room tracks every
5 * connected reviewer's current line hover and typing state. The module is
6 * self-contained: no DB, no Redis — purely ephemeral per-process state.
7 *
8 * Rooms are automatically created on first join and swept clean when the
9 * last user leaves. A background sweep evicts sessions whose lastSeen
10 * timestamp is older than STALE_THRESHOLD_MS (30 seconds).
11 *
12 * Public API:
13 * joinRoom(prId, sessionId, user)
14 * leaveRoom(prId, sessionId)
15 * updatePresence(prId, sessionId, line, typing)
16 * getRoomUsers(prId)
17 * broadcastToRoom(prId, message, excludeSession?)
18 * registerSocket(prId, sessionId, ws)
19 * unregisterSocket(prId, sessionId)
20 */
21
22export type PresenceUser = {
23 userId: string;
24 username: string;
25 colour: string;
26 line: number | null;
27 typing: boolean;
28 lastSeen: number; // Date.now()
29};
30
31type WsLike = {
32 send: (data: string) => void;
33 readyState?: number;
34};
35
36// Room: sessionId → PresenceUser
37const rooms = new Map<string, Map<string, PresenceUser>>();
38
39// Socket registry: prId → sessionId → WebSocket
40const sockets = new Map<string, Map<string, WsLike>>();
41
42const COLOURS = [
43 "#e74c3c",
44 "#3498db",
45 "#2ecc71",
46 "#f39c12",
47 "#9b59b6",
48 "#1abc9c",
49 "#e67e22",
50 "#34495e",
51];
52
53const STALE_THRESHOLD_MS = 30_000;
54const SWEEP_INTERVAL_MS = 15_000;
55
56/** Stable colour assignment: hash userId → one of 8 preset colours. */
57function assignColour(userId: string): string {
58 let hash = 0;
59 for (let i = 0; i < userId.length; i++) {
60 hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
61 }
62 return COLOURS[hash % COLOURS.length];
63}
64
65function getRoom(prId: string): Map<string, PresenceUser> {
66 let room = rooms.get(prId);
67 if (!room) {
68 room = new Map();
69 rooms.set(prId, room);
70 }
71 return room;
72}
73
74function getSockets(prId: string): Map<string, WsLike> {
75 let map = sockets.get(prId);
76 if (!map) {
77 map = new Map();
78 sockets.set(prId, map);
79 }
80 return map;
81}
82
83/** Add or refresh a user's presence in a room. */
84export function joinRoom(
85 prId: string,
86 sessionId: string,
87 user: { userId: string; username: string }
88): PresenceUser {
89 const room = getRoom(prId);
90 const existing = room.get(sessionId);
91 const entry: PresenceUser = {
92 userId: user.userId,
93 username: user.username,
94 colour: existing?.colour ?? assignColour(user.userId),
95 line: existing?.line ?? null,
96 typing: existing?.typing ?? false,
97 lastSeen: Date.now(),
98 };
99 room.set(sessionId, entry);
100 return entry;
101}
102
103/** Remove a session from a room; prune empty rooms. */
104export function leaveRoom(prId: string, sessionId: string): void {
105 const room = rooms.get(prId);
106 if (!room) return;
107 room.delete(sessionId);
108 if (room.size === 0) rooms.delete(prId);
109
110 const ss = sockets.get(prId);
111 if (ss) {
112 ss.delete(sessionId);
113 if (ss.size === 0) sockets.delete(prId);
114 }
115}
116
117/** Update cursor line / typing state and refresh lastSeen. */
118export function updatePresence(
119 prId: string,
120 sessionId: string,
121 line: number | null,
122 typing: boolean
123): PresenceUser | null {
124 const room = rooms.get(prId);
125 if (!room) return null;
126 const entry = room.get(sessionId);
127 if (!entry) return null;
128 entry.line = line;
129 entry.typing = typing;
130 entry.lastSeen = Date.now();
131 return entry;
132}
133
134/** Ping-only: refresh lastSeen without changing line/typing. */
135export function pingSession(prId: string, sessionId: string): void {
136 const room = rooms.get(prId);
137 if (!room) return;
138 const entry = room.get(sessionId);
139 if (entry) entry.lastSeen = Date.now();
140}
141
142/** Snapshot of all current users in a room (excluding stale sessions). */
143export function getRoomUsers(prId: string): Array<PresenceUser & { sessionId: string }> {
144 const room = rooms.get(prId);
145 if (!room) return [];
146 const now = Date.now();
147 return Array.from(room.entries())
148 .filter(([, u]) => now - u.lastSeen < STALE_THRESHOLD_MS)
149 .map(([sessionId, u]) => ({ ...u, sessionId }));
150}
151
152/** Register a WebSocket connection for a session. */
153export function registerSocket(prId: string, sessionId: string, ws: WsLike): void {
154 getSockets(prId).set(sessionId, ws);
155}
156
157/** Remove a WebSocket registration for a session. */
158export function unregisterSocket(prId: string, sessionId: string): void {
159 const ss = sockets.get(prId);
160 if (!ss) return;
161 ss.delete(sessionId);
162 if (ss.size === 0) sockets.delete(prId);
163}
164
165/**
166 * Broadcast a JSON message to every connected WebSocket in a room.
167 * If excludeSession is set, that session's socket is skipped (suppress echo).
168 */
169export function broadcastToRoom(
170 prId: string,
171 message: unknown,
172 excludeSession?: string
173): void {
174 const ss = sockets.get(prId);
175 if (!ss) return;
176 const payload = JSON.stringify(message);
177 for (const [sessionId, ws] of ss) {
178 if (excludeSession && sessionId === excludeSession) continue;
179 // 1 = OPEN
180 if (ws.readyState !== undefined && ws.readyState !== 1) continue;
181 try {
182 ws.send(payload);
183 } catch {
184 // Stale socket — will be cleaned up by sweep or disconnect handler.
185 }
186 }
187}
188
189// ---------------------------------------------------------------------------
190// Background sweep — evict stale sessions every 15 s
191// ---------------------------------------------------------------------------
192
193function sweep(): void {
194 const now = Date.now();
195 for (const [prId, room] of rooms) {
196 for (const [sessionId, user] of room) {
197 if (now - user.lastSeen >= STALE_THRESHOLD_MS) {
198 // Notify room peers before evicting.
199 broadcastToRoom(prId, { type: "leave", sessionId }, sessionId);
200 room.delete(sessionId);
201 const ss = sockets.get(prId);
202 if (ss) ss.delete(sessionId);
203 }
204 }
205 if (room.size === 0) rooms.delete(prId);
206 }
207}
208
209// Start sweep loop — fire-and-forget, never throws.
210const _sweepInterval = setInterval(sweep, SWEEP_INTERVAL_MS);
211// Allow Bun/Node to exit cleanly even if this interval is live.
212if (typeof _sweepInterval === "object" && _sweepInterval !== null) {
213 (_sweepInterval as NodeJS.Timeout).unref?.();
214}
Modifiedsrc/routes/pulls.tsx+486−0View fileUnifiedSplit
@@ -129,6 +129,19 @@ import {
129129 reviewersForChangedFiles,
130130} from "../lib/codeowners";
131131import { checkMergeEligible } from "../lib/branch-rules";
132import {
133 joinRoom,
134 leaveRoom,
135 updatePresence,
136 pingSession,
137 getRoomUsers,
138 broadcastToRoom,
139 registerSocket,
140 unregisterSocket,
141} from "../lib/pr-presence";
142import { upgradeWebSocket, websocket as presenceWebsocket } from "hono/bun";
143
144export { presenceWebsocket };
132145
133146const pulls = new Hono<AuthEnv>();
134147
@@ -1477,6 +1490,121 @@ const PRS_DETAIL_STYLES = `
14771490 }
14781491`;
14791492
1493/* ──────────────────────────────────────────────────────────────────────
1494 * Figma-style collaborative PR presence — styles for the presence bar
1495 * above the diff and the per-line reviewer cursor pills. All scoped
1496 * with `.presence-*` prefix so they never bleed into other views.
1497 * ──────────────────────────────────────────────────────────────────── */
1498const PRESENCE_STYLES = `
1499 .presence-bar {
1500 display: flex;
1501 align-items: center;
1502 gap: 10px;
1503 padding: 8px 14px;
1504 margin: 0 0 10px;
1505 background: var(--bg-elevated);
1506 border: 1px solid var(--border);
1507 border-radius: 10px;
1508 font-size: 12.5px;
1509 color: var(--text-muted);
1510 min-height: 38px;
1511 }
1512 .presence-bar-label {
1513 font-weight: 600;
1514 color: var(--text-muted);
1515 flex-shrink: 0;
1516 }
1517 .presence-avatars {
1518 display: flex;
1519 align-items: center;
1520 gap: 6px;
1521 flex: 1;
1522 flex-wrap: wrap;
1523 }
1524 .presence-avatar {
1525 display: inline-flex;
1526 align-items: center;
1527 gap: 5px;
1528 padding: 3px 8px 3px 4px;
1529 border-radius: 9999px;
1530 font-size: 12px;
1531 font-weight: 600;
1532 color: #fff;
1533 opacity: 0.92;
1534 transition: opacity 200ms;
1535 }
1536 .presence-avatar-dot {
1537 width: 20px; height: 20px;
1538 border-radius: 9999px;
1539 background: rgba(255,255,255,0.22);
1540 display: inline-flex;
1541 align-items: center;
1542 justify-content: center;
1543 font-size: 10px;
1544 font-weight: 700;
1545 flex-shrink: 0;
1546 }
1547 .presence-count {
1548 font-size: 12px;
1549 color: var(--text-faint);
1550 flex-shrink: 0;
1551 }
1552 /* Per-line reviewer cursor pill — injected by JS into .diff-row */
1553 .presence-line-pill {
1554 position: absolute;
1555 right: 6px;
1556 top: 50%;
1557 transform: translateY(-50%);
1558 display: inline-flex;
1559 align-items: center;
1560 gap: 4px;
1561 padding: 1px 7px;
1562 border-radius: 9999px;
1563 font-size: 10.5px;
1564 font-weight: 600;
1565 color: #fff;
1566 pointer-events: none;
1567 white-space: nowrap;
1568 z-index: 10;
1569 opacity: 0.88;
1570 animation: presence-in 160ms ease;
1571 }
1572 presence-in {
1573 from { opacity: 0; transform: translateY(-50%) scale(0.85); }
1574 to { opacity: 0.88; transform: translateY(-50%) scale(1); }
1575 }
1576 .presence-line-pill.is-typing::after {
1577 content: '…';
1578 opacity: 0.7;
1579 }
1580 /* diff rows with a cursor pill need relative positioning */
1581 .diff-row { position: relative; }
1582 /* Toast for join/leave events */
1583 .presence-toast-wrap {
1584 position: fixed;
1585 bottom: 24px;
1586 right: 24px;
1587 display: flex;
1588 flex-direction: column;
1589 gap: 8px;
1590 z-index: 9999;
1591 pointer-events: none;
1592 }
1593 .presence-toast {
1594 padding: 8px 14px;
1595 border-radius: 8px;
1596 background: var(--bg-elevated);
1597 border: 1px solid var(--border);
1598 box-shadow: 0 6px 20px -8px rgba(0,0,0,0.55);
1599 font-size: 13px;
1600 color: var(--text);
1601 opacity: 1;
1602 transition: opacity 400ms;
1603 }
1604 .presence-toast.fading { opacity: 0; }
1605`;
1606
1607
14801608/**
14811609 * Tiny inline JS that drives the "Suggest description with AI" button.
14821610 * On click, gathers form values, POSTs JSON to the given endpoint, and
@@ -1534,6 +1662,197 @@ function AI_PR_DESC_SCRIPT(endpointUrl: string): string {
15341662 * All endpoint URLs are JSON-escaped via safe replacements so they
15351663 * can't break out of the <script> tag.
15361664 */
1665
1666/**
1667 * Figma-style collaborative PR presence client (WebSocket).
1668 *
1669 * Connects to `GET /:owner/:repo/pulls/:number/presence` (WebSocket upgrade).
1670 * On connect the server sends `{type:"init", users:[...]}` so the bar renders
1671 * immediately. Subsequent messages from the server drive the presence bar and
1672 * per-line cursor pills in the diff.
1673 *
1674 * Outbound messages:
1675 * {type:"cursor", line: N} — user hovered a diff line
1676 * {type:"typing", line: N, typing: bool} — textarea focus/blur in diff
1677 * {type:"ping"} — keep-alive every 10s
1678 *
1679 * Inbound messages:
1680 * {type:"init", users:[{sessionId,username,colour,line,typing}]}
1681 * {type:"join", user:{sessionId,username,colour,line,typing}}
1682 * {type:"leave", sessionId}
1683 * {type:"cursor", sessionId, username, colour, line}
1684 * {type:"typing", sessionId, username, colour, line, typing}
1685 */
1686function PR_PRESENCE_SCRIPT(owner: string, repo: string, prNum: number): string {
1687 const wsPath = JSON.stringify(`/${owner}/${repo}/pulls/${prNum}/presence`)
1688 .split("<").join("\\u003C")
1689 .split(">").join("\\u003E")
1690 .split("&").join("\\u0026");
1691 return `(function(){
1692try{
1693var wsPath=${wsPath};
1694var proto=location.protocol==='https:'?'wss:':'ws:';
1695var url=proto+'//'+location.host+wsPath;
1696var mySessionId=null;
1697// sessionId -> {username, colour, line, typing}
1698var peers={};
1699var ws=null;
1700var pingTimer=null;
1701var reconnectDelay=1500;
1702var reconnectTimer=null;
1703
1704function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,function(c){return{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});}
1705
1706// ── Toast ──────────────────────────────────────────────────────────────
1707var toastWrap=document.getElementById('presence-toasts');
1708function toast(msg){
1709 if(!toastWrap)return;
1710 var t=document.createElement('div');
1711 t.className='presence-toast';
1712 t.textContent=msg;
1713 toastWrap.appendChild(t);
1714 setTimeout(function(){t.classList.add('fading');setTimeout(function(){if(t.parentNode)t.parentNode.removeChild(t);},420);},2500);
1715}
1716
1717// ── Presence bar ───────────────────────────────────────────────────────
1718var avEl=document.getElementById('presence-avatars');
1719var countEl=document.getElementById('presence-count');
1720function renderBar(){
1721 if(!avEl)return;
1722 var ids=Object.keys(peers);
1723 var html='';
1724 for(var i=0;i<ids.length&&i<8;i++){
1725 var p=peers[ids[i]];
1726 var initials=(p.username||'?').slice(0,2).toUpperCase();
1727 html+='<span class="presence-avatar" style="background:'+esc(p.colour)+'" title="'+esc(p.username)+'">';
1728 html+='<span class="presence-avatar-dot">'+esc(initials)+'</span>';
1729 html+=esc(p.username);
1730 html+='</span>';
1731 }
1732 avEl.innerHTML=html;
1733 if(countEl){
1734 var n=ids.length;
1735 countEl.textContent=n===0?'No other reviewers':n===1?'1 reviewer online':n+' reviewers online';
1736 }
1737}
1738
1739// ── Diff cursor pills ──────────────────────────────────────────────────
1740// data-line value is like "12:x:5" or "12:5:x" — pull numeric line only
1741function lineNumFromKey(key){var m=String(key).match(/(\d+)/);return m?parseInt(m[1],10):null;}
1742function findDiffRow(line){return document.querySelector('[data-line]') &&
1743 (function(){var rows=document.querySelectorAll('[data-line]');
1744 for(var i=0;i<rows.length;i++){var n=lineNumFromKey(rows[i].getAttribute('data-line')||'');if(n===line)return rows[i];}
1745 return null;
1746 })();}
1747function removePill(sessionId){var old=document.querySelector('[data-presence-sid="'+sessionId+'"]');if(old&&old.parentNode)old.parentNode.removeChild(old);}
1748function placePill(sessionId,username,colour,line,typing){
1749 removePill(sessionId);
1750 if(line==null)return;
1751 var row=findDiffRow(line);if(!row)return;
1752 var pill=document.createElement('span');
1753 pill.className='presence-line-pill'+(typing?' is-typing':'');
1754 pill.setAttribute('data-presence-sid',sessionId);
1755 pill.style.background=colour||'#8c6dff';
1756 pill.textContent=(username||'?').slice(0,12)+(typing?' typing':'');
1757 row.appendChild(pill);
1758}
1759function clearPeer(sessionId){removePill(sessionId);delete peers[sessionId];}
1760
1761// ── Inbound message handler ────────────────────────────────────────────
1762function onMsg(raw){
1763 var d;try{d=JSON.parse(raw);}catch(e){return;}
1764 if(!d||!d.type)return;
1765 if(d.type==='init'){
1766 mySessionId=d.sessionId||null;
1767 peers={};
1768 (d.users||[]).forEach(function(u){
1769 if(u.sessionId===mySessionId)return;
1770 peers[u.sessionId]={username:u.username,colour:u.colour,line:u.line,typing:u.typing};
1771 placePill(u.sessionId,u.username,u.colour,u.line,u.typing);
1772 });
1773 renderBar();
1774 } else if(d.type==='join'){
1775 if(d.user&&d.user.sessionId!==mySessionId){
1776 peers[d.user.sessionId]={username:d.user.username,colour:d.user.colour,line:d.user.line,typing:d.user.typing};
1777 renderBar();
1778 toast(esc(d.user.username)+' joined the review');
1779 }
1780 } else if(d.type==='leave'){
1781 if(d.sessionId&&d.sessionId!==mySessionId){
1782 var name=peers[d.sessionId]&&peers[d.sessionId].username;
1783 clearPeer(d.sessionId);
1784 renderBar();
1785 if(name)toast(esc(name)+' left the review');
1786 }
1787 } else if(d.type==='cursor'){
1788 if(d.sessionId&&d.sessionId!==mySessionId){
1789 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=false;}
1790 placePill(d.sessionId,d.username,d.colour,d.line,false);
1791 }
1792 } else if(d.type==='typing'){
1793 if(d.sessionId&&d.sessionId!==mySessionId){
1794 if(peers[d.sessionId]){peers[d.sessionId].line=d.line;peers[d.sessionId].typing=d.typing;}
1795 placePill(d.sessionId,d.username,d.colour,d.line,d.typing);
1796 }
1797 }
1798}
1799
1800// ── Outbound helpers ───────────────────────────────────────────────────
1801function send(obj){try{if(ws&&ws.readyState===1)ws.send(JSON.stringify(obj));}catch(e){}}
1802
1803// ── Mouse hover on diff rows ───────────────────────────────────────────
1804var hoverTimer=null;
1805document.addEventListener('mouseover',function(ev){
1806 var row=ev.target&&ev.target.closest&&ev.target.closest('[data-line]');
1807 if(!row)return;
1808 if(hoverTimer)clearTimeout(hoverTimer);
1809 hoverTimer=setTimeout(function(){
1810 var key=row.getAttribute('data-line')||'';
1811 var line=lineNumFromKey(key);
1812 if(line!=null)send({type:'cursor',line:line});
1813 },80);
1814});
1815
1816// ── Typing detection in diff comment textareas ─────────────────────────
1817document.addEventListener('focusin',function(ev){
1818 var ta=ev.target;
1819 if(!ta||ta.tagName!=='TEXTAREA')return;
1820 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1821 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1822 if(line!=null)send({type:'typing',line:line,typing:true});
1823});
1824document.addEventListener('focusout',function(ev){
1825 var ta=ev.target;
1826 if(!ta||ta.tagName!=='TEXTAREA')return;
1827 var row=ta.closest&&ta.closest('[data-line]');if(!row)return;
1828 var line=lineNumFromKey(row.getAttribute('data-line')||'');
1829 if(line!=null)send({type:'typing',line:line,typing:false});
1830});
1831
1832// ── WebSocket lifecycle ────────────────────────────────────────────────
1833function connect(){
1834 if(reconnectTimer){clearTimeout(reconnectTimer);reconnectTimer=null;}
1835 try{ws=new WebSocket(url);}catch(e){scheduleReconnect();return;}
1836 ws.onopen=function(){
1837 reconnectDelay=1500;
1838 pingTimer=setInterval(function(){send({type:'ping'});},10000);
1839 };
1840 ws.onmessage=function(ev){onMsg(ev.data);};
1841 ws.onclose=function(){
1842 if(pingTimer){clearInterval(pingTimer);pingTimer=null;}
1843 scheduleReconnect();
1844 };
1845 ws.onerror=function(){try{ws.close();}catch(e){}};
1846}
1847function scheduleReconnect(){
1848 reconnectTimer=setTimeout(function(){connect();},reconnectDelay);
1849 reconnectDelay=Math.min(reconnectDelay*2,30000);
1850}
1851
1852connect();
1853}catch(e){}})();`;
1854}
1855
15371856function LIVE_COEDIT_SCRIPT(prId: string): string {
15381857 const idJson = JSON.stringify(prId)
15391858 .split("<").join("\\u003C")
@@ -3892,6 +4211,25 @@ pulls.get("/:owner/:repo/pulls/:number", softAuth, requireRepoAccess("read"), as
38924211 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
38934212 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
38944213
4214 {/* Presence styles + bar (shown only on the files tab so cursor pills work) */}
4215 <style dangerouslySetInnerHTML={{ __html: PRESENCE_STYLES }} />
4216 {/* Toast container — always present for join/leave toasts */}
4217 <div id="presence-toasts" class="presence-toast-wrap" aria-live="polite" />
4218 {user && (
4219 <>
4220 <div class="presence-bar" id="presence-bar">
4221 <span class="presence-bar-label">Live reviewers</span>
4222 <div class="presence-avatars" id="presence-avatars" />
4223 <span class="presence-count" id="presence-count">Loading…</span>
4224 </div>
4225 <script
4226 dangerouslySetInnerHTML={{
4227 __html: PR_PRESENCE_SCRIPT(ownerName, repoName, pr.number),
4228 }}
4229 />
4230 </>
4231 )}
4232
38954233 <nav class="prs-detail-tabs" aria-label="Pull request sections">
38964234 <a
38974235 class={`prs-detail-tab${tab === "conversation" ? " is-active" : ""}`}
@@ -5566,4 +5904,152 @@ pulls.post(
55665904 }
55675905);
55685906
5907// ─── WebSocket presence endpoint ─────────────────────────────────────────────
5908//
5909// GET /:owner/:repo/pulls/:number/presence (WebSocket upgrade)
5910//
5911// Unauthenticated connections are rejected with 401. On connect:
5912// → server sends {type:"init", sessionId, users:[...]}
5913// → server broadcasts {type:"join", user} to all other sessions in the room
5914//
5915// Accepted client messages:
5916// {type:"cursor", line: number} — user hovering a diff line
5917// {type:"typing", line: number, typing: bool} — textarea focus/blur
5918// {type:"ping"} — keep-alive (updates lastSeen)
5919//
5920// The WS `data` payload we store on each socket carries everything needed in
5921// the event handlers so no closure tricks are required.
5922
5923pulls.get(
5924 "/:owner/:repo/pulls/:number/presence",
5925 softAuth,
5926 upgradeWebSocket(async (c) => {
5927 const { owner: ownerName, repo: repoName, number: prNumStr } = c.req.param();
5928 const prNum = parseInt(prNumStr ?? "0", 10);
5929 const user = c.get("user");
5930
5931 // Auth check — no anonymous presence
5932 if (!user) {
5933 // upgradeWebSocket doesn't support returning a non-101 directly;
5934 // we return a dummy handler that immediately closes with 4001.
5935 return {
5936 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5937 ws.close(4001, "Unauthorized");
5938 },
5939 onMessage() {},
5940 onClose() {},
5941 };
5942 }
5943
5944 // Resolve repo to get its numeric id for the room key
5945 const resolved = await resolveRepo(ownerName, repoName);
5946 if (!resolved || isNaN(prNum)) {
5947 return {
5948 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5949 ws.close(4004, "Not found");
5950 },
5951 onMessage() {},
5952 onClose() {},
5953 };
5954 }
5955
5956 const prId = `${resolved.repo.id}:${prNum}`;
5957 const sessionId = `${user.id}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
5958
5959 return {
5960 onOpen(_evt: Event, ws: import("hono/ws").WSContext) {
5961 // Register and join room
5962 registerSocket(prId, sessionId, {
5963 send: (data: string) => ws.send(data),
5964 readyState: ws.readyState,
5965 });
5966 const presenceUser = joinRoom(prId, sessionId, {
5967 userId: user.id,
5968 username: user.username,
5969 });
5970
5971 // Send init snapshot to the new joiner
5972 const currentUsers = getRoomUsers(prId);
5973 ws.send(
5974 JSON.stringify({
5975 type: "init",
5976 sessionId,
5977 users: currentUsers,
5978 })
5979 );
5980
5981 // Broadcast join to all OTHER sessions
5982 broadcastToRoom(
5983 prId,
5984 {
5985 type: "join",
5986 user: { ...presenceUser, sessionId },
5987 },
5988 sessionId
5989 );
5990 },
5991
5992 onMessage(evt: MessageEvent, _ws: import("hono/ws").WSContext) {
5993 let msg: { type: string; line?: number; typing?: boolean };
5994 try {
5995 msg = JSON.parse(typeof evt.data === "string" ? evt.data : String(evt.data));
5996 } catch {
5997 return;
5998 }
5999
6000 if (msg.type === "ping") {
6001 pingSession(prId, sessionId);
6002 return;
6003 }
6004
6005 if (msg.type === "cursor") {
6006 const line = typeof msg.line === "number" ? msg.line : null;
6007 const updated = updatePresence(prId, sessionId, line, false);
6008 if (updated) {
6009 broadcastToRoom(
6010 prId,
6011 {
6012 type: "cursor",
6013 sessionId,
6014 username: updated.username,
6015 colour: updated.colour,
6016 line,
6017 },
6018 sessionId
6019 );
6020 }
6021 return;
6022 }
6023
6024 if (msg.type === "typing") {
6025 const line = typeof msg.line === "number" ? msg.line : null;
6026 const typing = !!msg.typing;
6027 const updated = updatePresence(prId, sessionId, line, typing);
6028 if (updated) {
6029 broadcastToRoom(
6030 prId,
6031 {
6032 type: "typing",
6033 sessionId,
6034 username: updated.username,
6035 colour: updated.colour,
6036 line,
6037 typing,
6038 },
6039 sessionId
6040 );
6041 }
6042 return;
6043 }
6044 },
6045
6046 onClose() {
6047 leaveRoom(prId, sessionId);
6048 unregisterSocket(prId, sessionId);
6049 broadcastToRoom(prId, { type: "leave", sessionId });
6050 },
6051 };
6052 })
6053);
6054
55696055export default pulls;
55706056