// ==UserScript==
// @name ChatGPT Question Navigation Sidebar v2
// @namespace npm/chatgpt-question-navigator
// @version 2.1
// @author Pitroytech
// @description Lấy cảm hứng từ "ChatGPT Question Navigation sidebar" của okokdi, nhưng viết lại hoàn toàn bằng mã mới — siêu nhanh, mượt nhẹ, hiệu ứng đẹp vượt trội (Inspired by "ChatGPT Question Navigation sidebar" by okokdi, entirely rewritten with new code — ultra-fast, lightweight, with significantly improved, elegant effects.
// @match https://chatgpt.com/**
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ====== CẤU HÌNH (CONFIG) ======
// Tìm icon sidebar để thay <span class="toggle-btn">✮⋆˙</span>
// Find sidebar icon to replace <span class="toggle-btn">✮⋆˙</span>
// Độ trong suốt khi sidebar ĐANG THU GỌN và KHÔNG hover
// Opacity when sidebar is COLLAPSED and NOT hovering
// 0 = hoàn toàn trong suốt (fully transparent), 1 = đục hoàn toàn (fully opaque)
const COLLAPSED_OPACITY = 0.35;
// Thời gian ép sidebar "dính cuối" sau khi khởi tạo (ms)
// Time to force sidebar "stick to bottom" after initialization (ms)
// Gợi ý: 300–600ms (càng thấp thì càng ít can thiệp)
// Suggestion: 300–600ms (lower = less intrusive)
const FORCE_STICK_BOTTOM_MS = 500;
// =====================
// Các hằng số hệ thống (System constants)
const DOM_MARK = 'data-chatgpt-question-directory';
const CHAT_LIST_EL_CLASS = 'flex flex-col text-sm';
const isSharePage = location.pathname.startsWith('/share/');
const scrollMarginTop = 56;
const RIGHT_OFFSET_PX = 12; // Sidebar luôn neo cách mép phải 12px (Sidebar always anchored 12px from right edge)
const TOP_MIN_MARGIN = 20; // Chặn kéo vượt viền trên (Prevent dragging beyond top boundary)
const BOTTOM_MIN_MARGIN = 20; // Chặn kéo vượt viền dưới (Prevent dragging beyond bottom boundary)
const DRAG_THRESHOLD_PX = 3; // Ngưỡng di chuyển để coi là kéo (Movement threshold to consider as dragging)
// ====== CÁC HÀM TRỢ GIÚP (HELPER FUNCTIONS) ======
// Tìm container chứa các tin nhắn chat (Find chat messages container)
function queryChatContainer() {
const main = document.querySelector('main');
return main?.querySelector('.' + CHAT_LIST_EL_CLASS.split(' ').join('.'));
}
// Lấy danh sách các phần tử câu hỏi (chỉ lấy index chẵn)
// Get list of question elements (only even indexes)
function queryQuestionEls() {
const container = queryChatContainer();
if (!container) return [];
return Array.from(container.children)
.filter(child => child.hasAttribute('data-testid'))
.filter((_, index) => index % 2 === 0); // Chỉ lấy câu hỏi (index chẵn) / Only questions (even index)
}
// Trích xuất nội dung text của các câu hỏi
// Extract text content of questions
function getQuestions() {
const questionEls = queryQuestionEls();
return questionEls.map(el => {
const textEl = el.querySelector('[data-message-author-role]');
return textEl?.innerText || '';
}).filter(Boolean); // Loại bỏ các giá trị rỗng (Remove empty values)
}
// Lấy ID cuộc hội thoại từ URL (Get conversation ID from URL)
function getConversationIdByUrl() {
const match = location.pathname.match(/\/c\/(.*)/);
return match?.[1] || null;
}
// ====== TẠO GIAO DIỆN SIDEBAR (CREATE SIDEBAR UI) ======
function createSidebar() {
const sidebar = document.createElement('div');
sidebar.setAttribute(DOM_MARK, '');
sidebar.innerHTML = `
<style>
/* Styles cho sidebar chính (Main sidebar styles) */
[${DOM_MARK}] {
position: fixed;
top: 10vh;
right: ${RIGHT_OFFSET_PX}px;
padding: 12px;
border-radius: 8px;
background: rgba(17, 24, 39, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.65s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1000;
max-width: 300px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
opacity: 1; /* Mặc định hiển thị rõ (Default fully visible) */
}
/* Trạng thái đang kéo (Dragging state) */
[${DOM_MARK}].dragging {
transition: none !important;
cursor: grabbing !important;
user-select: none !important;
opacity: 0.9 !important;
}
[${DOM_MARK}].dragging * {
pointer-events: none !important;
cursor: grabbing !important;
}
/* Trạng thái thu gọn (Collapsed state) */
[${DOM_MARK}].collapsed {
width: 55px;
overflow: hidden;
}
/* Thu gọn + KHÔNG hover => trong suốt toàn bộ */
/* Collapsed + NOT hovering => fully transparent */
[${DOM_MARK}].collapsed:not(.hovering) {
opacity: ${COLLAPSED_OPACITY};
}
[${DOM_MARK}].collapsed .questions-list {
opacity: 0.8;
visibility: visible;
transition: opacity 0.45s ease, visibility 0.45s ease;
}
/* Hiển thị đầy đủ khi mở rộng hoặc hover */
/* Full display when expanded or hovering */
[${DOM_MARK}]:not(.collapsed) .questions-list,
[${DOM_MARK}].hovering:not(.dragging) .questions-list {
opacity: 1;
visibility: visible;
transition: opacity 0.45s ease, visibility 0.45s ease;
}
/* Trạng thái hover (Hover state) */
[${DOM_MARK}].hovering:not(.dragging) {
width: auto;
max-width: 300px;
overflow: visible;
transition: all 0.65s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 1; /* Hover vào để xem nội dung rõ ràng (Hover for clear content view) */
}
/* Header của sidebar (Sidebar header) */
[${DOM_MARK}] .header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
color: #e5e7eb;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
gap: 8px;
cursor: default; /* Không còn kéo bằng header (No longer drag by header) */
user-select: none;
}
[${DOM_MARK}] .header:hover {
background: rgba(255, 255, 255, 0.05);
margin: -4px -4px 4px -4px;
padding: 4px;
border-radius: 4px;
}
/* Tiêu đề sidebar (Sidebar title) */
[${DOM_MARK}] .title {
opacity: 1;
transition: opacity 0.45s ease;
pointer-events: none;
}
[${DOM_MARK}].collapsed:not(.hovering) .title {
opacity: 0;
width: 0;
overflow: hidden;
}
/* Nút toggle/kéo (Toggle/drag button) */
[${DOM_MARK}] .toggle-btn {
cursor: grab; /* Chỉ kéo bằng nút ✮⋆˙ (Only drag using ✮⋆˙ button) */
opacity: 0.8;
transition: all 0.45s ease;
font-size: 20px;
padding: 2px;
filter: grayscale(0);
flex-shrink: 0;
margin-left: auto;
}
[${DOM_MARK}] .toggle-btn:hover {
opacity: 1;
transform: scale(1.1);
}
[${DOM_MARK}].collapsed .toggle-btn {
opacity: 1;
filter: grayscale(0);
}
/* Danh sách câu hỏi (Questions list) */
[${DOM_MARK}] .questions-list {
max-height: 60vh;
overflow-y: auto;
margin: 0;
padding: 0;
list-style: none;
}
/* Thanh cuộn tùy chỉnh (Custom scrollbar) */
[${DOM_MARK}] .questions-list::-webkit-scrollbar {
width: 4px;
}
[${DOM_MARK}] .questions-list::-webkit-scrollbar-track {
background: transparent;
}
[${DOM_MARK}] .questions-list::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
}
[${DOM_MARK}] .questions-list::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Mục câu hỏi (Question item) */
[${DOM_MARK}] .question-item {
padding: 6px 8px;
margin: 2px 0;
color: #9ca3af;
font-size: 13px;
cursor: pointer;
border-radius: 4px;
transition: all 0.32s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: flex-start;
gap: 8px;
}
[${DOM_MARK}].collapsed:not(.hovering) .question-item {
padding: 6px 4px;
}
[${DOM_MARK}] .question-item:hover {
background: rgba(255, 255, 255, 0.05);
color: #e5e7eb;
}
/* Câu hỏi đang active (Active question) */
[${DOM_MARK}] .question-item.active {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
font-weight: 500;
}
/* Số thứ tự câu hỏi (Question number) */
[${DOM_MARK}] .question-number {
flex-shrink: 0;
font-weight: 600;
color: #6b7280;
min-width: 20px;
text-align: left;
}
[${DOM_MARK}] .question-item.active .question-number {
color: #10b981;
}
/* Text câu hỏi (Question text) */
[${DOM_MARK}] .question-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
transition: opacity 0.45s ease;
}
[${DOM_MARK}].collapsed:not(.hovering) .question-text {
opacity: 0;
width: 0;
}
</style>
<div class="header">
<span class="title">📋 Questions</span>
<span class="toggle-btn">✮⋆˙</span>
</div>
<ol class="questions-list"></ol>
`;
return sidebar;
}
// ====== CHỨC NĂNG KÉO THẢ (DRAG FUNCTIONALITY) ======
// Chỉ kéo khi giữ nút ✮⋆˙ và chỉ thay đổi tọa độ Y (neo bên phải)
// Only drag when holding ✮⋆˙ button and only change Y coordinate (anchored to right)
function setupDragBehavior(sidebar) {
const toggleBtn = sidebar.querySelector('.toggle-btn');
let isPointerDown = false;
let isDragging = false;
let startY = 0;
let startTop = 0;
let ignoreNextClick = false;
// Luôn neo bên phải (Always anchor to the right)
sidebar.style.right = RIGHT_OFFSET_PX + 'px';
sidebar.style.left = '';
// Load vị trí top đã lưu (nếu có) / Load saved top position (if exists)
const savedTop = localStorage.getItem('chatgpt-sidebar-top');
if (savedTop) {
sidebar.style.top = savedTop;
}
// Xóa giá trị right cũ không dùng nữa (Remove unused old right value)
try { localStorage.removeItem('chatgpt-sidebar-right'); } catch {}
// Bắt đầu kéo (Start dragging)
function onPointerDown(e) {
// Chỉ cho phép kéo khi bấm lên nút ✮⋆˙
// Only allow dragging when clicking on ✮⋆˙ button
if (e.target !== toggleBtn) return;
isPointerDown = true;
isDragging = false;
startY = (e.touches ? e.touches[0].clientY : e.clientY);
startTop = sidebar.getBoundingClientRect().top;
// Lắng nghe di chuyển/thả trên document
// Listen for move/release on document
document.addEventListener('mousemove', onPointerMove);
document.addEventListener('mouseup', onPointerUp);
document.addEventListener('touchmove', onPointerMove, { passive: false });
document.addEventListener('touchend', onPointerUp);
}
// Xử lý di chuyển chuột/touch (Handle mouse/touch move)
function onPointerMove(e) {
if (!isPointerDown) return;
const clientY = (e.touches ? e.touches[0].clientY : e.clientY);
const deltaY = clientY - startY;
// Kiểm tra ngưỡng để bắt đầu kéo (Check threshold to start dragging)
if (!isDragging && Math.abs(deltaY) > DRAG_THRESHOLD_PX) {
isDragging = true;
sidebar.classList.add('dragging');
sidebar.classList.remove('hovering');
}
if (!isDragging) return;
// Tính top mới, chỉ theo trục Y (Calculate new top, only Y axis)
const rect = sidebar.getBoundingClientRect();
const minTop = TOP_MIN_MARGIN;
const maxTop = window.innerHeight - rect.height - BOTTOM_MIN_MARGIN;
let newTop = startTop + deltaY;
newTop = Math.max(minTop, Math.min(newTop, maxTop)); // Giới hạn trong vùng cho phép (Limit within allowed area)
// Áp dụng vị trí (Apply position)
sidebar.style.top = newTop + 'px';
sidebar.style.right = RIGHT_OFFSET_PX + 'px';
sidebar.style.left = '';
e.preventDefault();
e.stopPropagation();
}
// Kết thúc kéo (End dragging)
function onPointerUp() {
if (!isPointerDown) return;
if (isDragging) {
// Lưu tọa độ Y (top) và vẫn neo phải
// Save Y coordinate (top) and keep anchored to right
const rect = sidebar.getBoundingClientRect();
localStorage.setItem('chatgpt-sidebar-top', rect.top + 'px');
sidebar.style.right = RIGHT_OFFSET_PX + 'px';
// Tránh click toggle ngay sau khi kéo
// Avoid toggle click right after dragging
ignoreNextClick = true;
setTimeout(() => { ignoreNextClick = false; }, 0);
}
isPointerDown = false;
isDragging = false;
sidebar.classList.remove('dragging');
// Gỡ bỏ các event listeners (Remove event listeners)
document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('mouseup', onPointerUp);
document.removeEventListener('touchmove', onPointerMove);
document.removeEventListener('touchend', onPointerUp);
}
// Click nút ✮⋆˙: toggle thu gọn/mở rộng (trừ khi vừa kéo)
// Click ✮⋆˙ button: toggle collapse/expand (except after dragging)
function onToggleClick(e) {
if (ignoreNextClick) {
e.preventDefault();
e.stopPropagation();
return;
}
e.stopPropagation();
toggleSidebar(sidebar);
}
// Gắn events (Attach events)
toggleBtn.addEventListener('mousedown', onPointerDown);
toggleBtn.addEventListener('touchstart', onPointerDown, { passive: true });
toggleBtn.addEventListener('click', onToggleClick);
// Hàm dọn dẹp (Cleanup function)
sidebar._cleanupDrag = () => {
toggleBtn.removeEventListener('mousedown', onPointerDown);
toggleBtn.removeEventListener('touchstart', onPointerDown);
toggleBtn.removeEventListener('click', onToggleClick);
document.removeEventListener('mousemove', onPointerMove);
document.removeEventListener('mouseup', onPointerUp);
document.removeEventListener('touchmove', onPointerMove);
document.removeEventListener('touchend', onPointerUp);
};
}
// Cuộn danh sách sidebar xuống cuối (Scroll sidebar list to bottom)
function scrollSidebarToBottom(sidebar) {
const list = sidebar.querySelector('.questions-list');
if (list) {
list.scrollTop = list.scrollHeight;
// Đảm bảo cuộn sau khi render (Ensure scroll after render)
setTimeout(() => {
list.scrollTop = list.scrollHeight;
}, 100);
}
}
// ====== QUẢN LÝ CÂU HỎI ACTIVE (ACTIVE QUESTION MANAGEMENT) ======
// Set câu hỏi cụ thể làm active
// Set specific question as active
// scrollChat: true => cuộn KHUNG CHAT tới câu hỏi đó (scroll CHAT FRAME to that question)
// scrollChat: false => KHÔNG đụng tới khung chat, chỉ highlight (DON'T touch chat frame, only highlight)
function setActiveQuestion(sidebar, index, questionEls, opts = {}) {
const { scrollChat = true } = opts;
// Cập nhật class active cho các mục trong sidebar
// Update active class for sidebar items
sidebar.querySelectorAll('.question-item').forEach((item, i) => {
item.classList.toggle('active', i === index);
});
// Cuộn khung chat nếu được yêu cầu (Scroll chat frame if requested)
if (scrollChat && questionEls[index]) {
setTimeout(() => {
questionEls[index].scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}
// Cập nhật câu hỏi active dựa trên vị trí scroll (không cuộn chat)
// Update active question based on scroll position (without scrolling chat)
function updateActiveQuestion(sidebar, questionEls) {
const topThreshold = isSharePage ? scrollMarginTop : 0;
let activeIndex = -1;
// Tìm câu hỏi đầu tiên xuất hiện trong viewport
// Find first question appearing in viewport
for (let i = 0; i < questionEls.length; i++) {
const rect = questionEls[i].getBoundingClientRect();
if (rect.top >= topThreshold) {
activeIndex = i;
break;
}
}
// Cập nhật class active (Update active class)
sidebar.querySelectorAll('.question-item').forEach((item, index) => {
item.classList.toggle('active', index === activeIndex);
});
}
// ====== CẬP NHẬT DANH SÁCH CÂU HỎI (UPDATE QUESTIONS LIST) ======
// Cập nhật danh sách câu hỏi (đảm bảo dính cuối nếu cần, và KHÔNG cuộn khung chat khi khởi tạo)
// Update questions list (ensure stick to bottom if needed, and DON'T scroll chat frame on init)
function updateQuestionsList(sidebar, scrollToLast = false) {
const list = sidebar.querySelector('.questions-list');
if (!list) return;
const questions = getQuestions();
const questionEls = queryQuestionEls();
// Lưu vị trí cuộn trước khi rebuild (Save scroll position before rebuild)
const prevScrollTop = list.scrollTop;
const prevAtBottom = (list.scrollTop + list.clientHeight >= list.scrollHeight - 5);
// Rebuild danh sách (Rebuild list)
list.innerHTML = questions.map((q, index) => `
<li class="question-item" data-index="${index}">
<span class="question-number">${index + 1}.</span>
<span class="question-text">${q}</span>
</li>
`).join('');
// Kiểm tra có nên dính cuối không (Check if should stick to bottom)
const forceWindow = Date.now() < (sidebar._forceBottomUntil || 0);
const shouldStick = scrollToLast || forceWindow || sidebar._stickToBottom || prevAtBottom;
if (questions.length > 0 && shouldStick) {
// Quan trọng: KHÔNG cuộn khung chat ở nhánh này
// Important: DON'T scroll chat frame in this branch
setActiveQuestion(sidebar, questions.length - 1, questionEls, { scrollChat: false });
requestAnimationFrame(() => scrollSidebarToBottom(sidebar));
} else {
updateActiveQuestion(sidebar, questionEls);
// Khôi phục vị trí cuộn nếu không dính cuối
// Restore scroll position if not sticking to bottom
requestAnimationFrame(() => { list.scrollTop = prevScrollTop; });
}
}
// Xử lý click vào câu hỏi (click mới cuộn khung chat)
// Handle click on question (only click scrolls chat frame)
function handleQuestionClick(e, sidebar) {
const item = e.target.closest('.question-item');
if (!item) return;
const index = parseInt(item.dataset.index);
const questionEls = queryQuestionEls();
// Click => cuộn khung chat tới câu hỏi đó + set active
// Click => scroll chat frame to that question + set active
setActiveQuestion(sidebar, index, questionEls, { scrollChat: true });
}
// Toggle thu gọn/mở rộng sidebar (Toggle collapse/expand sidebar)
function toggleSidebar(sidebar) {
const isCollapsed = sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed');
sidebar.classList.remove('hovering');
sidebar._isManuallyToggled = true;
sidebar._isOpen = isCollapsed;
}
// ====== CHỨC NĂNG HOVER (HOVER FUNCTIONALITY) ======
function setupHoverBehavior(sidebar) {
let hoverTimeout;
// Khi di chuột vào (On mouse enter)
sidebar.addEventListener('mouseenter', () => {
if (!sidebar.classList.contains('dragging')) {
clearTimeout(hoverTimeout);
sidebar.classList.add('hovering');
}
});
// Khi di chuột ra (On mouse leave)
sidebar.addEventListener('mouseleave', () => {
if (!sidebar.classList.contains('dragging')) {
clearTimeout(hoverTimeout);
// Delay trước khi tắt hover (Delay before removing hover)
hoverTimeout = setTimeout(() => {
sidebar.classList.remove('hovering');
}, 234);
}
});
}
// ====== KHỞI TẠO CHÍNH (MAIN INITIALIZATION) ======
function init(isFirstLoad = false) {
// Xóa sidebar cũ nếu có (Remove old sidebar if exists)
const existing = document.querySelector(`[${DOM_MARK}]`);
if (existing) {
existing._cleanup?.();
existing._cleanupDrag?.();
existing.remove();
}
// Kiểm tra có câu hỏi không (Check if there are questions)
const questionEls = queryQuestionEls();
if (questionEls.length === 0) return;
// Tạo và thêm sidebar mới (Create and add new sidebar)
const sidebar = createSidebar();
document.body.appendChild(sidebar);
// Khởi tạo trạng thái (Initialize state)
sidebar._isManuallyToggled = false;
sidebar._isOpen = false;
sidebar.classList.add('collapsed');
const list = sidebar.querySelector('.questions-list');
// Trạng thái "dính cuối" (Stick to bottom state)
sidebar._stickToBottom = true; // Lần đầu luôn coi như dính cuối (First time always stick to bottom)
sidebar._forceBottomUntil = Date.now() + FORCE_STICK_BOTTOM_MS;
// Theo dõi scroll của sidebar để biết khi nào người dùng kéo lên
// Track sidebar scroll to know when user scrolls up
list.addEventListener('scroll', () => {
const atBottom = (list.scrollTop + list.clientHeight >= list.scrollHeight - 5);
sidebar._stickToBottom = atBottom;
});
// Click câu hỏi (Question click)
list.addEventListener('click', (e) => handleQuestionClick(e, sidebar));
// Setup các behavior (Setup behaviors)
setupHoverBehavior(sidebar);
setupDragBehavior(sidebar);
// Lần đầu: build list và chỉ cuộn sidebar về cuối (không đụng tới chat)
// First time: build list and only scroll sidebar to bottom (don't touch chat)
updateQuestionsList(sidebar, isFirstLoad);
// Theo dõi scroll của khung chat (Track chat frame scroll)
const scrollContainer = queryChatContainer()?.parentElement;
if (scrollContainer) {
let scrollTimeout;
scrollContainer.addEventListener('scroll', () => {
clearTimeout(scrollTimeout);
// Debounce để tránh cập nhật quá nhiều (Debounce to avoid too many updates)
scrollTimeout = setTimeout(() => {
updateActiveQuestion(sidebar, queryQuestionEls());
}, 50);
});
}
// Quan sát thay đổi DOM để cập nhật danh sách
// Observe DOM changes to update list
const observer = new MutationObserver(() => {
updateQuestionsList(sidebar);
});
const chatContainer = queryChatContainer();
if (chatContainer) {
observer.observe(chatContainer, { childList: true });
}
// Hàm dọn dẹp (Cleanup function)
sidebar._cleanup = () => {
observer.disconnect();
};
}
// ====== GIÁM SÁT THAY ĐỔI (MONITOR FOR CHANGES) ======
let loaded = false;
let conversationId = null;
let isInitialLoad = true;
// Kiểm tra định kỳ để phát hiện thay đổi conversation
// Periodically check to detect conversation changes
setInterval(() => {
const latestConversationId = getConversationIdByUrl();
const hasQuestions = queryQuestionEls().length > 0;
// Nếu conversation thay đổi hoặc không có câu hỏi
// If conversation changed or no questions
if (conversationId !== latestConversationId || !hasQuestions) {
conversationId = latestConversationId;
// Xóa sidebar cũ (Remove old sidebar)
const existing = document.querySelector(`[${DOM_MARK}]`);
if (existing) {
existing._cleanup?.();
existing._cleanupDrag?.();
existing.remove();
}
loaded = false;
// Reset trạng thái initial load khi đổi conversation
// Reset initial load state when conversation changes
if (conversationId !== latestConversationId) {
isInitialLoad = true;
}
}
// Khởi tạo sidebar mới nếu có câu hỏi
// Initialize new sidebar if there are questions
if (!loaded && hasQuestions) {
init(isInitialLoad);
loaded = true;
isInitialLoad = false;
}
}, 600); // Kiểm tra mỗi 600ms (Check every 600ms)
})();