Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread
// ==UserScript==
// @name Perplexity Thread ToC Sidebar
// @version 2.3.0
// @description Adds a toggleable floating Table of Contents sidebar showing interleaved prompts and AI responses in a Perplexity thread
// @author Sergio Dias
// @match *://www.perplexity.ai/*
// @grant none
// @run-at document-idle
// @license MIT
// @namespace https://github.com/sergiodias/sergios-userscripts
// ==/UserScript==
(function () {
'use strict';
const TRUNCATE_LEN = 200;
const STORAGE_KEY_MODE = 'ptoc-display-mode';
const DEBOUNCE_MS = 300;
const RETRY_INTERVAL_MS = 500;
const RETRY_MAX = 20;
const SIDEBAR_MIN_WIDTH = 280;
const SIDEBAR_GAP = 16;
const STORAGE_KEY_AUTOLOAD = 'ptoc-autoload';
const AUTOLOAD_SCROLL_DELAY = 250;
const AUTOLOAD_SCROLL_FACTOR = 0.8;
// ── CSS ──────────────────────────────────────────────────────────────────
const CSS = `
#ptoc-toggle {
position: fixed;
top: 80px;
right: 20px;
z-index: 99999;
width: 36px;
height: 36px;
border-radius: 50%;
background: oklch(20.09% 0.003 67.68);
color: oklch(87.35% 0.002 67.8);
border: 1px solid #3a3a3a;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
transition: background 0.15s;
}
#ptoc-toggle:hover {
background: #2e2e2e;
}
#ptoc-sidebar {
position: fixed;
top: 128px;
right: 20px;
min-width: 280px;
max-height: calc(100vh - 128px - 20px);
z-index: 99998;
background: oklch(20.09% 0.003 67.68);
color: oklch(87.35% 0.002 67.8);
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
border: 1px solid #3a3a3a;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
font-family: system-ui, sans-serif;
font-size: 15px;
transition: opacity 0.15s ease, transform 0.15s ease;
}
#ptoc-sidebar.ptoc-hidden {
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
#ptoc-header {
padding: 12px 16px 10px;
border-radius: 12px 12px 0 0;
font-weight: 600;
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #888;
border-bottom: 1px solid #2e2e2e;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
}
#ptoc-mode {
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #2e2e2e;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
transition: background 0.15s;
}
#ptoc-mode:hover {
background: #3a3a3a;
}
#ptoc-header-controls {
display: flex;
gap: 6px;
align-items: center;
}
#ptoc-autoload {
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
background: #2e2e2e;
color: #aaa;
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
transition: background 0.15s, box-shadow 0.15s;
}
#ptoc-autoload:hover {
background: #3a3a3a;
}
#ptoc-autoload.ptoc-on {
background: #252525;
color: #ccc;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.4);
}
@keyframes ptoc-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.ptoc-spinner {
display: inline-block;
width: 10px;
height: 10px;
vertical-align: middle;
border: 2px solid transparent;
border-top-color: #aaa;
border-radius: 50%;
animation: ptoc-spin 0.6s linear infinite;
}
#ptoc-list {
overflow-y: auto;
flex: 1;
padding: 8px 0;
list-style: none;
margin: 0;
}
#ptoc-list::-webkit-scrollbar {
width: 4px;
}
#ptoc-list::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 2px;
}
.ptoc-entry {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 16px;
cursor: pointer;
transition: background 0.1s;
}
.ptoc-entry:hover {
background: #2e2e2e;
}
.ptoc-entry.ptoc-active {
background: #3a3a3a;
}
.ptoc-num {
color: #666;
min-width: 20px;
flex-shrink: 0;
padding-top: 2px;
}
.ptoc-body {
flex: 1;
min-width: 0;
overflow: hidden;
}
.ptoc-prompt {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: oklch(87.35% 0.002 67.8);
font-size: 14px;
line-height: 1.3;
}
.ptoc-response {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: #a0a0a0;
font-style: italic;
font-size: 12px;
line-height: 1.3;
}
.ptoc-skeleton {
height: 12px;
width: 60%;
border-radius: 4px;
background: #2e2e2e;
animation: ptoc-pulse 1.5s ease-in-out infinite;
margin-top: 2px;
}
@keyframes ptoc-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}
.ptoc-skipped {
opacity: 0.6;
}
#ptoc-empty {
padding: 16px;
color: #666;
font-style: italic;
}
`;
// ── State ─────────────────────────────────────────────────────────────────
let sidebar, list, toggleBtn, modeBtn;
let activeElements = []; // parallel array: DOM nodes for scroll targets (may be null for virtualized items)
let cachedPairs = []; // stable text-only cache; only grows within a navigation
let displayMode = localStorage.getItem(STORAGE_KEY_MODE) || 'both'; // 'both' | 'prompts' | 'responses'
let debounceTimer = null;
let mutationObs = null;
let intersectionObs = null;
let intersectionPaused = false;
let userHidden = false;
let currentHref = location.href;
let autoLoadEnabled = localStorage.getItem(STORAGE_KEY_AUTOLOAD) === 'true';
let autoLoadAborted = false;
let autoLoadRunning = false;
let autoLoadBtn = null;
// ── Init ──────────────────────────────────────────────────────────────────
function injectStyles() {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
}
function buildUI() {
// Toggle button
toggleBtn = document.createElement('button');
toggleBtn.id = 'ptoc-toggle';
toggleBtn.title = 'Toggle Table of Contents';
toggleBtn.innerHTML = '☰'; // ☰
toggleBtn.style.visibility = 'hidden'; // hidden until scan() finds pairs
toggleBtn.addEventListener('click', () => {
userHidden = !userHidden;
sidebar.classList.toggle('ptoc-hidden', userHidden);
});
// Sidebar
sidebar = document.createElement('div');
sidebar.id = 'ptoc-sidebar';
sidebar.classList.add('ptoc-hidden');
const header = document.createElement('div');
header.id = 'ptoc-header';
const titleSpan = document.createElement('span');
titleSpan.textContent = 'Thread';
const MODE_LABELS = { both: 'Q+A', prompts: 'Q', responses: 'A' };
const MODES = ['both', 'prompts', 'responses'];
modeBtn = document.createElement('span');
modeBtn.id = 'ptoc-mode';
modeBtn.textContent = MODE_LABELS[displayMode];
modeBtn.addEventListener('click', () => {
displayMode = MODES[(MODES.indexOf(displayMode) + 1) % MODES.length];
modeBtn.textContent = MODE_LABELS[displayMode];
localStorage.setItem(STORAGE_KEY_MODE, displayMode);
renderList(cachedPairs);
});
autoLoadBtn = document.createElement('span');
autoLoadBtn.id = 'ptoc-autoload';
autoLoadBtn.textContent = 'Auto';
if (autoLoadEnabled) autoLoadBtn.classList.add('ptoc-on');
autoLoadBtn.addEventListener('click', () => {
autoLoadEnabled = !autoLoadEnabled;
localStorage.setItem(STORAGE_KEY_AUTOLOAD, autoLoadEnabled);
autoLoadBtn.classList.toggle('ptoc-on', autoLoadEnabled);
if (autoLoadEnabled) {
startAutoLoad();
} else {
autoLoadAborted = true;
autoLoadBtn.classList.remove('ptoc-loading');
}
});
const controls = document.createElement('span');
controls.id = 'ptoc-header-controls';
controls.appendChild(modeBtn);
controls.appendChild(autoLoadBtn);
header.appendChild(titleSpan);
header.appendChild(controls);
list = document.createElement('ul');
list.id = 'ptoc-list';
sidebar.appendChild(header);
sidebar.appendChild(list);
document.body.appendChild(toggleBtn);
document.body.appendChild(sidebar);
}
// ── Auto-load (full-page sweep) ───────────────────────────────────────────
function updateAutoLoadBtn(state) {
if (!autoLoadBtn) return;
if (state === 'idle') {
autoLoadBtn.classList.remove('ptoc-loading', 'ptoc-done');
autoLoadBtn.textContent = 'Auto';
} else if (state === 'loading') {
autoLoadBtn.classList.add('ptoc-loading');
autoLoadBtn.classList.remove('ptoc-done');
autoLoadBtn.innerHTML = "Auto <span class='ptoc-spinner'></span>";
} else if (state === 'done') {
autoLoadBtn.classList.remove('ptoc-loading');
autoLoadBtn.classList.add('ptoc-done');
autoLoadBtn.textContent = 'Auto \u2713';
}
}
function finishAutoLoad() {
autoLoadRunning = false;
intersectionPaused = false;
const container = document.querySelector('.scrollable-container');
if (container) container.scrollTop = container.scrollHeight;
updateAutoLoadBtn('done');
rebindIntersectionObserver();
}
function cleanupAutoLoad(restoreScrollTop) {
autoLoadRunning = false;
autoLoadAborted = false;
intersectionPaused = false;
const container = document.querySelector('.scrollable-container');
if (container && restoreScrollTop != null) container.scrollTop = restoreScrollTop;
updateAutoLoadBtn('idle');
rebindIntersectionObserver();
}
function startAutoLoad() {
const container = document.querySelector('.scrollable-container');
if (!container || autoLoadRunning) return;
autoLoadRunning = true;
autoLoadAborted = false;
intersectionPaused = true;
updateAutoLoadBtn('loading');
const savedScrollTop = container.scrollTop;
container.scrollTop = 0;
scan();
let lastScrollTop = -1;
function step() {
if (autoLoadAborted) {
cleanupAutoLoad(savedScrollTop);
return;
}
const { scrollTop, clientHeight, scrollHeight } = container;
const atBottom = scrollTop + clientHeight >= scrollHeight - 5;
const stalled = scrollTop === lastScrollTop;
if (atBottom || stalled) {
finishAutoLoad();
return;
}
lastScrollTop = scrollTop;
container.scrollTop += clientHeight * AUTOLOAD_SCROLL_FACTOR;
scan();
setTimeout(step, AUTOLOAD_SCROLL_DELAY);
}
setTimeout(step, AUTOLOAD_SCROLL_DELAY);
}
// ── Scan & render ─────────────────────────────────────────────────────────
let lastKey = '';
function isAnswerSkipped(promptEl) {
// Walk up from the group/title element to find the turn-level container,
// then look for a leaf <div> with exact "Answer skipped" text.
let container = promptEl;
for (let i = 0; i < 6; i++) {
if (!container.parentElement) break;
container = container.parentElement;
if (container.classList.contains('scrollable-container')) return false;
}
const divs = container.getElementsByTagName('div');
for (let i = 0; i < divs.length; i++) {
if (divs[i].childElementCount === 0 && divs[i].textContent.trim() === 'Answer skipped') {
return true;
}
}
return false;
}
function scan() {
const promptWrappers = Array.from(document.querySelectorAll('[class*="group/title"]'));
const responseContainers = Array.from(document.querySelectorAll('[id^="markdown-content-"]'));
// Tag each node with its type and sort by DOM order
const allNodes = [
...promptWrappers.map(el => ({ el, type: 'prompt' })),
...responseContainers.map(el => ({ el, type: 'response' })),
];
allNodes.sort((a, b) => {
const pos = a.el.compareDocumentPosition(b.el);
return pos & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
});
// Build livePairs from what's currently in the DOM
const livePairs = [];
for (let i = 0; i < allNodes.length; i++) {
const node = allNodes[i];
if (node.type !== 'prompt') continue;
const spanEl = node.el.querySelector('span[class*="select-text"]');
const promptText = spanEl ? (spanEl.innerText || spanEl.textContent || '').trim() : '';
if (!promptText) continue;
let response = null;
let _order = null;
if (i + 1 < allNodes.length && allNodes[i + 1].type === 'response') {
const respNode = allNodes[i + 1];
const orderMatch = respNode.el.id.match(/^markdown-content-(\d+)$/);
if (orderMatch) _order = parseInt(orderMatch[1], 10);
const p = respNode.el.querySelector('p:first-of-type');
const fullText = p ? (p.innerText || p.textContent || '').trim() : '';
if (fullText) {
const truncated = fullText.length > TRUNCATE_LEN
? fullText.slice(0, TRUNCATE_LEN) + '\u2026'
: fullText;
response = { text: truncated };
}
i++; // always skip the response node when present
}
const _skipped = !response && isAnswerSkipped(node.el);
livePairs.push({ prompt: { text: promptText }, response, _order, _promptEl: node.el, _skipped });
}
// Merge livePairs into cachedPairs (cache only grows, never shrinks within a navigation).
// PRIMARY: use _order (from markdown-content-N IDs) for stable positioning across disjoint
// virtualization windows. FALLBACK: bi-directional neighbor lookup for prompts without _order.
for (let liveIdx = 0; liveIdx < livePairs.length; liveIdx++) {
const live = livePairs[liveIdx];
const existingIdx = cachedPairs.findIndex(c => c.prompt.text === live.prompt.text);
if (existingIdx !== -1) {
if (live.response) cachedPairs[existingIdx].response = live.response;
cachedPairs[existingIdx]._promptEl = live._promptEl;
if (live._order != null) cachedPairs[existingIdx]._order = live._order;
if (live._skipped) cachedPairs[existingIdx]._skipped = true;
} else {
let insertAt = cachedPairs.length;
if (live._order != null) {
// PRIMARY: find first cached item with a higher _order and insert before it
for (let k = 0; k < cachedPairs.length; k++) {
if (cachedPairs[k]._order != null && cachedPairs[k]._order > live._order) {
insertAt = k;
break;
}
}
} else {
// FALLBACK: neighbor lookup — forward first, then backward
for (let j = liveIdx + 1; j < livePairs.length; j++) {
const nextIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
if (nextIdx !== -1) { insertAt = nextIdx; break; }
}
if (insertAt === cachedPairs.length) {
for (let j = liveIdx - 1; j >= 0; j--) {
const prevIdx = cachedPairs.findIndex(c => c.prompt.text === livePairs[j].prompt.text);
if (prevIdx !== -1) { insertAt = prevIdx + 1; break; }
}
}
}
cachedPairs.splice(insertAt, 0, live);
}
}
// Rebuild activeElements from cache (null for virtualized-away items)
activeElements = cachedPairs.map(p => p._promptEl || null);
rebindIntersectionObserver(); // always rebind — DOM refs may have changed
// Change detection on stable cachedPairs (never shrinks, so key never shrinks)
const newKey = cachedPairs.map(p => p.prompt.text + (p._skipped ? '\x01skip' : '') + (p.response?.text ?? '')).join('|');
if (newKey === lastKey) return;
lastKey = newKey;
renderList(cachedPairs);
updateSidebarWidth();
}
function setActive(idx) {
list.querySelectorAll('.ptoc-entry').forEach((el) => el.classList.remove('ptoc-active'));
const activeEl = list.querySelector(`.ptoc-entry[data-index="${idx}"]`);
if (activeEl) activeEl.classList.add('ptoc-active');
}
function doScroll(el, idx) {
intersectionPaused = true;
setActive(idx);
el.scrollIntoView({ behavior: 'instant', block: 'start' });
setTimeout(() => { intersectionPaused = false; }, 400);
}
const PROG_MAX = 10;
const PROG_DELAY = 300;
function progressiveScroll(targetIdx, attempt) {
if (attempt >= PROG_MAX) return;
// Find nearest connected element to scroll toward target
let nearest = -1;
let nearestDist = Infinity;
for (let i = 0; i < activeElements.length; i++) {
const el = activeElements[i];
if (el && el.isConnected) {
const dist = Math.abs(i - targetIdx);
if (dist < nearestDist) { nearestDist = dist; nearest = i; }
}
}
if (nearest === -1) return;
intersectionPaused = true;
setActive(targetIdx);
activeElements[nearest].scrollIntoView({ behavior: 'instant', block: 'start' });
setTimeout(() => {
scan(); // force DOM refresh so virtualizer-loaded elements are picked up
const target = activeElements[targetIdx];
if (target && target.isConnected) {
doScroll(target, targetIdx);
} else {
progressiveScroll(targetIdx, attempt + 1);
}
}, PROG_DELAY);
}
function scrollToEntry(idx) {
const target = activeElements[idx];
if (target && target.isConnected) {
doScroll(target, idx);
} else {
progressiveScroll(idx, 0);
}
}
function setUIVisible(visible) {
toggleBtn.style.visibility = visible ? '' : 'hidden';
if (visible && !userHidden) {
sidebar.classList.remove('ptoc-hidden');
updateSidebarWidth();
} else if (!visible) {
sidebar.classList.add('ptoc-hidden');
}
}
function updateSidebarWidth() {
const threadBody = document.querySelector('[class*="max-w-threadContentWidth"]');
if (!threadBody) return;
const threadRight = threadBody.getBoundingClientRect().right;
const available = window.innerWidth - threadRight - 20 - SIDEBAR_GAP;
sidebar.style.width = Math.max(SIDEBAR_MIN_WIDTH, available) + 'px';
}
function renderList(pairs) {
list.innerHTML = '';
if (pairs.length < 1) {
setUIVisible(false);
return;
}
setUIVisible(true);
pairs.forEach(({ prompt, response, _skipped }, i) => {
const li = document.createElement('li');
li.className = 'ptoc-entry';
li.dataset.index = i;
const num = document.createElement('span');
num.className = 'ptoc-num';
num.textContent = i + 1;
const body = document.createElement('div');
body.className = 'ptoc-body';
if (displayMode === 'both' || displayMode === 'prompts') {
const promptEl = document.createElement('div');
promptEl.className = 'ptoc-prompt';
promptEl.textContent = prompt.text;
body.appendChild(promptEl);
}
if (displayMode === 'both' || displayMode === 'responses') {
if (response) {
const responseEl = document.createElement('div');
responseEl.className = 'ptoc-response';
responseEl.textContent = response.text;
body.appendChild(responseEl);
} else if (_skipped) {
const skippedEl = document.createElement('div');
skippedEl.className = 'ptoc-response ptoc-skipped';
skippedEl.textContent = 'Skipped';
body.appendChild(skippedEl);
} else if (displayMode === 'responses') {
// Skeleton only in responses-only mode; 'both' with no response shows nothing
const skeleton = document.createElement('div');
skeleton.className = 'ptoc-skeleton';
body.appendChild(skeleton);
}
}
li.appendChild(num);
li.appendChild(body);
li.addEventListener('click', () => scrollToEntry(i));
list.appendChild(li);
});
}
// ── Active entry via IntersectionObserver ─────────────────────────────────
function rebindIntersectionObserver() {
if (intersectionObs) intersectionObs.disconnect();
const nonNullElements = activeElements.filter(el => el !== null);
if (nonNullElements.length === 0) return;
intersectionObs = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!intersectionPaused && entry.isIntersecting) {
const idx = activeElements.indexOf(entry.target);
if (idx !== -1) setActive(idx);
}
});
},
{ threshold: 0.3 }
);
nonNullElements.forEach((el) => intersectionObs.observe(el));
}
// ── MutationObserver on thread container ──────────────────────────────────
function startMutationObserver(container) {
if (mutationObs) mutationObs.disconnect();
mutationObs = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(scan, DEBOUNCE_MS);
});
mutationObs.observe(container, { childList: true, subtree: true });
}
// ── Wait for container with retry ─────────────────────────────────────────
function waitForContainer(attempts) {
const container = document.querySelector('.scrollable-container') || document.body;
if (container !== document.body || attempts <= 0) {
startMutationObserver(container);
scan();
if (autoLoadEnabled) setTimeout(startAutoLoad, 300);
return;
}
setTimeout(() => waitForContainer(attempts - 1), RETRY_INTERVAL_MS);
}
// ── SPA navigation detection ──────────────────────────────────────────────
function onNavigation() {
autoLoadAborted = true;
userHidden = false;
lastKey = '';
cachedPairs = [];
setUIVisible(false); // hide immediately; scan() re-shows only if it finds pairs
waitForContainer(RETRY_MAX); // retries until .scrollable-container is in the DOM
}
function watchNavigation() {
// Watch title changes as a proxy for SPA navigation
const titleObs = new MutationObserver(() => {
if (location.href !== currentHref) {
currentHref = location.href;
setTimeout(onNavigation, 500); // brief delay for new DOM to settle
}
});
const titleEl = document.querySelector('title');
if (titleEl) {
titleObs.observe(titleEl, { childList: true });
}
// Also intercept pushState / replaceState
const origPush = history.pushState.bind(history);
const origReplace = history.replaceState.bind(history);
history.pushState = function (...args) {
origPush(...args);
if (location.href !== currentHref) {
currentHref = location.href;
setTimeout(onNavigation, 500);
}
};
history.replaceState = function (...args) {
origReplace(...args);
if (location.href !== currentHref) {
currentHref = location.href;
setTimeout(onNavigation, 500);
}
};
window.addEventListener('popstate', () => {
if (location.href !== currentHref) {
currentHref = location.href;
setTimeout(onNavigation, 500);
}
});
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
function init() {
injectStyles();
buildUI();
waitForContainer(RETRY_MAX);
watchNavigation();
window.addEventListener('resize', updateSidebarWidth);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();