Skip to content

Instantly share code, notes, and snippets.

@basyura
Last active May 6, 2026 09:47
Show Gist options
  • Select an option

  • Save basyura/73dec4df430d290121285bd14cb64bb3 to your computer and use it in GitHub Desktop.

Select an option

Save basyura/73dec4df430d290121285bd14cb64bb3 to your computer and use it in GitHub Desktop.
ChatGPT User Message Navigator
// ==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