Instantly share code, notes, and snippets.
Last active
May 6, 2026 09:47
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save basyura/73dec4df430d290121285bd14cb64bb3 to your computer and use it in GitHub Desktop.
ChatGPT User Message Navigator
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 ChatGPT User Message Navigator | |
| // @namespace local.chatgpt.user-message-nav | |
| // @version 0.17.0 | |
| // @description ChatGPT の自分の発言をサイドリスト化してクリックでジャンプする | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @run-at document-idle | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com | |
| // @grant none | |
| // ==/UserScript== | |
| (() => { | |
| "use strict"; | |
| const MESSAGE_SELECTOR = ".user-message-bubble-color"; | |
| const PANEL_WIDTH = 300; | |
| const COLLAPSED_PANEL_WIDTH = 42; | |
| let seen = new WeakSet(); | |
| let items = []; | |
| let lastJumpIndex = -1; | |
| let lastJumpAt = 0; | |
| let activeUpdateFrame = 0; | |
| let currentPath = location.pathname; | |
| const panel = document.createElement("aside"); | |
| panel.id = "cgpt-user-message-nav"; | |
| panel.innerHTML = ` | |
| <div class="cgpt-nav-header"> | |
| <span>Comments</span> | |
| <button type="button" id="cgpt-nav-toggle">−</button> | |
| </div> | |
| <ul id="cgpt-nav-list"></ul> | |
| `; | |
| const style = document.createElement("style"); | |
| style.textContent = ` | |
| #cgpt-user-message-nav { | |
| position: fixed; | |
| top: 72px; | |
| right: 12px; | |
| z-index: 999999; | |
| width: ${PANEL_WIDTH}px; | |
| max-height: calc(100vh - 96px); | |
| background: rgba(32, 33, 35, 0.94); | |
| color: #f5f5f5; | |
| border: 1px solid rgba(255,255,255,0.14); | |
| border-radius: 10px; | |
| box-shadow: 0 8px 24px rgba(0,0,0,0.28); | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| font-size: 13px; | |
| overflow: hidden; | |
| } | |
| #cgpt-user-message-nav.is-collapsed { | |
| width: ${COLLAPSED_PANEL_WIDTH}px; | |
| } | |
| .cgpt-nav-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 8px; | |
| padding: 8px 10px; | |
| border-bottom: 1px solid rgba(255,255,255,0.12); | |
| font-weight: 600; | |
| } | |
| #cgpt-nav-toggle { | |
| width: 24px; | |
| height: 24px; | |
| border: 0; | |
| border-radius: 6px; | |
| background: rgba(255,255,255,0.12); | |
| color: #fff; | |
| cursor: pointer; | |
| } | |
| #cgpt-user-message-nav.is-collapsed .cgpt-nav-header { | |
| justify-content: center; | |
| padding: 8px; | |
| border-bottom: 0; | |
| } | |
| #cgpt-user-message-nav.is-collapsed .cgpt-nav-header span { | |
| display: none; | |
| } | |
| #cgpt-nav-list { | |
| max-height: calc(100vh - 140px); | |
| overflow-y: auto; | |
| padding: 6px 8px; | |
| margin: 0; | |
| list-style: none; | |
| } | |
| .cgpt-nav-li { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr); | |
| align-items: baseline; | |
| min-width: 0; | |
| margin: 0; | |
| padding: 4px 6px; | |
| } | |
| .cgpt-nav-item { | |
| display: block; | |
| min-width: 0; | |
| width: 100%; | |
| border: 0; | |
| background: transparent; | |
| color: inherit; | |
| text-align: left; | |
| padding: 0; | |
| cursor: pointer; | |
| line-height: 1.45; | |
| font: inherit; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .cgpt-nav-item:hover { | |
| text-decoration: underline; | |
| } | |
| .cgpt-nav-li.is-active { | |
| background: rgba(255,255,255,0.14); | |
| border-radius: 6px; | |
| text-underline-offset: 3px; | |
| } | |
| .cgpt-nav-li.is-active .cgpt-nav-item { | |
| text-decoration: underline; | |
| } | |
| #cgpt-user-message-nav.is-collapsed #cgpt-nav-list { | |
| display: none; | |
| } | |
| `; | |
| document.documentElement.appendChild(style); | |
| document.body.appendChild(panel); | |
| const list = panel.querySelector("#cgpt-nav-list"); | |
| const toggle = panel.querySelector("#cgpt-nav-toggle"); | |
| toggle.addEventListener("click", () => { | |
| panel.classList.toggle("is-collapsed"); | |
| toggle.textContent = panel.classList.contains("is-collapsed") ? "+" : "−"; | |
| applyMainOffset(); | |
| }); | |
| function applyMainOffset() { | |
| const main = document.querySelector("main"); | |
| if (!main) return; | |
| if (!main.dataset.cgptOriginalMarginLeft) { | |
| main.dataset.cgptOriginalMarginLeft = main.style.marginLeft || ""; | |
| } | |
| main.style.marginLeft = panel.classList.contains("is-collapsed") | |
| ? `-${COLLAPSED_PANEL_WIDTH}px` | |
| : `-${PANEL_WIDTH}px`; | |
| } | |
| function resetList() { | |
| seen = new WeakSet(); | |
| items = []; | |
| lastJumpIndex = -1; | |
| lastJumpAt = 0; | |
| list.replaceChildren(); | |
| } | |
| function requestActiveListItemUpdate() { | |
| if (activeUpdateFrame) return; | |
| activeUpdateFrame = window.requestAnimationFrame(() => { | |
| activeUpdateFrame = 0; | |
| updateActiveListItem(); | |
| }); | |
| } | |
| function handleRouteChange() { | |
| if (currentPath === location.pathname) return; | |
| currentPath = location.pathname; | |
| resetList(); | |
| window.setTimeout(() => scan(), 300); | |
| } | |
| function getText(el) { | |
| return el.innerText.trim().replace(/\s+/g, " "); | |
| } | |
| function addMessage(el) { | |
| if (seen.has(el)) return; | |
| const text = getText(el); | |
| if (!text) return; | |
| seen.add(el); | |
| items.push(el); | |
| const itemIndex = items.length - 1; | |
| const li = document.createElement("li"); | |
| li.className = "cgpt-nav-li"; | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| button.className = "cgpt-nav-item"; | |
| button.textContent = text; | |
| button.addEventListener("click", () => jumpToMessage(itemIndex)); | |
| li.appendChild(button); | |
| list.appendChild(li); | |
| } | |
| function scan(root = document) { | |
| handleRouteChange(); | |
| if (items.some((el) => !el.isConnected)) { | |
| resetList(); | |
| root = document; | |
| } | |
| root.querySelectorAll?.(MESSAGE_SELECTOR).forEach(addMessage); | |
| updateActiveListItem(); | |
| } | |
| function jumpToMessage(index) { | |
| const el = items[index]; | |
| if (!el?.isConnected) { | |
| scan(); | |
| return; | |
| } | |
| lastJumpIndex = index; | |
| lastJumpAt = Date.now(); | |
| updateActiveListItem(index); | |
| el.scrollIntoView({ | |
| behavior: "smooth", | |
| block: "center", | |
| }); | |
| } | |
| function getNearestVisibleIndex() { | |
| const centerY = window.innerHeight / 2; | |
| let nearestIndex = -1; | |
| let nearestDistance = Number.POSITIVE_INFINITY; | |
| items.forEach((el, index) => { | |
| if (!el.isConnected) return; | |
| const rect = el.getBoundingClientRect(); | |
| const messageCenterY = rect.top + rect.height / 2; | |
| const distance = Math.abs(messageCenterY - centerY); | |
| if (distance < nearestDistance) { | |
| nearestIndex = index; | |
| nearestDistance = distance; | |
| } | |
| }); | |
| return nearestIndex; | |
| } | |
| function updateActiveListItem(activeIndex = getNearestVisibleIndex()) { | |
| const listItems = list.querySelectorAll(".cgpt-nav-li"); | |
| listItems.forEach((li, index) => { | |
| li.classList.toggle("is-active", index === activeIndex); | |
| }); | |
| } | |
| function jumpBy(delta) { | |
| scan(); | |
| if (!items.length) return; | |
| const isRapidKeyRepeat = Date.now() - lastJumpAt < 700; | |
| const baseIndex = | |
| isRapidKeyRepeat && items[lastJumpIndex]?.isConnected | |
| ? lastJumpIndex | |
| : getNearestVisibleIndex(); | |
| const nextIndex = Math.max( | |
| 0, | |
| Math.min(items.length - 1, baseIndex + delta) | |
| ); | |
| jumpToMessage(nextIndex); | |
| } | |
| document.addEventListener( | |
| "keydown", | |
| (event) => { | |
| const key = event.key.toLowerCase(); | |
| const isNextKey = key === "arrowdown" || (event.ctrlKey && key === "n"); | |
| const isPrevKey = key === "arrowup" || (event.ctrlKey && key === "p"); | |
| if (!isNextKey && !isPrevKey) return; | |
| event.preventDefault(); | |
| event.stopPropagation(); | |
| jumpBy(isNextKey ? 1 : -1); | |
| }, | |
| true | |
| ); | |
| scan(); | |
| applyMainOffset(); | |
| const observer = new MutationObserver((mutations) => { | |
| handleRouteChange(); | |
| applyMainOffset(); | |
| for (const mutation of mutations) { | |
| for (const node of mutation.addedNodes) { | |
| if (!(node instanceof HTMLElement)) continue; | |
| if (node.matches?.(MESSAGE_SELECTOR)) { | |
| addMessage(node); | |
| } | |
| scan(node); | |
| } | |
| } | |
| }); | |
| observer.observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| }); | |
| const originalPushState = history.pushState; | |
| const originalReplaceState = history.replaceState; | |
| history.pushState = function (...args) { | |
| originalPushState.apply(this, args); | |
| window.setTimeout(scan, 300); | |
| }; | |
| history.replaceState = function (...args) { | |
| originalReplaceState.apply(this, args); | |
| window.setTimeout(scan, 300); | |
| }; | |
| window.addEventListener("popstate", () => { | |
| window.setTimeout(scan, 300); | |
| }); | |
| window.addEventListener("scroll", requestActiveListItemUpdate, { | |
| passive: true, | |
| }); | |
| document.addEventListener("scroll", requestActiveListItemUpdate, { | |
| capture: true, | |
| passive: true, | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment