Created
September 25, 2025 19:55
-
-
Save 23maverick23/77fa747e12e44d0d6624fc8d96cbf4c5 to your computer and use it in GitHub Desktop.
NS: User Script: Claude Chat Table of Contents
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 Claude Chat TOC Navigator | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2.0 | |
| // @description Table of Contents for Claude chat with on-demand updates to prevent UI slowdown during streaming | |
| // @author rymoio | |
| // @match https://*.anthropic.com/* | |
| // @match https://claude.ai/* | |
| // @icon https://claude.ai/favicon.ico | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // --- Performance tracking --- | |
| let activeObservers = []; | |
| let tocVisible = false; | |
| let tocNeedsUpdate = false; | |
| let lastUpdateTime = 0; | |
| let globalEventListeners = []; // Track global event listeners for cleanup | |
| // --- Inject Share Button-like Styles --- | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| #claude-toc-share-btn { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| width: 32px; | |
| height: 32px; | |
| margin-left: 8px; | |
| border-radius: 6px; | |
| border: 1px solid #E5E7EB; | |
| background: #FFF; | |
| color: #23272F; | |
| font-size: 18px; | |
| cursor: pointer; | |
| transition: background 0.15s, color 0.15s, border 0.15s; | |
| position: relative; | |
| z-index: 1001; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.03); | |
| } | |
| #claude-toc-share-btn:hover, #claude-toc-share-btn:focus { | |
| background: #23272F; | |
| color: #FFF; | |
| border-color: #23272F; | |
| outline: none; | |
| } | |
| #claude-toc-share-btn.needs-update { | |
| position: relative; | |
| } | |
| #claude-toc-share-btn.needs-update::after { | |
| content: ''; | |
| position: absolute; | |
| top: 2px; | |
| right: 2px; | |
| width: 6px; | |
| height: 6px; | |
| background: #FF6B47; | |
| border-radius: 50%; | |
| border: 1px solid #FFF; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| #claude-toc-share-btn { | |
| background: #23272F; | |
| color: #F7F7F8; | |
| border-color: #363B47; | |
| } | |
| #claude-toc-share-btn:hover, #claude-toc-share-btn:focus { | |
| background: #FFF; | |
| color: #23272F; | |
| border-color: #FFF; | |
| } | |
| #claude-toc-share-btn.needs-update::after { | |
| border-color: #23272F; | |
| } | |
| } | |
| #claude-toc-container { | |
| position: fixed !important; | |
| top: 60px !important; | |
| right: 20px !important; | |
| width: 320px; | |
| max-height: 70vh; | |
| background: #F7F7F8; | |
| border: 1px solid #E5E7EB; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.12); | |
| z-index: 9999 !important; | |
| padding: 1.25rem 1rem 1rem 1rem; | |
| font-family: 'Inter', 'system-ui', sans-serif; | |
| overflow-y: auto; | |
| border-radius: 10px; | |
| color: #23272F; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| min-width: 240px; | |
| min-height: 40px; | |
| transition: opacity 0.2s; | |
| } | |
| #claude-toc-container.closed { | |
| display: none !important; | |
| } | |
| #claude-toc-container h3 { | |
| font-size: 1.1rem; | |
| font-weight: 500; | |
| margin-bottom: 1rem; | |
| color: #23272F; | |
| border-bottom: 1px solid #E5E7EB; | |
| padding-bottom: 0.5rem; | |
| letter-spacing: -0.01em; | |
| } | |
| .toc-loading { | |
| padding: 1rem; | |
| text-align: center; | |
| color: #6B7280; | |
| font-style: italic; | |
| } | |
| .toc-entry { | |
| padding: 0.5rem 0.75rem; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| transition: background 0.15s, border-color 0.15s, color 0.15s; | |
| font-size: 15px; | |
| margin-bottom: 6px; | |
| background: #F7F7F8; | |
| border: 1px solid #E5E7EB; | |
| border-left-width: 6px; | |
| color: #23272F; | |
| font-family: inherit; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5em; | |
| } | |
| .toc-entry.user { | |
| border-left-color: #D1D5DB; | |
| } | |
| .toc-entry.claude { | |
| border-left-color: #FFB25B; | |
| } | |
| .toc-entry.artifact { | |
| border-left-color: #FF8A00; | |
| background: #FFF5E6; | |
| border-color: #FFB25B; | |
| font-weight: 500; | |
| padding-left: 0.5rem; | |
| margin-left: 1.5rem; | |
| } | |
| .toc-entry:hover, .toc-entry:focus { | |
| background: #ECECEC; | |
| color: #23272F; | |
| border-color: #D1D5DB; | |
| outline: none; | |
| } | |
| .toc-entry.artifact:hover, .toc-entry.artifact:focus { | |
| background: #FFEECC; | |
| border-color: #FF8A00; | |
| color: #23272F; | |
| } | |
| .toc-entry.active { | |
| background: #FFF3E6; | |
| border-left-color: #FFB25B; | |
| border-color: #FFB25B; | |
| color: #23272F; | |
| } | |
| .toc-entry.artifact.active { | |
| background: #FFEECC; | |
| border-left-color: #FF8A00; | |
| border-color: #FF8A00; | |
| color: #23272F; | |
| } | |
| .artifact-icon { | |
| flex-shrink: 0; | |
| width: 16px; | |
| height: 16px; | |
| color: #FF8A00; | |
| } | |
| @media (prefers-color-scheme: dark) { | |
| #claude-toc-container { | |
| background: #23272F; | |
| border-color: #363B47; | |
| color: #F7F7F8; | |
| } | |
| #claude-toc-container h3 { | |
| color: #F7F7F8; | |
| border-bottom: 1px solid #363B47; | |
| } | |
| .toc-loading { | |
| color: #9CA3AF; | |
| } | |
| .toc-entry { | |
| background: #23272F; | |
| border: 1px solid #363B47; | |
| border-left-width: 6px; | |
| color: #F7F7F8; | |
| } | |
| .toc-entry.user { | |
| border-left-color: #363B47; | |
| } | |
| .toc-entry.claude { | |
| border-left-color: #FFB25B; | |
| } | |
| .toc-entry.artifact { | |
| border-left-color: #FF8A00; | |
| background: #2A2416; | |
| border-color: #FFB25B; | |
| margin-left: 1.5rem; | |
| } | |
| .toc-entry:hover, .toc-entry:focus { | |
| background: #181A20; | |
| color: #F7F7F8; | |
| border-color: #363B47; | |
| } | |
| .toc-entry.artifact:hover, .toc-entry.artifact:focus { | |
| background: #332A1A; | |
| border-color: #FF8A00; | |
| color: #F7F7F8; | |
| } | |
| .toc-entry.active { | |
| background: #2A2E39; | |
| border-left-color: #FFB25B; | |
| border-color: #FFB25B; | |
| color: #F7F7F8; | |
| } | |
| .toc-entry.artifact.active { | |
| background: #332A1A; | |
| border-left-color: #FF8A00; | |
| border-color: #FF8A00; | |
| color: #F7F7F8; | |
| } | |
| .artifact-icon { | |
| color: #FFB25B; | |
| } | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // --- Cleanup function to disconnect all observers and event listeners --- | |
| function cleanupObservers() { | |
| activeObservers.forEach(observer => observer.disconnect()); | |
| activeObservers = []; | |
| // Remove global event listeners | |
| globalEventListeners.forEach(({ element, event, handler }) => { | |
| element.removeEventListener(event, handler); | |
| }); | |
| globalEventListeners = []; | |
| } | |
| // --- SPA Navigation Support: Watch for URL changes AND artifact panel changes --- | |
| let lastUrl = location.href; | |
| let lastArtifactState = document.querySelector('[data-testid="artifact-panel"]') ? 'open' : 'closed'; | |
| setInterval(() => { | |
| const currentUrl = location.href; | |
| const currentArtifactState = document.querySelector('[data-testid="artifact-panel"]') ? 'open' : 'closed'; | |
| if (currentUrl !== lastUrl || currentArtifactState !== lastArtifactState) { | |
| lastUrl = currentUrl; | |
| lastArtifactState = currentArtifactState; | |
| cleanupObservers(); | |
| removeTOC(); | |
| tocNeedsUpdate = true; | |
| waitForHeaderAndChatContainer((headerBar, chatContainer) => { | |
| // Check if we're in artifact mode | |
| const artifactPanel = document.querySelector('[data-testid="artifact-panel"]'); | |
| if (artifactPanel) { | |
| waitForArtifactHeaderAndInsertTOC(artifactPanel, chatContainer); | |
| } else { | |
| waitForShareButtonAndInsertTOC(headerBar, chatContainer); | |
| } | |
| }); | |
| } | |
| }, 1000); // Reduced frequency for better performance | |
| // --- Lightweight change detection (only when TOC is closed) --- | |
| function setupLightweightChangeDetection(chatContainer) { | |
| if (!chatContainer) return; | |
| // Only observe when TOC is closed to detect when updates are needed | |
| const lightObserver = new MutationObserver((mutations) => { | |
| if (!tocVisible) { | |
| // Only check if significant changes occurred | |
| const significantChange = mutations.some(mutation => { | |
| return mutation.addedNodes.length > 0 || | |
| mutation.removedNodes.length > 0 || | |
| (mutation.type === 'childList' && mutation.target.matches && | |
| (mutation.target.matches('[data-message-author]') || | |
| mutation.target.closest('[data-message-author]'))); | |
| }); | |
| if (significantChange) { | |
| tocNeedsUpdate = true; | |
| updateTOCButtonState(); | |
| } | |
| } | |
| }); | |
| // Use less aggressive observation settings | |
| lightObserver.observe(chatContainer, { | |
| childList: true, | |
| subtree: false // Only watch direct children, not deep subtree | |
| }); | |
| activeObservers.push(lightObserver); | |
| } | |
| // --- Update TOC button visual state --- | |
| function updateTOCButtonState() { | |
| const tocBtn = document.getElementById('claude-toc-share-btn'); | |
| if (tocBtn) { | |
| tocBtn.classList.toggle('needs-update', tocNeedsUpdate && !tocVisible); | |
| } | |
| } | |
| // --- Wait for artifact header and insert TOC there --- | |
| function waitForArtifactHeaderAndInsertTOC(artifactPanel, chatContainer) { | |
| function check() { | |
| const artifactHeader = | |
| artifactPanel.querySelector('[data-testid="artifact-version-trigger"]')?.closest('.flex.items-center') || | |
| artifactPanel.querySelector('[data-testid="artifact-version-trigger"]')?.parentNode || | |
| artifactPanel.querySelector('.group\\/segmented-control') || | |
| artifactPanel.querySelector('[role="group"]') || | |
| artifactPanel.querySelector('.pr-2.pl-3.flex.items-center.justify-between') || | |
| artifactPanel.querySelector('.flex.items-center.justify-between') || | |
| artifactPanel.querySelector('.flex.items-center.flex-1.gap-2') || | |
| artifactPanel.querySelector('header') || | |
| artifactPanel.querySelector('[class*="header"]'); | |
| if (artifactHeader) { | |
| insertTOCButtonInArtifactHeader(artifactHeader, chatContainer); | |
| } else { | |
| setTimeout(check, 200); | |
| } | |
| } | |
| check(); | |
| } | |
| // --- Insert TOC button in artifact header --- | |
| function insertTOCButtonInArtifactHeader(artifactHeader, chatContainer) { | |
| removeTOC(); | |
| const tocBtn = document.createElement('button'); | |
| tocBtn.id = 'claude-toc-share-btn'; | |
| tocBtn.setAttribute('aria-label', 'Table of Contents'); | |
| tocBtn.title = 'Table of Contents'; | |
| tocBtn.innerHTML = getListSVG(); | |
| if (artifactHeader.parentNode) { | |
| artifactHeader.parentNode.insertBefore(tocBtn, artifactHeader.nextSibling); | |
| } else { | |
| artifactHeader.appendChild(tocBtn); | |
| } | |
| const tocContainer = document.createElement('div'); | |
| tocContainer.id = 'claude-toc-container'; | |
| tocContainer.className = 'closed'; | |
| tocContainer.innerHTML = ` | |
| <h3>Table of Contents</h3> | |
| <div id="claude-toc-content"></div> | |
| `; | |
| tocContainer.style.position = 'fixed'; | |
| tocContainer.style.right = '20px'; | |
| tocContainer.style.top = '60px'; | |
| tocContainer.style.minWidth = '240px'; | |
| tocContainer.style.minHeight = '40px'; | |
| document.body.appendChild(tocContainer); | |
| setupTOCToggle(tocBtn, tocContainer, chatContainer); | |
| setupLightweightChangeDetection(chatContainer); | |
| tocNeedsUpdate = true; | |
| updateTOCButtonState(); | |
| } | |
| // --- Remove TOC button and container if present --- | |
| function removeTOC() { | |
| document.getElementById('claude-toc-share-btn')?.remove(); | |
| document.getElementById('claude-toc-container')?.remove(); | |
| cleanupObservers(); | |
| } | |
| // --- Wait for both header and chat container --- | |
| function waitForHeaderAndChatContainer(callback) { | |
| function check() { | |
| let headerBar = null; | |
| const allHeaders = document.querySelectorAll('header, [class*="Header"]'); | |
| for (const header of allHeaders) { | |
| const hasShareBtn = Array.from(header.querySelectorAll('button, [role="button"]')) | |
| .some(btn => btn.textContent?.trim().toLowerCase() === 'share' || btn.getAttribute('aria-label')?.toLowerCase() === 'share'); | |
| if (hasShareBtn) { | |
| headerBar = header; | |
| break; | |
| } | |
| } | |
| if (!headerBar) { | |
| headerBar = document.querySelector('header') || document.querySelector('[class*="Header"]'); | |
| } | |
| const chatContainer = | |
| document.querySelector('.flex-1.flex.flex-col.gap-3') || | |
| document.querySelector('.conversation-container') || | |
| document.querySelector('.message-container'); | |
| if (headerBar && chatContainer) { | |
| callback(headerBar, chatContainer); | |
| } else { | |
| setTimeout(check, 200); | |
| } | |
| } | |
| check(); | |
| } | |
| // --- Main: Wait for Share button, then insert TOC --- | |
| function waitForShareButtonAndInsertTOC(headerBar, chatContainer) { | |
| let shareBtn = Array.from(headerBar.querySelectorAll('button, [role="button"]')) | |
| .find(btn => btn.textContent?.trim().toLowerCase() === 'share' || btn.getAttribute('aria-label')?.toLowerCase() === 'share'); | |
| const buttonContainer = headerBar.querySelector('[data-testid="chat-actions"]') || | |
| headerBar.querySelector('.flex.items-center.gap-1') || | |
| headerBar.querySelector('.right-3.flex.gap-2') || | |
| shareBtn?.parentNode; | |
| if (shareBtn && buttonContainer) { | |
| insertTOCButton(shareBtn, headerBar, chatContainer, buttonContainer); | |
| return; | |
| } else if (buttonContainer) { | |
| insertTOCButton(null, headerBar, chatContainer, buttonContainer); | |
| return; | |
| } | |
| const observer = new MutationObserver(() => { | |
| let shareBtn = Array.from(headerBar.querySelectorAll('button, [role="button"]')) | |
| .find(btn => btn.textContent?.trim().toLowerCase() === 'share' || btn.getAttribute('aria-label')?.toLowerCase() === 'share'); | |
| const buttonContainer = headerBar.querySelector('[data-testid="chat-actions"]') || | |
| headerBar.querySelector('.flex.items-center.gap-1') || | |
| headerBar.querySelector('.right-3.flex.gap-2') || | |
| shareBtn?.parentNode; | |
| if (buttonContainer) { | |
| observer.disconnect(); | |
| insertTOCButton(shareBtn, headerBar, chatContainer, buttonContainer); | |
| } | |
| }); | |
| observer.observe(headerBar, { childList: true, subtree: true }); | |
| activeObservers.push(observer); | |
| } | |
| // --- Insert TOC button and panel --- | |
| function insertTOCButton(shareBtn, headerBar, chatContainer, buttonContainer) { | |
| removeTOC(); | |
| const tocBtn = document.createElement('button'); | |
| tocBtn.id = 'claude-toc-share-btn'; | |
| tocBtn.setAttribute('aria-label', 'Table of Contents'); | |
| tocBtn.title = 'Table of Contents'; | |
| tocBtn.innerHTML = getListSVG(); | |
| if (shareBtn && shareBtn.parentNode) { | |
| shareBtn.parentNode.insertBefore(tocBtn, shareBtn.nextSibling); | |
| } else if (buttonContainer) { | |
| buttonContainer.appendChild(tocBtn); | |
| } else { | |
| headerBar.appendChild(tocBtn); | |
| } | |
| const tocContainer = document.createElement('div'); | |
| tocContainer.id = 'claude-toc-container'; | |
| tocContainer.className = 'closed'; | |
| tocContainer.innerHTML = ` | |
| <h3>Table of Contents</h3> | |
| <div id="claude-toc-content"></div> | |
| `; | |
| tocContainer.style.position = 'fixed'; | |
| tocContainer.style.right = '20px'; | |
| tocContainer.style.top = '60px'; | |
| tocContainer.style.minWidth = '240px'; | |
| tocContainer.style.minHeight = '40px'; | |
| document.body.appendChild(tocContainer); | |
| setupTOCToggle(tocBtn, tocContainer, chatContainer); | |
| setupLightweightChangeDetection(chatContainer); | |
| tocNeedsUpdate = true; | |
| updateTOCButtonState(); | |
| } | |
| // --- Setup TOC toggle functionality --- | |
| function setupTOCToggle(tocBtn, tocContainer, chatContainer) { | |
| let dismissHandlersActive = false; | |
| let outsideClickHandler = null; | |
| let escapeKeyHandler = null; | |
| function closeTOC() { | |
| tocVisible = false; | |
| tocContainer.classList.add('closed'); | |
| tocBtn.innerHTML = getListSVG(); | |
| updateTOCButtonState(); | |
| // Remove dismiss handlers when closing | |
| if (dismissHandlersActive) { | |
| if (outsideClickHandler) { | |
| document.removeEventListener('mousedown', outsideClickHandler, true); | |
| } | |
| if (escapeKeyHandler) { | |
| document.removeEventListener('keydown', escapeKeyHandler, true); | |
| } | |
| dismissHandlersActive = false; | |
| } | |
| } | |
| function openTOC() { | |
| tocVisible = true; | |
| tocContainer.classList.remove('closed'); | |
| tocBtn.innerHTML = getCloseSVG(); | |
| // Update TOC content when opening (on-demand) | |
| if (tocNeedsUpdate) { | |
| updateTOC(); | |
| tocNeedsUpdate = false; | |
| } | |
| updateTOCButtonState(); | |
| // Add dismiss handlers only after opening | |
| if (!dismissHandlersActive) { | |
| // Outside click handler | |
| outsideClickHandler = (e) => { | |
| if ( | |
| tocVisible && | |
| tocContainer && | |
| tocBtn && | |
| !tocContainer.contains(e.target) && | |
| !tocBtn.contains(e.target) | |
| ) { | |
| closeTOC(); | |
| } | |
| }; | |
| // Escape key handler | |
| escapeKeyHandler = (e) => { | |
| if (e.key === 'Escape' && tocVisible) { | |
| e.preventDefault(); | |
| closeTOC(); | |
| } | |
| }; | |
| // Add the handlers | |
| document.addEventListener('mousedown', outsideClickHandler, true); | |
| document.addEventListener('keydown', escapeKeyHandler, true); | |
| dismissHandlersActive = true; | |
| } | |
| } | |
| // Button click handler - this is the only handler we attach immediately | |
| const buttonClickHandler = (e) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| if (tocVisible) { | |
| closeTOC(); | |
| } else { | |
| openTOC(); | |
| } | |
| }; | |
| tocBtn.addEventListener('click', buttonClickHandler); | |
| } | |
| // --- Update TOC entries (now called on-demand only) --- | |
| function updateTOC() { | |
| const tocContent = document.getElementById('claude-toc-content'); | |
| if (!tocContent) return; | |
| // Show loading state | |
| tocContent.innerHTML = '<div class="toc-loading">Updating...</div>'; | |
| // Use requestAnimationFrame to avoid blocking the UI | |
| requestAnimationFrame(() => { | |
| try { | |
| const allElements = collectTOCElements(); | |
| renderTOCEntries(tocContent, allElements); | |
| lastUpdateTime = Date.now(); | |
| } catch (error) { | |
| console.warn('TOC update failed:', error); | |
| tocContent.innerHTML = '<div class="toc-loading">Failed to load</div>'; | |
| } | |
| }); | |
| } | |
| // --- Collect all TOC elements --- | |
| function collectTOCElements() { | |
| let allElements = []; | |
| // User messages | |
| const userSelectors = [ | |
| '.group.relative.inline-flex.gap-2.bg-bg-300.rounded-xl', | |
| '.user-message', | |
| '[data-message-author="user"]', | |
| '.message.user' | |
| ]; | |
| let userMessages = []; | |
| for (const selector of userSelectors) { | |
| const elements = document.querySelectorAll(selector); | |
| if (elements.length > 0) { | |
| userMessages = Array.from(elements); | |
| break; | |
| } | |
| } | |
| userMessages.forEach((msg, index) => { | |
| let textElement = msg.querySelector('[data-testid="user-message"]') || | |
| msg.querySelector('.message-content') || | |
| msg; | |
| const textContent = textElement?.textContent?.trim(); | |
| if (textContent) { | |
| allElements.push({ | |
| type: 'user', | |
| content: truncateText(textContent, 50), | |
| element: msg, | |
| position: getElementPosition(msg) | |
| }); | |
| } | |
| }); | |
| // Claude responses | |
| const claudeSelectors = [ | |
| '.group.relative.-tracking-\\[0\\.015em\\]', | |
| '.claude-message', | |
| '[data-message-author="assistant"]', | |
| '.message.assistant' | |
| ]; | |
| let claudeMessages = []; | |
| for (const selector of claudeSelectors) { | |
| const elements = document.querySelectorAll(selector); | |
| if (elements.length > 0) { | |
| claudeMessages = Array.from(elements); | |
| break; | |
| } | |
| } | |
| claudeMessages.forEach((msg, index) => { | |
| let textElement = msg.querySelector('.whitespace-pre-wrap.break-words') || | |
| msg.querySelector('.message-content') || | |
| msg.querySelector('p'); | |
| const textContent = textElement?.textContent?.trim(); | |
| if (textContent) { | |
| allElements.push({ | |
| type: 'claude', | |
| content: truncateText(textContent, 50), | |
| element: msg, | |
| position: getElementPosition(msg) | |
| }); | |
| } | |
| }); | |
| // Artifacts | |
| const artifactSelectors = [ | |
| '.artifact-block-cell', | |
| '.group\\/artifact-block', | |
| '[class*="artifact-block"]', | |
| '[class*="artifact"]' | |
| ]; | |
| let artifacts = []; | |
| for (const selector of artifactSelectors) { | |
| const elements = document.querySelectorAll(selector); | |
| if (elements.length > 0) { | |
| artifacts = Array.from(elements); | |
| break; | |
| } | |
| } | |
| if (artifacts.length === 0) { | |
| const potentialArtifacts = document.querySelectorAll('div'); | |
| artifacts = Array.from(potentialArtifacts).filter(el => { | |
| const classes = el.className; | |
| return classes.includes('artifact') || | |
| el.querySelector('[class*="artifact"]') || | |
| el.querySelector('.leading-tight.text-sm.line-clamp-1'); | |
| }); | |
| } | |
| artifacts.forEach((artifact, index) => { | |
| let titleElement = artifact.querySelector('.leading-tight.text-sm.line-clamp-1') || | |
| artifact.querySelector('[class*="title"]') || | |
| artifact.querySelector('h1, h2, h3, h4, h5, h6') || | |
| artifact.querySelector('.text-sm'); | |
| let artifactTitle = titleElement?.textContent?.trim(); | |
| if (!artifactTitle || artifactTitle.length < 3) { | |
| artifactTitle = 'Artifact'; | |
| const classes = artifact.className.toLowerCase(); | |
| if (classes.includes('react') || artifact.querySelector('[class*="react"]')) { | |
| artifactTitle = 'React Component'; | |
| } else if (classes.includes('html') || artifact.querySelector('iframe')) { | |
| artifactTitle = 'HTML Document'; | |
| } else if (classes.includes('code') || artifact.querySelector('code, pre')) { | |
| artifactTitle = 'Code Snippet'; | |
| } else if (classes.includes('text') || classes.includes('markdown')) { | |
| artifactTitle = 'Text Document'; | |
| } | |
| } | |
| allElements.push({ | |
| type: 'artifact', | |
| content: truncateText(artifactTitle, 40), | |
| element: artifact, | |
| position: getElementPosition(artifact) | |
| }); | |
| }); | |
| // Sort by DOM position | |
| allElements.sort((a, b) => a.position - b.position); | |
| return allElements; | |
| } | |
| // --- Render TOC entries --- | |
| function renderTOCEntries(tocContent, allElements) { | |
| tocContent.innerHTML = ''; | |
| if (allElements.length === 0) { | |
| tocContent.innerHTML = '<div class="toc-loading">No messages found</div>'; | |
| return; | |
| } | |
| allElements.forEach((item, idx) => { | |
| const entry = document.createElement('div'); | |
| entry.className = `toc-entry ${item.type}`; | |
| entry.tabIndex = 0; | |
| entry.dataset.index = idx; | |
| if (item.type === 'artifact') { | |
| entry.innerHTML = ` | |
| <div class="artifact-icon">${getArtifactSVG()}</div> | |
| <span style="font-style: italic;">${item.content}</span> | |
| `; | |
| } else if (item.type === 'user') { | |
| entry.innerHTML = ` | |
| <div class="artifact-icon">${getUserMessageSVG()}</div> | |
| <span>${item.content}</span> | |
| `; | |
| } else { | |
| entry.innerHTML = ` | |
| <div class="artifact-icon">${getClaudeMessageSVG()}</div> | |
| <span>${item.content}</span> | |
| `; | |
| } | |
| entry.addEventListener('click', () => { | |
| item.element.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| highlightElement(item.element); | |
| setActiveEntry(idx); | |
| }); | |
| entry.addEventListener('mouseenter', () => { | |
| entry.classList.add('active'); | |
| }); | |
| entry.addEventListener('mouseleave', () => { | |
| entry.classList.remove('active'); | |
| }); | |
| entry.addEventListener('focus', () => { | |
| entry.classList.add('active'); | |
| }); | |
| entry.addEventListener('blur', () => { | |
| entry.classList.remove('active'); | |
| }); | |
| tocContent.appendChild(entry); | |
| }); | |
| } | |
| // --- Helper functions (unchanged) --- | |
| function getElementPosition(element) { | |
| return element.getBoundingClientRect().top + window.scrollY; | |
| } | |
| function truncateText(text, maxLength) { | |
| if (text.length <= maxLength) return text; | |
| return text.substr(0, maxLength) + '...'; | |
| } | |
| function highlightElement(element) { | |
| const originalBackground = element.style.background; | |
| const originalTransition = element.style.transition; | |
| element.style.transition = 'background-color 0.5s ease'; | |
| element.style.backgroundColor = isDarkMode() | |
| ? 'rgba(255, 178, 91, 0.3)' | |
| : 'rgba(255, 178, 91, 0.15)'; | |
| setTimeout(() => { | |
| element.style.backgroundColor = originalBackground; | |
| setTimeout(() => { | |
| element.style.transition = originalTransition; | |
| }, 500); | |
| }, 1000); | |
| } | |
| function isDarkMode() { | |
| return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| } | |
| function setActiveEntry(idx) { | |
| document.querySelectorAll('.toc-entry').forEach((el, i) => { | |
| el.classList.toggle('active', i === idx); | |
| }); | |
| } | |
| function getListSVG() { | |
| return ` | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none"> | |
| <rect x="4" y="7" width="16" height="2" rx="1" fill="currentColor"/> | |
| <rect x="4" y="11" width="16" height="2" rx="1" fill="currentColor"/> | |
| <rect x="4" y="15" width="16" height="2" rx="1" fill="currentColor"/> | |
| </svg> | |
| `; | |
| } | |
| function getCloseSVG() { | |
| return ` | |
| <svg width="18" height="18" viewBox="0 0 24 24" fill="none"> | |
| <line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| `; | |
| } | |
| function getArtifactSVG() { | |
| return ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none"> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> | |
| <polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/> | |
| <line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| <line x1="10" y1="9" x2="8" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> | |
| </svg> | |
| `; | |
| } | |
| function getUserMessageSVG() { | |
| return ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h4l4 4 4-4h4c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/> | |
| </svg> | |
| `; | |
| } | |
| function getClaudeMessageSVG() { | |
| return ` | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> | |
| <path d="M12 3C6.48 3 2 6.58 2 11c0 2.03.78 3.87 2.06 5.28L3 21l5.28-1.06C9.69 20.22 10.82 20.5 12 20.5c5.52 0 10-3.58 10-8S17.52 3 12 3z"/> | |
| </svg> | |
| `; | |
| } | |
| // --- Start --- | |
| waitForHeaderAndChatContainer((headerBar, chatContainer) => { | |
| waitForShareButtonAndInsertTOC(headerBar, chatContainer); | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment