Skip to content

Instantly share code, notes, and snippets.

@warrenseine
Last active September 15, 2026 13:52
Show Gist options
  • Select an option

  • Save warrenseine/13d4c944391da2dc3ab744f1c993cafa to your computer and use it in GitHub Desktop.

Select an option

Save warrenseine/13d4c944391da2dc3ab744f1c993cafa to your computer and use it in GitHub Desktop.
Expand replies in the GitHub comments panel
// ==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