Last active
September 15, 2026 13:52
-
-
Save warrenseine/13d4c944391da2dc3ab744f1c993cafa to your computer and use it in GitHub Desktop.
Expand replies in the GitHub comments panel
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
| // ==UserScript== | |
| // @name Expand replies in the GitHub comments panel | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2026-09-15b | |
| // @description Expand replies in the GitHub comments panel | |
| // @author Warren Seine | |
| // @match https://github.com/*/*/pull/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=github.com | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // The panel renders the first comment of a thread and links the rest away, to | |
| // a diff URL pinned to the commit the thread was written on. Once that commit | |
| // is gone — a force-push — the link 404s and the replies are unreachable from | |
| // here. They are not missing, though: the panel already holds them. | |
| const REPLY_LINK = /^\d+ repl(?:y|ies)$/; | |
| const DIFF_TAB = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/(?:changes|files)\b/; | |
| const MARKER = 'inlineReplies'; | |
| function pullRequest() { | |
| const parts = DIFF_TAB.exec(document.location.pathname); | |
| return parts ? {owner: parts[1], repo: parts[2], number: parts[3]} : null; | |
| } | |
| // The thread, comments and all, hangs off the React props of the card. This | |
| // reads an internal field, so every use is guarded and failure is silent: | |
| // without it the panel simply behaves as it does today. | |
| function threadProps(element) { | |
| const key = Object.keys(element).find(name => name.startsWith('__reactFiber$')); | |
| let fiber = key ? element[key] : null; | |
| for (let depth = 0; depth < 20 && fiber; depth++, fiber = fiber.return) { | |
| const props = fiber.memoizedProps; | |
| if (props && props.thread && props.thread.commentsData) return props; | |
| } | |
| return null; | |
| } | |
| function comments(props) { | |
| const data = props.thread.commentsData.comments; | |
| return data ? Object.values(data) : []; | |
| } | |
| function timeAgo(iso) { | |
| const minutes = Math.round((Date.now() - new Date(iso)) / 60000); | |
| if (minutes < 60) return `${minutes}m ago`; | |
| if (minutes < 1440) return `${Math.round(minutes / 60)}h ago`; | |
| return `${Math.round(minutes / 1440)}d ago`; | |
| } | |
| function renderComment(comment) { | |
| const article = document.createElement('article'); | |
| article.style.cssText = 'border-top:1px solid var(--borderColor-muted,#d1d9e0);padding:8px 0;'; | |
| const header = document.createElement('div'); | |
| header.style.cssText = 'display:flex;gap:6px;align-items:center;font-size:12px;color:var(--fgColor-muted,#59636e);margin-bottom:4px;'; | |
| const author = document.createElement('strong'); | |
| author.textContent = (comment.author && comment.author.login) || 'unknown'; | |
| author.style.color = 'var(--fgColor-default,#1f2328)'; | |
| header.append(author, document.createTextNode(timeAgo(comment.createdAt || comment.publishedAt))); | |
| const body = document.createElement('div'); | |
| body.className = 'markdown-body'; | |
| body.style.fontSize = '14px'; | |
| // bodyHTML is GitHub's own rendering of the comment, taken from the payload | |
| // the panel already received. | |
| body.innerHTML = comment.bodyHTML || ''; | |
| article.append(header, body); | |
| return article; | |
| } | |
| async function postReply(props, text) { | |
| const pr = pullRequest(); | |
| const root = comments(props)[0]; | |
| const nonce = document.querySelector('meta[name="fetch-nonce"]'); | |
| if (!pr || !root || !nonce) throw new Error('missing reply context'); | |
| // The endpoint the panel's own composer posts to, authenticated by the | |
| // session cookie plus the page's fetch nonce. | |
| const response = await fetch(`/${pr.owner}/${pr.repo}/pull/${pr.number}/page_data/create_review_comment`, { | |
| method: 'POST', | |
| credentials: 'same-origin', | |
| headers: { | |
| 'accept': 'application/json', | |
| 'content-type': 'application/json', | |
| 'github-verified-fetch': 'true', | |
| 'x-requested-with': 'XMLHttpRequest', | |
| 'x-fetch-nonce': nonce.content | |
| }, | |
| body: JSON.stringify({ | |
| text, | |
| submitBatch: true, | |
| inReplyTo: root.databaseId, | |
| path: props.thread.path | |
| }) | |
| }); | |
| if (!response.ok) throw new Error(`reply failed: ${response.status}`); | |
| const payload = await response.json(); | |
| return payload.thread ? Object.values(payload.thread.commentsData.comments) : null; | |
| } | |
| function buildComposer(props, onPosted) { | |
| const form = document.createElement('div'); | |
| form.style.cssText = 'border-top:1px solid var(--borderColor-muted,#d1d9e0);padding-top:8px;'; | |
| const input = document.createElement('textarea'); | |
| input.placeholder = 'Reply…'; | |
| input.rows = 3; | |
| input.style.cssText = 'width:100%;box-sizing:border-box;font:inherit;padding:6px 8px;border:1px solid var(--borderColor-default,#d1d9e0);border-radius:6px;background:var(--bgColor-default,#fff);color:inherit;'; | |
| const send = document.createElement('button'); | |
| send.type = 'button'; | |
| send.textContent = 'Reply'; | |
| send.style.cssText = 'margin-top:6px;padding:4px 12px;border-radius:6px;border:1px solid var(--borderColor-default,#d1d9e0);background:var(--bgColor-default,#f6f8fa);font:inherit;cursor:pointer;'; | |
| const status = document.createElement('span'); | |
| status.style.cssText = 'margin-left:8px;font-size:12px;color:var(--fgColor-muted,#59636e);'; | |
| send.addEventListener('click', async () => { | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| send.disabled = true; | |
| status.textContent = 'Posting…'; | |
| try { | |
| const updated = await postReply(props, text); | |
| input.value = ''; | |
| status.textContent = ''; | |
| if (updated) onPosted(updated); | |
| } catch (error) { | |
| status.textContent = error.message; | |
| } finally { | |
| send.disabled = false; | |
| } | |
| }); | |
| form.append(input, send, status); | |
| return form; | |
| } | |
| // The panel's own comment bodies are inset from the card edge. Measuring one | |
| // keeps the replies lined up with them whatever the theme does. | |
| function inset(card, parent) { | |
| const body = card && card.querySelector('.markdown-body'); | |
| if (!body || !parent) return 13; | |
| const offset = Math.round(body.getBoundingClientRect().left - parent.getBoundingClientRect().left); | |
| return offset > 0 && offset < 40 ? offset : 13; | |
| } | |
| function attach(link) { | |
| if (link.dataset[MARKER]) return; | |
| const props = threadProps(link); | |
| if (!props) return; | |
| link.dataset[MARKER] = 'on'; | |
| const row = link.closest('div') || link.parentElement; | |
| const card = link.closest('article') || row.parentElement; | |
| const padding = inset(card, row.parentElement); | |
| const panel = document.createElement('div'); | |
| panel.hidden = true; | |
| panel.style.cssText = `padding:0 ${padding}px 8px;`; | |
| const list = document.createElement('div'); | |
| const draw = all => { | |
| list.textContent = ''; | |
| // The first comment is the one the card already shows. | |
| all.slice(1).forEach(comment => list.append(renderComment(comment))); | |
| }; | |
| draw(comments(props)); | |
| panel.append(list, buildComposer(props, draw)); | |
| link.addEventListener('click', event => { | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| // The count has served its purpose once the replies are on screen. | |
| row.hidden = true; | |
| panel.hidden = false; | |
| }, true); | |
| row.after(panel); | |
| } | |
| function attachAll() { | |
| if (!pullRequest()) return; | |
| for (const link of document.querySelectorAll('a[href*="#r"]')) { | |
| if (REPLY_LINK.test(link.textContent.trim())) { | |
| try { | |
| attach(link); | |
| } catch (error) { | |
| /* leave the link as GitHub rendered it */ | |
| } | |
| } | |
| } | |
| } | |
| // The panel re-renders on filtering, scrolling and client-side navigation, so | |
| // keep watching instead of running once. | |
| let scheduled = false; | |
| new MutationObserver(() => { | |
| if (scheduled) return; | |
| scheduled = true; | |
| setTimeout(() => { | |
| scheduled = false; | |
| attachAll(); | |
| }, 100); | |
| }).observe(document.body, {childList: true, subtree: true}); | |
| attachAll(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment