// ==UserScript==
// @name JanitorAI – Right Arrow Generate Indicator
// @namespace gf:jai-green-right
// @version 1.0.1
// @description The right arrow turns green when the next click would generate a new output.
// @match https://janitorai.com/*
// @match https://janitorai.com/chats*
// @license MIT
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const SEL = {
right: 'button[aria-label="Right"]',
left: 'button[aria-label="Left"]',
slider: 'div[class*="_botChoicesSlider_"]',
};
const style = document.createElement('style');
style.textContent = `
.jai-green-arrow { outline: 3px solid #22c55e !important; outline-offset: 2px !important; border-radius: 999px !important; }
.jai-green-arrow svg { color: #22c55e !important; fill: currentColor !important; }
`;
document.head.appendChild(style);
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => Array.from(r.querySelectorAll(s));
let ctx = { right: null, left: null, slider: null, sliderMO: null, rebinderMO: null, pulseTimer: null };
// ---------- pairing ----------
function isVisible(el) {
if (!el) return false;
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
const r = el.getBoundingClientRect();
return r.width > 8 && r.height > 8;
}
function bottomMostRight() {
const candidates = $$(SEL.right).filter(isVisible);
if (!candidates.length) return null;
candidates.sort((a, b) => a.getBoundingClientRect().top - b.getBoundingClientRect().top);
return candidates[candidates.length - 1]; // lowest on the page
}
function findNearestSliderFrom(node) {
let el = node;
for (let i = 0; el && i < 12; i++, el = el.parentElement) {
const s = $(SEL.slider, el);
if (s && isVisible(s)) return s;
}
// fallback: the last visible slider on page
const all = $$(SEL.slider).filter(isVisible);
return all[all.length - 1] || null;
}
function pair() {
const right = bottomMostRight();
const slider = right ? findNearestSliderFrom(right) : null;
const left = (slider ? slider.parentElement : document.body)?.querySelector(SEL.left) || null;
const changed = right !== ctx.right || slider !== ctx.slider || left !== ctx.left;
if (!changed) return;
// unhook old slider observer
if (ctx.sliderMO) ctx.sliderMO.disconnect();
ctx.right = right;
ctx.slider = slider;
ctx.left = left;
// hook clicks to pulse recalcs
if (ctx.right) {
ctx.right.addEventListener('click', () => pulse(3000), { capture: true });
}
if (ctx.left) {
ctx.left.addEventListener('click', () => pulse(2000), { capture: true });
}
// watch slider for changes/movement
if (ctx.slider) {
ctx.sliderMO = new MutationObserver(() => {
setTimeout(updateTint, 30);
setTimeout(updateTint, 200);
setTimeout(updateTint, 700);
});
ctx.sliderMO.observe(ctx.slider, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });
const vp = getViewport(ctx.slider);
vp.addEventListener('scroll', updateTint, { passive: true });
vp.addEventListener('transitionend', updateTint, { passive: true });
vp.addEventListener('animationend', updateTint, { passive: true });
}
updateTint();
}
function getViewport(slider) {
let el = slider?.parentElement;
while (el && el !== document.body) {
const ox = getComputedStyle(el).overflowX;
if (ox && ox !== 'visible') return el;
el = el.parentElement;
}
return slider?.parentElement || slider;
}
// ---------- index detection ----------
function sliderItems(slider) {
return $$(':scope > *', slider).filter(isVisible);
}
function indexFromTransform(slider, items) {
const inline = slider.style.transform || '';
let m = inline.match(/translateX\((-?\d+(?:\.\d+)?)%\)/);
if (m) return Math.min(Math.max(Math.round(Math.abs(parseFloat(m[1])) / 100), 0), items.length - 1);
const comp = getComputedStyle(slider).transform;
if (comp && comp !== 'none') {
const mm = comp.match(/matrix\(([^)]+)\)/);
if (mm) {
const parts = mm[1].split(',').map(s => parseFloat(s.trim()));
const tx = parts[4] || 0;
const w = items[0]?.getBoundingClientRect().width || 1;
return Math.min(Math.max(Math.round(Math.abs(tx) / w), 0), items.length - 1);
}
}
return null;
}
function indexByViewport(slider, items) {
const vp = getViewport(slider).getBoundingClientRect();
const centerV = vp.left + vp.width / 2;
let best = 0, bestDist = Infinity;
for (let i = 0; i < items.length; i++) {
const r = items[i].getBoundingClientRect();
const dist = Math.abs((r.left + r.width / 2) - centerV);
if (dist < bestDist) { bestDist = dist; best = i; }
}
return best;
}
function currentIndex(slider) {
const items = sliderItems(slider);
if (!items.length) return 0;
return indexFromTransform(slider, items) ?? indexByViewport(slider, items);
}
// ---------- tint logic ----------
function shouldBeGreen() {
if (!ctx.right || !ctx.slider) return false;
const items = sliderItems(ctx.slider);
if (!items.length) return false;
const idx = currentIndex(ctx.slider);
return idx >= items.length - 1; // last saved variant → next click generates
}
function updateTint() {
if (!ctx.right) return;
ctx.right.classList.toggle('jai-green-arrow', shouldBeGreen());
}
// ---------- keep it fresh ----------
function pulse(ms) {
if (ctx.pulseTimer) clearInterval(ctx.pulseTimer);
const endAt = performance.now() + ms;
ctx.pulseTimer = setInterval(() => {
if (performance.now() > endAt) {
clearInterval(ctx.pulseTimer);
ctx.pulseTimer = null;
}
pair();
updateTint();
}, 180);
}
// Re-pair when React swaps nodes
function startRebinder() {
if (ctx.rebinderMO) ctx.rebinderMO.disconnect();
ctx.rebinderMO = new MutationObserver(() => {
if (!document.contains(ctx.right) || !document.contains(ctx.slider)) pair();
});
ctx.rebinderMO.observe(document.body, { childList: true, subtree: true });
}
// Nudge after generation POSTs
(function patchNetwork() {
const looksGen = (url, method) =>
method === 'POST' &&
/janitorai\.com/i.test(url) &&
/(generate|regenerate|completion|completions|respond|messages|chat)/i.test(url);
const _fetch = window.fetch;
window.fetch = function(resource, init = {}) {
try {
const url = typeof resource === 'string' ? resource : resource.url;
const method = (init?.method || 'GET').toUpperCase();
if (looksGen(url, method)) pulse(5000);
} catch {}
return _fetch.apply(this, arguments);
};
const XO = XMLHttpRequest.prototype.open;
const XS = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url) {
this._m = (method || 'GET').toUpperCase();
this._u = url || '';
return XO.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
try {
if (looksGen(this._u || '', this._m || 'GET')) pulse(5000);
} catch {}
return XS.apply(this, arguments);
};
})();
// SPA nav + boot
function hookSpa() {
const origPush = history.pushState;
history.pushState = function () {
const r = origPush.apply(this, arguments);
setTimeout(() => { pair(); updateTint(); }, 400);
return r;
};
window.addEventListener('popstate', () => setTimeout(() => { pair(); updateTint(); }, 400));
window.addEventListener('resize', updateTint);
}
function boot() {
pair();
startRebinder();
hookSpa();
// initial retries for late mount
let tries = 0;
const iv = setInterval(() => {
pair();
if (++tries > 40 || (ctx.right && ctx.slider)) clearInterval(iv);
}, 200);
}
boot();
})();