Greasy Fork is available in English.
Floating outline modal for ChatGPT conversations
// ==UserScript==
// @name ChatGPT Overview
// @namespace http://tampermonkey.net/
// @version 2026-01-15
// @description Floating outline modal for ChatGPT conversations
// @author stark-bit
// @license GPL-3.0
// @match https://www.chatgpt.com/c/*
// @match https://chatgpt.com/c/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const STORAGE_KEY = 'chatgpt-overview-minimized';
const STORAGE_KEY_COLLAPSED = 'chatgpt-overview-collapsed';
// Cache for extracted article data (prevents re-extraction when articles are virtualized)
const entryCache = new Map();
let lastArticleCount = 0;
let llmCollapsed = false;
const visibleArticles = new Set(); // Track which article indices are currently in view
let initialScrollDone = false; // Track if initial scroll to bottom has been done
let isAtChatEnd = false; // Track if user is at the end of chat
// --- Utility Functions ---
function truncate(str, maxLen) {
if (!str) return '';
str = str.replace(/\s+/g, ' ').trim();
return str.length > maxLen ? str.slice(0, maxLen) + '...' : str;
}
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
function throttle(fn, limit) {
let inThrottle = false;
return (...args) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function isDarkMode() {
return document.documentElement.classList.contains('dark') ||
document.body.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function getMinimizedState() {
return localStorage.getItem(STORAGE_KEY) === 'true';
}
function setMinimizedState(value) {
localStorage.setItem(STORAGE_KEY, value);
}
function getCollapsedState() {
return localStorage.getItem(STORAGE_KEY_COLLAPSED) === 'true';
}
function setCollapsedState(value) {
localStorage.setItem(STORAGE_KEY_COLLAPSED, value);
}
// --- CSS Injection ---
function injectStyles() {
const styles = document.createElement('style');
styles.id = 'chat-overview-styles';
styles.textContent = `
#chat-overview-container {
position: fixed;
top: 70px;
right: 20px;
width: 300px;
min-height: 100px;
max-height: 60vh;
z-index: 10000;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 13px;
display: flex;
flex-direction: column;
transition: width 0.2s ease, min-height 0.2s ease, max-height 0.2s ease, border-radius 0.2s ease;
overflow: hidden;
contain: layout style;
will-change: width, min-height, max-height;
}
#chat-overview-container.minimized {
width: 50px;
min-height: 50px;
max-height: 50px;
border-radius: 8px;
}
/* Light theme */
#chat-overview-container.light {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
color: #1a1a1a;
}
/* Dark theme */
#chat-overview-container.dark {
background: rgba(32, 33, 35, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e5e5e5;
}
#chat-overview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid;
flex-shrink: 0;
}
#chat-overview-header-controls {
display: flex;
align-items: center;
gap: 4px;
}
#chat-overview-container.minimized #chat-overview-header-controls {
justify-content: center;
}
#chat-overview-container.light #chat-overview-header {
border-bottom-color: rgba(0, 0, 0, 0.08);
}
#chat-overview-container.dark #chat-overview-header {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
#chat-overview-container.minimized #chat-overview-header {
border-bottom: none;
padding: 0;
justify-content: center;
height: 50px;
}
#chat-overview-title {
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
}
#chat-overview-container.minimized #chat-overview-title {
display: none;
}
#chat-overview-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 22px;
padding: 6px;
border-radius: 6px;
opacity: 0.6;
transition: opacity 0.15s ease, background 0.15s ease;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
#chat-overview-collapse {
background: none;
border: none;
cursor: pointer;
font-size: 17px;
padding: 6px;
border-radius: 6px;
opacity: 0.6;
transition: opacity 0.15s ease, background 0.15s ease;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
}
#chat-overview-container.minimized #chat-overview-collapse {
display: none;
}
#chat-overview-toggle:hover,
#chat-overview-collapse:hover {
opacity: 1;
}
#chat-overview-container.light #chat-overview-toggle:hover,
#chat-overview-container.light #chat-overview-collapse:hover {
background: rgba(0, 0, 0, 0.05);
}
#chat-overview-container.dark #chat-overview-toggle:hover,
#chat-overview-container.dark #chat-overview-collapse:hover {
background: rgba(255, 255, 255, 0.1);
}
#chat-overview-content {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
#chat-overview-container.minimized #chat-overview-content {
display: none;
}
#chat-overview-content::-webkit-scrollbar {
width: 6px;
}
#chat-overview-content::-webkit-scrollbar-track {
background: transparent;
}
#chat-overview-container.light #chat-overview-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
#chat-overview-container.dark #chat-overview-content::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
.chat-overview-entry {
padding: 6px 12px;
}
.chat-overview-user {
padding-left: 16px;
border-left: 3px solid #6b8aad;
margin-left: 12px;
cursor: pointer;
transition: background 0.1s ease;
border-radius: 0 4px 4px 0;
}
#chat-overview-container.light .chat-overview-user:hover {
background: rgba(107, 138, 173, 0.1);
}
#chat-overview-container.dark .chat-overview-user:hover {
background: rgba(107, 138, 173, 0.2);
}
.chat-overview-user-text {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#chat-overview-container.light .chat-overview-user-text {
color: #1a1a1a;
}
#chat-overview-container.dark .chat-overview-user-text {
color: #e5e5e5;
}
.chat-overview-llm {
margin-left: 24px;
padding-left: 12px;
border-left: 2px solid #7a9a7a;
font-size: 12px;
}
#chat-overview-container.light .chat-overview-llm {
color: #4a4a4a;
}
#chat-overview-container.dark .chat-overview-llm {
color: #b8b8b8;
}
.chat-overview-llm-line {
display: flex;
align-items: center;
padding: 3px 8px;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s ease;
}
#chat-overview-container.light .chat-overview-llm-line:hover {
background: rgba(122, 154, 122, 0.1);
}
#chat-overview-container.dark .chat-overview-llm-line:hover {
background: rgba(122, 154, 122, 0.2);
}
.chat-overview-llm-prefix {
flex-shrink: 0;
margin-right: 6px;
opacity: 0.5;
}
.chat-overview-llm-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
#chat-overview-container.light .chat-overview-llm-text {
color: #4a4a4a;
}
#chat-overview-container.dark .chat-overview-llm-text {
color: #b8b8b8;
}
.chat-overview-code-prefix {
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 10px;
background: rgba(128, 128, 128, 0.2);
padding: 1px 4px;
border-radius: 3px;
margin-right: 6px;
flex-shrink: 0;
}
.chat-overview-empty {
padding: 20px 12px;
text-align: center;
opacity: 0.5;
font-size: 12px;
}
#chat-overview-container.llm-collapsed .chat-overview-llm {
display: none;
}
#chat-overview-container.llm-collapsed .chat-overview-user {
margin-bottom: 4px;
}
/* In-view highlight */
#chat-overview-container.light .chat-overview-user.in-view {
background: rgba(107, 138, 173, 0.12);
}
#chat-overview-container.dark .chat-overview-user.in-view {
background: rgba(107, 138, 173, 0.18);
}
#chat-overview-container.light .chat-overview-llm.in-view {
background: rgba(122, 154, 122, 0.1);
border-radius: 4px;
margin-right: 8px;
}
#chat-overview-container.dark .chat-overview-llm.in-view {
background: rgba(122, 154, 122, 0.15);
border-radius: 4px;
margin-right: 8px;
}
/* End of chat entry */
.chat-overview-end {
padding: 10px 12px;
text-align: center;
cursor: pointer;
transition: background 0.1s ease, opacity 0.1s ease;
border-radius: 4px;
margin: 8px 12px 4px 12px;
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
.chat-overview-end-text {
font-size: 11px;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 0.5px;
}
#chat-overview-container.light .chat-overview-end:hover {
background: rgba(0, 0, 0, 0.05);
}
#chat-overview-container.dark .chat-overview-end:hover {
background: rgba(255, 255, 255, 0.08);
}
.chat-overview-end:hover .chat-overview-end-text {
opacity: 0.8;
}
/* End entry in-view highlight */
#chat-overview-container.light .chat-overview-end.in-view {
background: rgba(128, 128, 128, 0.1);
}
#chat-overview-container.dark .chat-overview-end.in-view {
background: rgba(255, 255, 255, 0.1);
}
.chat-overview-end.in-view .chat-overview-end-text {
opacity: 0.7;
}
`;
document.head.appendChild(styles);
}
// --- Modal Creation ---
function createModal() {
const container = document.createElement('div');
container.id = 'chat-overview-container';
container.innerHTML = `
<div id="chat-overview-header">
<span id="chat-overview-title">Outline</span>
<div id="chat-overview-header-controls">
<button id="chat-overview-collapse" title="Toggle LLM replies">☰</button>
<button id="chat-overview-toggle" title="Toggle overview">⚙</button>
</div>
</div>
<div id="chat-overview-content"></div>
`;
document.body.appendChild(container);
// Set initial theme
updateTheme();
// Set initial minimized state
if (getMinimizedState()) {
container.classList.add('minimized');
}
// Set initial collapsed state
llmCollapsed = getCollapsedState();
if (llmCollapsed) {
container.classList.add('llm-collapsed');
}
// Toggle handler (gear icon - minimize)
const toggleBtn = container.querySelector('#chat-overview-toggle');
toggleBtn.addEventListener('click', () => {
const isMinimized = container.classList.toggle('minimized');
setMinimizedState(isMinimized);
});
// Collapse handler (hamburger icon - collapse LLM entries)
const collapseBtn = container.querySelector('#chat-overview-collapse');
collapseBtn.addEventListener('click', () => {
llmCollapsed = !llmCollapsed;
container.classList.toggle('llm-collapsed', llmCollapsed);
setCollapsedState(llmCollapsed);
// Scroll modal to first highlighted (in-view) entry
scrollModalToHighlighted();
});
return container;
}
// --- Theme Update ---
function updateTheme() {
const container = document.getElementById('chat-overview-container');
if (!container) return;
const dark = isDarkMode();
container.classList.remove('light', 'dark');
container.classList.add(dark ? 'dark' : 'light');
}
// --- Content Extraction & Rendering ---
function extractUserEntry(article) {
const userNode = article.querySelector('[data-message-author-role="user"]');
if (!userNode) return null;
const text = truncate(userNode.innerText, 50);
return {
type: 'user',
text: text || '',
target: article,
complete: !!text
};
}
function extractLLMEntry(article) {
const nodes = article.querySelectorAll('p[data-start], h1[data-start], h2[data-start], h3[data-start], h4[data-start], h5[data-start], h6[data-start], pre[data-start]');
const lines = [];
for (let i = 0; i < Math.min(2, nodes.length); i++) {
const node = nodes[i];
const isCode = node.tagName === 'PRE' || !!node.querySelector('code');
const text = truncate(node.innerText, 40);
lines.push({
text: text || '',
isCode: isCode,
target: node
});
}
// Check if we got meaningful content
const hasContent = lines.some(line => line.text);
return {
type: 'llm',
lines: lines,
target: article,
complete: hasContent
};
}
function scanArticles() {
const articles = document.querySelectorAll('article');
const entries = [];
articles.forEach((article, index) => {
const cacheKey = index;
const cached = entryCache.get(cacheKey);
// Determine if this is a user or LLM article
const isUserArticle = !!article.querySelector('[data-message-author-role="user"]');
// If we have a complete cached entry, use it (just update target refs)
if (cached && cached.complete) {
cached.target = article;
if (cached.type === 'llm' && cached.lines) {
const nodes = article.querySelectorAll('p[data-start], h1[data-start], h2[data-start], h3[data-start], h4[data-start], h5[data-start], h6[data-start], pre[data-start]');
cached.lines.forEach((line, i) => {
line.target = nodes[i] || article;
});
}
entries.push(cached);
return;
}
// Extract fresh data
let entry;
if (isUserArticle) {
entry = extractUserEntry(article);
} else {
entry = extractLLMEntry(article);
}
if (entry) {
// Cache it (even if incomplete, so we know the type)
entryCache.set(cacheKey, entry);
entries.push(entry);
}
});
return entries;
}
function renderOverview() {
const content = document.getElementById('chat-overview-content');
if (!content) return;
const entries = scanArticles();
if (entries.length === 0) {
content.innerHTML = '<div class="chat-overview-empty">No messages yet</div>';
return;
}
content.innerHTML = '';
let articleIndex = 0;
entries.forEach(entry => {
const currentIndex = articleIndex;
const isVisible = visibleArticles.has(currentIndex);
if (entry.type === 'user') {
const userDiv = document.createElement('div');
userDiv.className = 'chat-overview-entry chat-overview-user';
if (isVisible) userDiv.classList.add('in-view');
userDiv.dataset.articleIndex = currentIndex;
userDiv.innerHTML = `<span class="chat-overview-user-text">${escapeHtml(entry.text)}</span>`;
userDiv.addEventListener('click', () => {
entry.target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
content.appendChild(userDiv);
articleIndex++;
} else if (entry.type === 'llm') {
const llmDiv = document.createElement('div');
llmDiv.className = 'chat-overview-entry chat-overview-llm';
if (isVisible) llmDiv.classList.add('in-view');
llmDiv.dataset.articleIndex = currentIndex;
entry.lines.forEach((line, idx) => {
const lineDiv = document.createElement('div');
lineDiv.className = 'chat-overview-llm-line';
const prefix = idx === 0 ? '├─' : '└─';
if (line.isCode) {
lineDiv.innerHTML = `
<span class="chat-overview-llm-prefix">${prefix}</span>
<span class="chat-overview-code-prefix">[code]</span>
<span class="chat-overview-llm-text">${escapeHtml(line.text)}</span>
`;
} else {
lineDiv.innerHTML = `
<span class="chat-overview-llm-prefix">${prefix}</span>
<span class="chat-overview-llm-text">${escapeHtml(line.text)}</span>
`;
}
lineDiv.addEventListener('click', () => {
(line.target || entry.target).scrollIntoView({ behavior: 'smooth', block: 'start' });
});
llmDiv.appendChild(lineDiv);
});
content.appendChild(llmDiv);
articleIndex++;
}
});
// Add "Jump to end" entry at the bottom
const endDiv = document.createElement('div');
endDiv.className = 'chat-overview-entry chat-overview-end';
endDiv.id = 'chat-overview-end-entry';
endDiv.innerHTML = `<span class="chat-overview-end-text">↓ Jump to end</span>`;
endDiv.addEventListener('click', () => {
// Scroll to bottom of chat
const lastArticle = document.querySelector('article:last-of-type');
if (lastArticle) {
lastArticle.scrollIntoView({ behavior: 'smooth', block: 'end' });
} else {
// Fallback: scroll main container to bottom
const main = document.querySelector('main');
if (main) main.scrollTop = main.scrollHeight;
}
});
content.appendChild(endDiv);
// Scroll modal to bottom on initial load (chat starts at bottom)
if (!initialScrollDone) {
content.scrollTop = content.scrollHeight;
initialScrollDone = true;
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Check all currently visible articles and re-extract incomplete entries
function checkVisibleArticles() {
const articles = document.querySelectorAll('article');
let needsRerender = false;
const newVisibleSet = new Set();
articles.forEach((article, index) => {
// Check if article is in viewport
const rect = article.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight && rect.bottom > 0;
if (isVisible) {
newVisibleSet.add(index);
const cached = entryCache.get(index);
if (cached && !cached.complete) {
// Re-extract based on type
const fresh = cached.type === 'user'
? extractUserEntry(article)
: extractLLMEntry(article);
if (fresh && fresh.complete) {
entryCache.set(index, fresh);
needsRerender = true;
}
}
}
});
// Check if user is at the end of chat (last article is visible and near bottom)
const lastArticle = articles[articles.length - 1];
if (lastArticle) {
const rect = lastArticle.getBoundingClientRect();
// Consider "at end" if the bottom of last article is visible
isAtChatEnd = rect.bottom <= window.innerHeight + 100;
}
// Check if visibility changed
const visibilityChanged = newVisibleSet.size !== visibleArticles.size ||
[...newVisibleSet].some(idx => !visibleArticles.has(idx));
// Update the visible set
visibleArticles.clear();
newVisibleSet.forEach(idx => visibleArticles.add(idx));
if (needsRerender) {
renderOverview();
} else if (visibilityChanged) {
// Just update the in-view classes without full re-render
updateInViewClasses();
}
}
// Update in-view classes without full re-render
function updateInViewClasses() {
const content = document.getElementById('chat-overview-content');
if (!content) return;
content.querySelectorAll('[data-article-index]').forEach(el => {
const index = parseInt(el.dataset.articleIndex, 10);
if (visibleArticles.has(index)) {
el.classList.add('in-view');
} else {
el.classList.remove('in-view');
}
});
// Update "Jump to end" entry highlight
const endEntry = document.getElementById('chat-overview-end-entry');
if (endEntry) {
if (isAtChatEnd) {
endEntry.classList.add('in-view');
} else {
endEntry.classList.remove('in-view');
}
}
// Ensure highlighted entries are visible in modal
ensureHighlightedVisible();
}
// Scroll the modal content to the first highlighted entry
function scrollModalToHighlighted() {
const content = document.getElementById('chat-overview-content');
if (!content) return;
// Find first in-view entry (prioritize user entries when collapsed)
const firstHighlighted = content.querySelector('.chat-overview-user.in-view') ||
content.querySelector('.in-view');
if (firstHighlighted) {
// Use setTimeout to allow CSS transition to complete first
setTimeout(() => {
firstHighlighted.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 50);
}
}
// Ensure highlighted entries are visible within the modal's scroll area
function ensureHighlightedVisible() {
const content = document.getElementById('chat-overview-content');
if (!content) return;
const contentRect = content.getBoundingClientRect();
const buffer = 10; // Buffer zone to trigger scroll slightly earlier
// Get all highlighted entries
const highlighted = content.querySelectorAll('.in-view');
if (highlighted.length === 0) return;
// Find the first highlighted entry that is above the visible area
for (const el of highlighted) {
const elRect = el.getBoundingClientRect();
const isAbove = elRect.bottom < contentRect.top + buffer;
if (isAbove) {
// This entry is scrolled out above - scroll it into view
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
}
// Find the last highlighted entry that is below the visible area
for (let i = highlighted.length - 1; i >= 0; i--) {
const el = highlighted[i];
const elRect = el.getBoundingClientRect();
const isBelow = elRect.top > contentRect.bottom - buffer;
if (isBelow) {
// This entry is scrolled out below - scroll it into view
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
return;
}
}
}
// --- Initialization ---
function init() {
// Inject styles
injectStyles();
// Create modal
createModal();
// Initial render
renderOverview();
// Check visible articles after a short delay to catch any that were already in view
// Run multiple times to handle slow-loading content
setTimeout(checkVisibleArticles, 300);
setTimeout(checkVisibleArticles, 800);
setTimeout(checkVisibleArticles, 1500);
// Watch for DOM changes (new messages) - only re-render when article count changes
const debouncedCheck = debounce(() => {
const currentCount = document.querySelectorAll('article').length;
if (currentCount !== lastArticleCount) {
// New articles added - clear cache entries beyond previous count
// to allow fresh extraction of new articles
for (let i = lastArticleCount; i < currentCount; i++) {
entryCache.delete(i);
}
lastArticleCount = currentCount;
renderOverview();
// Re-observe new articles for visibility
observeArticles();
// Also check visible articles after new content loads
setTimeout(checkVisibleArticles, 300);
}
updateTheme();
}, 300);
// Find the main conversation container to observe
const findConversationContainer = () => {
return document.querySelector('main') ||
document.querySelector('[role="main"]') ||
document.body;
};
const mutationObserver = new MutationObserver(debouncedCheck);
// Start observing
const container = findConversationContainer();
mutationObserver.observe(container, {
childList: true,
subtree: true
});
// Initial article count
lastArticleCount = document.querySelectorAll('article').length;
// IntersectionObserver to retry extraction when articles become visible
const intersectionObserver = new IntersectionObserver((entries) => {
let needsRerender = false;
entries.forEach(ioEntry => {
if (ioEntry.isIntersecting) {
const article = ioEntry.target;
const articles = Array.from(document.querySelectorAll('article'));
const index = articles.indexOf(article);
if (index !== -1) {
const cached = entryCache.get(index);
// If cached entry is incomplete, try to re-extract
if (cached && !cached.complete) {
const isUserArticle = cached.type === 'user';
if (isUserArticle) {
const fresh = extractUserEntry(article);
if (fresh && fresh.complete) {
entryCache.set(index, fresh);
needsRerender = true;
}
} else {
const fresh = extractLLMEntry(article);
if (fresh && fresh.complete) {
entryCache.set(index, fresh);
needsRerender = true;
}
}
}
}
}
});
if (needsRerender) {
renderOverview();
}
}, { threshold: 0.1 });
// Function to observe all articles
function observeArticles() {
document.querySelectorAll('article').forEach(article => {
intersectionObserver.observe(article);
});
}
// Initial observation
observeArticles();
// Scroll listener to check visibility and re-extract incomplete entries
const throttledScrollCheck = throttle(checkVisibleArticles, 50);
// Listen on capturing phase to catch scroll events on any scrollable container
window.addEventListener('scroll', throttledScrollCheck, true);
// Also listen for theme changes via media query
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', updateTheme);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();