Last active
October 23, 2026 08:05
-
-
Save warrenseine/5bec27a7329501d1c868e80388b39a99 to your computer and use it in GitHub Desktop.
Show only unresolved comments in Linear reviews
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 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