Backend navigation with dynamic resizing, distinct icons, and bold-header support.
// ==UserScript==
// @name ChatGPT | TOC
// @namespace http://violentmonkey.net/
// @version 2.6
// @description Backend navigation with dynamic resizing, distinct icons, and bold-header support.
// @author Piknockyou (vibe-coded; see credits below)
// @license MIT
// @match https://chatgpt.com/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @grant none
// @run-at document-idle
// ==/UserScript==
/*
* CREDITS & ATTRIBUTION:
* This Userscript is heavily inspired by the "Scroll" extension by Asker Kurtelli.
* Original Extension: https://github.com/asker-kurtelli/scroll
*
* DIFFERENCE IN ARCHITECTURE:
* While the UI concept is derived from Scroll, this script utilizes a different backend approach.
* Instead of scraping the DOM (which relies on messages being rendered), this script fetches
* the conversation tree directly from ChatGPT's internal `backend-api`. This ensures the
* Table of Contents is always 100% complete and accurate, bypassing ChatGPT's lazy-loading/virtualization
* mechanisms that often hide messages from the DOM when they are off-screen.
*/
(function() {
'use strict';
// --- CONFIGURATION ---
const CONFIG = {
defaultWidth: '300px',
headerOffset: 80,
colors: {
bg: '#171717',
border: '#333',
text: '#ececec',
subText: '#999',
hover: '#2a2a2a',
active: '#333',
userIcon: '#999',
aiIcon: '#10a37f'
},
icons: {
user: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>`,
ai: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="23"></line><line x1="8" y1="23" x2="16" y2="23"></line></svg>`,
arrowRight: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>`,
arrowDown: `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`
}
};
// --- STATE ---
const state = {
isOpen: true,
accessToken: null,
currentUuid: null,
elements: {},
expandedItems: new Set()
};
// --- UI SETUP ---
function initUI() {
// Toggle Button
const toggle = document.createElement('div');
toggle.innerHTML = '☰';
toggle.style.cssText = `
position: fixed; top: 10px; right: 10px;
width: 40px; height: 40px;
background: ${CONFIG.colors.bg}; color: ${CONFIG.colors.text};
border: 1px solid ${CONFIG.colors.border}; border-radius: 8px;
z-index: 10000; display: flex; align-items: center; justify-content: center;
cursor: pointer; font-size: 20px; user-select: none;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
`;
toggle.onclick = () => {
state.isOpen = !state.isOpen;
sidebar.style.display = state.isOpen ? 'block' : 'none';
};
// Sidebar Panel
const sidebar = document.createElement('div');
sidebar.style.cssText = `
position: fixed; top: 60px; right: 10px;
width: ${CONFIG.defaultWidth}; max-height: calc(100vh - 80px);
background: ${CONFIG.colors.bg}; border: 1px solid ${CONFIG.colors.border};
border-radius: 12px; z-index: 10000;
display: ${state.isOpen ? 'block' : 'none'};
overflow-y: auto; overflow-x: hidden;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
font-family: Söhne, ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 13px; color: ${CONFIG.colors.text};
resize: horizontal; direction: rtl; /* Resize handle on left */
`;
// Content Container (Reset direction to LTR)
const content = document.createElement('div');
content.id = 'toc-content';
content.style.cssText = `direction: ltr; padding: 10px;`;
content.innerHTML = '<div style="padding:10px; color:#888;">Initializing...</div>';
sidebar.appendChild(content);
document.body.appendChild(toggle);
document.body.appendChild(sidebar);
state.elements = { toggle, sidebar, content };
}
// --- API LOGIC ---
async function getAccessToken() {
try {
const resp = await fetch('/api/auth/session');
if (resp.ok) {
const data = await resp.json();
return data.accessToken;
}
} catch (e) { console.error(e); }
return null;
}
function getUUID() {
const match = window.location.pathname.match(/\/c\/([a-f0-9-]{36})/);
return match ? match[1] : null;
}
async function loadConversation() {
const contentDiv = state.elements.content;
const uuid = getUUID();
if (!uuid) {
contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">No Conversation ID.</div>';
return;
}
if (!state.accessToken) state.accessToken = await getAccessToken();
if (!state.accessToken) return; // Silent fail, retry later
contentDiv.innerHTML = '<div style="padding:10px; color:#aaa;">Loading...</div>';
try {
const response = await fetch(`/backend-api/conversation/${uuid}`, {
headers: {
'Authorization': `Bearer ${state.accessToken}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const data = await response.json();
processData(data);
} catch (err) {
contentDiv.innerHTML = `<div style="padding:10px; color:#f87171;">Error: ${err.message}</div>`;
}
}
// --- HEADER PARSING (BOLD + MARKDOWN) ---
function extractHeaders(text) {
const headers = [];
const lines = text.split('\n');
lines.forEach((line, index) => {
const trimmed = line.trim();
if (!trimmed) return;
// 1. Standard Markdown Headers (# Header)
const mdMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
if (mdMatch) {
headers.push({
type: 'markdown',
text: mdMatch[2].trim(),
level: mdMatch[1].length
});
return;
}
// 2. Bold Headers (**Header** or __Header__)
// Must be the start of the line or the whole line
const boldMatch = trimmed.match(/^(\*\*|__)(.+?)\1:?$/);
if (boldMatch) {
headers.push({
type: 'bold',
text: boldMatch[2].trim(),
level: 3 // Treat bold lines roughly like H3
});
}
});
return headers;
}
// --- DATA PROCESSING ---
function processData(data) {
if (!data.mapping || !data.current_node) return;
const thread = [];
let currId = data.current_node;
while (currId) {
const node = data.mapping[currId];
if (!node) break;
const msg = node.message;
// Strict Filter: No System, No Internal Tool Calls
if (msg && msg.content && msg.content.parts && msg.content.parts.length > 0) {
const isSystem = msg.author.role === 'system';
const isInternal = msg.recipient && msg.recipient !== 'all';
if (!isSystem && !isInternal) {
let text = '';
if (typeof msg.content.parts[0] === 'string') {
text = msg.content.parts[0];
} else if (msg.content.content_type === 'code') {
text = "Code Block";
}
if (text.trim()) {
thread.push({
id: msg.id,
role: msg.author.role,
text: text,
headers: extractHeaders(text)
});
}
}
}
currId = node.parent;
}
thread.reverse();
renderTOC(thread);
}
// --- RENDERING ---
function renderTOC(items) {
const container = document.createElement('div');
// Main Title
container.innerHTML = `<div style="padding-bottom:10px; border-bottom:1px solid #333; margin-bottom:10px; font-weight:700; font-size:14px; color:#fff;">TOC (${items.length})</div>`;
items.forEach((item) => {
const hasHeaders = item.headers.length > 0;
const isExpanded = state.expandedItems.has(item.id);
const isUser = item.role === 'user';
// --- MAIN ROW ---
const row = document.createElement('div');
row.style.cssText = `
padding: 6px 4px; border-radius: 6px; margin-bottom: 2px;
display: flex; align-items: center; gap: 6px;
cursor: pointer; transition: background 0.1s;
min-width: 0; /* Important for flex truncation */
`;
// 1. Expand/Collapse Arrow (Or Spacer)
const arrowBox = document.createElement('div');
arrowBox.style.cssText = `
width: 16px; height: 16px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: ${CONFIG.colors.subText};
`;
if (hasHeaders) {
arrowBox.innerHTML = isExpanded ? CONFIG.icons.arrowDown : CONFIG.icons.arrowRight;
arrowBox.onclick = (e) => {
e.stopPropagation();
toggleExpand(item.id, items);
};
arrowBox.onmouseenter = () => arrowBox.style.color = '#fff';
arrowBox.onmouseleave = () => arrowBox.style.color = CONFIG.colors.subText;
}
row.appendChild(arrowBox);
// 2. Role Icon (User vs AI)
const iconBox = document.createElement('div');
iconBox.style.cssText = `
width: 16px; height: 16px; flex-shrink: 0;
color: ${isUser ? CONFIG.colors.userIcon : CONFIG.colors.aiIcon};
`;
iconBox.innerHTML = isUser ? CONFIG.icons.user : CONFIG.icons.ai;
row.appendChild(iconBox);
// 3. Message Title (Dynamic Truncation)
const titleSpan = document.createElement('span');
// Remove markdown symbols for clean display
const cleanTitle = item.text.split('\n')[0].replace(/[#*`_]/g, '').trim();
titleSpan.textContent = cleanTitle || "Message";
titleSpan.style.cssText = `
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-weight: ${isUser ? '400' : '500'};
color: ${isUser ? '#bbb' : '#fff'};
font-size: 13px;
`;
row.appendChild(titleSpan);
// Row Interaction
row.onmouseenter = () => row.style.background = CONFIG.colors.hover;
row.onmouseleave = () => row.style.background = 'transparent';
row.onclick = () => scrollToMessage(item.id);
container.appendChild(row);
// --- SUBHEADERS ---
if (hasHeaders && isExpanded) {
const subContainer = document.createElement('div');
subContainer.style.cssText = `
margin-left: 22px; /* Align under icon */
border-left: 1px solid ${CONFIG.colors.border};
padding-left: 4px; margin-bottom: 4px;
`;
item.headers.forEach(h => {
const subRow = document.createElement('div');
subRow.textContent = h.text;
subRow.title = h.text;
subRow.style.cssText = `
padding: 4px 8px; cursor: pointer; font-size: 12px;
color: ${CONFIG.colors.subText};
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
border-radius: 4px;
`;
subRow.onmouseenter = () => {
subRow.style.color = CONFIG.colors.text;
subRow.style.background = CONFIG.colors.hover;
};
subRow.onmouseleave = () => {
subRow.style.color = CONFIG.colors.subText;
subRow.style.background = 'transparent';
};
subRow.onclick = (e) => {
e.stopPropagation();
scrollToHeader(item.id, h.text);
};
subContainer.appendChild(subRow);
});
container.appendChild(subContainer);
}
});
state.elements.content.innerHTML = '';
state.elements.content.appendChild(container);
}
function toggleExpand(id, items) {
if (state.expandedItems.has(id)) {
state.expandedItems.delete(id);
} else {
state.expandedItems.add(id);
}
renderTOC(items);
}
// --- NAVIGATION LOGIC ---
function scrollToMessage(messageId) {
const el = document.querySelector(`[data-message-id="${messageId}"]`);
if (el) {
smartScrollTo(el);
flashElement(el);
} else {
alert("Message not rendered in DOM (virtual scrolling). Scroll manually.");
}
}
function scrollToHeader(messageId, headerText) {
const messageEl = document.querySelector(`[data-message-id="${messageId}"]`);
if (!messageEl) return;
// Extended Query: specific headers AND bold tags
const candidates = Array.from(messageEl.querySelectorAll('h1, h2, h3, h4, h5, h6, strong, b'));
// Find best match (loose matching to handle markdown parsing diffs)
const target = candidates.find(el => {
const elText = el.innerText.trim();
// Match if one contains the other
return elText && (elText.includes(headerText) || headerText.includes(elText));
});
if (target) {
smartScrollTo(target);
flashElement(target);
} else {
// Fallback: Scroll to message top
smartScrollTo(messageEl);
flashElement(messageEl);
}
}
function smartScrollTo(element) {
const scrollContainer = document.querySelector('main [class*="overflow-y-auto"]');
if (!scrollContainer) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const elRect = element.getBoundingClientRect();
const containerRect = scrollContainer.getBoundingClientRect();
const currentScroll = scrollContainer.scrollTop;
// Math: Current Scroll + Relative Element Top - Header Offset
const targetTop = currentScroll + (elRect.top - containerRect.top) - CONFIG.headerOffset;
scrollContainer.scrollTo({
top: targetTop,
behavior: 'smooth'
});
}
function flashElement(el) {
const originalTrans = el.style.transition;
const originalBg = el.style.backgroundColor;
el.style.transition = 'background-color 0.4s ease';
el.style.backgroundColor = 'rgba(255, 255, 255, 0.15)';
setTimeout(() => {
el.style.backgroundColor = originalBg;
setTimeout(() => {
el.style.transition = originalTrans;
}, 400);
}, 600);
}
// --- INIT ---
initUI();
setTimeout(loadConversation, 1500);
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
state.expandedItems.clear();
loadConversation();
}
}).observe(document.body, { subtree: true, childList: true });
})();