Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save warrenseine/5bec27a7329501d1c868e80388b39a99 to your computer and use it in GitHub Desktop.

Select an option

Save warrenseine/5bec27a7329501d1c868e80388b39a99 to your computer and use it in GitHub Desktop.
Show only unresolved comments in Linear reviews
// ==UserScript==
// @name Show only unresolved comments in Linear reviews
// @namespace http://tampermonkey.net/
// @version 2026-10-24
// @description Add an Unresolved toggle to Linear's Diff tab that collapses every file without an open comment thread
// @author Warren Seine
// @match https://linear.app/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=linear.app
// @run-at document-idle
// @grant none
// ==/UserScript==
(function() {
'use strict';
// The Diff tab of a review.
const DIFF_TAB = /^\/[^/]+\/review\/[^/]+\/changes\b/;
const HEADER = '[data-review-file-header]';
const PATH = '[aria-label^="Copy path"]';
const COLLAPSE = 'Collapse file contents';
const EXPAND = 'Expand file contents';
// A file header shows a chat icon and a count only while the file holds an
// unresolved thread; resolved threads do not count towards it. The header
// renders whether the file is expanded or not, so files can be sorted
// without loading their comments.
const OPEN_THREADS = 'use[href$="#Chat"]';
/*
* The toggle is set per review: a review with no open thread would otherwise
* open with every file shut. Linear keeps a file collapsed across reloads, so
* the files this script collapsed are kept too, to expand them once the
* toggle is off again, whenever that happens.
*/
const STATE = 'linear-review-unresolved:';
const BUTTON = 'linear-review-unresolved-toggle';
// One file per pass, and the poll doubles as the pace. Toggling several in
// the same tick loses all but the first few: the state update is batched and
// the re-render puts back every file it has not committed yet.
const PACE = 120;
// Linear re-renders rows as the list scrolls, so a toggle is retried rather
// than done once. The cap per visit stops the retry from becoming a loop.
const MAX_TOGGLES = 10;
let enabled = false;
let review = null;
let collapsed = new Set();
let manual = new Set();
let attempts = new Map();
function load() {
try {
const state = JSON.parse(localStorage.getItem(STATE + review)) || {};
enabled = state.enabled === true;
collapsed = new Set(state.collapsed || []);
} catch (error) {
enabled = false;
collapsed = new Set();
}
}
function save() {
try {
if (enabled || collapsed.size) {
localStorage.setItem(STATE + review, JSON.stringify({enabled, collapsed: [...collapsed]}));
} else {
localStorage.removeItem(STATE + review);
}
} catch (error) {
/* ignore */
}
}
function filePath(header) {
const copy = header.querySelector(PATH);
return copy ? copy.getAttribute('aria-label').replace(/^Copy path:\s*/, '').trim() : null;
}
function commentsButton() {
for (const button of document.querySelectorAll('button')) {
if (button.id !== BUTTON && /^Comments\d*$/.test(button.textContent.trim())) return button;
}
return null;
}
// The Comments filter only shows on a review that has comments; Display
// options is always there, so it anchors the button otherwise.
function anchor() {
return commentsButton() || document.querySelector('button[aria-label="Display options"]');
}
function paint(button) {
button.setAttribute('aria-pressed', String(enabled));
button.style.background = enabled ? 'rgba(127, 127, 127, 0.16)' : '';
button.style.fontWeight = enabled ? '600' : '';
}
// Linear re-renders the toolbar, which drops anything it did not render
// itself, so the button is put back on every pass. Reports whether it is on
// screen: files are only toggled while the reader can see why.
function placeButton() {
const existing = document.getElementById(BUTTON);
if (existing) {
paint(existing);
return true;
}
const neighbour = anchor();
if (!neighbour || !neighbour.parentElement) return false;
const button = document.createElement('button');
button.id = BUTTON;
button.type = 'button';
button.title = 'Collapse every file without an unresolved comment';
// Borrow the look of the toolbar buttons, minus the open-menu state.
button.className = (commentsButton() || neighbour).className
.split(/\s+/)
.filter(name => !/menuOpen/.test(name))
.join(' ');
button.textContent = 'Unresolved';
button.style.marginRight = '4px';
button.addEventListener('click', () => {
enabled = !enabled;
save();
attempts = new Map();
manual = new Set();
paint(button);
});
paint(button);
neighbour.parentElement.insertAdjacentElement('beforebegin', button);
return true;
}
function toggle(header, path) {
const done = attempts.get(path) || 0;
if (done >= MAX_TOGGLES) return false;
attempts.set(path, done + 1);
header.querySelector(`button[aria-label="${enabled ? COLLAPSE : EXPAND}"]`).click();
return true;
}
// On: collapses the first file still expanded that has no open thread.
// Off: expands the first file this script collapsed. Files the reader
// toggled by hand are left alone either way.
function step() {
if (!DIFF_TAB.test(document.location.pathname)) {
review = null;
return;
}
if (review !== document.location.pathname) {
review = document.location.pathname;
load();
manual = new Set();
attempts = new Map();
}
if (!placeButton()) return;
for (const header of document.querySelectorAll(HEADER)) {
const path = filePath(header);
if (!path || manual.has(path)) continue;
if (enabled) {
if (!header.querySelector(`button[aria-label="${COLLAPSE}"]`)) continue;
if (header.querySelector(OPEN_THREADS)) continue;
if (!toggle(header, path)) continue;
collapsed.add(path);
save();
return;
}
if (!collapsed.has(path)) continue;
if (!header.querySelector(`button[aria-label="${EXPAND}"]`)) {
collapsed.delete(path);
save();
continue;
}
if (!toggle(header, path)) continue;
collapsed.delete(path);
save();
return;
}
}
// A real click on a file's toggle hands that file over to the reader.
document.addEventListener('click', event => {
if (!event.isTrusted || !event.target.closest) return;
const button = event.target.closest('button');
const label = button && button.getAttribute('aria-label');
if (label !== COLLAPSE && label !== EXPAND) return;
const header = button.closest(HEADER);
const path = header && filePath(header);
if (!path) return;
manual.add(path);
if (collapsed.delete(path)) save();
}, true);
// Rows render progressively as the list scrolls, and the Diff tab is reached
// by client-side navigation, so poll instead of running once.
setInterval(step, PACE);
step();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment