// ==UserScript==
// @name AI Chat Scroller - Scroll to Previous User Message
// @version 1.3
// @namespace http://tampermonkey.net/ // Or your own unique namespace (e.g., your website, GitHub username)
// @description Scrolls to the previous message sent by the user in Gemini, AI Studio, and ChatGPT.
// @author WideKnotLabs
// @match https://gemini.google.com/*
// @match https://aistudio.google.com/*
// @match https://chatgpt.com/*
// @grant GM_addStyle
// @license MIT // Or another open-source license you prefer (e.g., GPL-3.0-or-later)
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VyclentQ29sb3IiPjxwYXRoIGQ9Ik0xMiAyQzYuNDggMiAyIDYuNDggMiAxMnM0LjQ4IDEwIDEwIDEwIDEwLTQuNDggMTAtMTBTMTcuNTIgMiAxMiAyem0wIDE4Yy00LjQxIDAtOC0zLjU5LTgtOHMzLjU5LTggOC04IDggMy41OSA4IDgtMy41OSA4LTggOHptLTEtMTFoMnYyaC0yem0wIDRoMnY2aC0yeiIvPjwvc3ZnPg== // Example icon (info icon) - optional
// ==/UserScript==
(function() {
'use strict';
// --- Configuration based on HTML analysis for ALL platforms ---
// Selector for ALL message blocks (user and AI) on all platforms.
// These should be the main containers for each distinct message entry.
const allMessageBlocksSelector = [
// Gemini selectors
'span.user-query-bubble-with-background', // Gemini User
'div.response-content', // Gemini AI
// AI Studio selectors
'div.user-prompt-container[data-turn-role="User"]', // AI Studio User
'div.chat-turn-container.model', // AI Studio AI (model turn)
// 'div[data-turn-role="Model"]', // Alternative for AI Studio AI if data-turn-role="Model" exists
// ChatGPT selectors
'div[data-message-author-role="user"]', // ChatGPT User
'div[data-message-author-role="assistant"]' // ChatGPT AI
].join(', '); // Joins into a single CSS selector string
// Function to identify if a given message block is a USER message.
function isUserMessage(messageElement) {
// Check for Gemini user message pattern
if (messageElement.matches('span.user-query-bubble-with-background')) {
return true;
}
// Check for AI Studio user message pattern
if (messageElement.matches('div.user-prompt-container[data-turn-role="User"]')) {
return true;
}
// Check for ChatGPT user message pattern
if (messageElement.matches('div[data-message-author-role="user"]')) {
return true;
}
return false;
}
// --- End Configuration ---
function highlightMessage(messageElement) {
if (!messageElement) return;
const originalOutline = messageElement.style.outline;
const originalOffset = messageElement.style.outlineOffset;
// Apply important to override existing styles if necessary
messageElement.style.setProperty('outline', '3px dashed #007bff', 'important');
messageElement.style.setProperty('outline-offset', '2px', 'important');
setTimeout(() => {
messageElement.style.outline = originalOutline;
messageElement.style.outlineOffset = originalOffset;
}, 2500);
}
function scrollToPreviousUserMessage() {
const allMessages = Array.from(document.querySelectorAll(allMessageBlocksSelector));
if (!allMessages.length) {
console.warn("No message blocks found with combined selector:", allMessageBlocksSelector);
alert("No message blocks found. The script's selectors might need an update if the site structure changed.");
return;
}
const userMessages = allMessages.filter(isUserMessage);
if (userMessages.length === 0) {
alert("No user messages found on the page based on the current HTML selectors for any platform.");
return;
}
let targetMessage = null;
// Iterate backwards through user messages to find the navigation target
for (let i = userMessages.length - 1; i >= 0; i--) {
const msg = userMessages[i];
const rect = msg.getBoundingClientRect();
// Is this message (msg) the one currently "active" at the top of the viewport?
// "Active" means its top is within a small threshold from the viewport top.
if (rect.top >= -10 && rect.top < 50 && rect.bottom > 0) {
if (i > 0) { // If this "active" message is not the first user message
targetMessage = userMessages[i - 1]; // Target the one before it
} else { // This is the first user message and it's "active"
alert("You are at your first message.");
highlightMessage(msg); // Re-highlight current if it's the first
msg.scrollIntoView({ behavior: 'smooth', block: 'start' });
return; // Stop further processing
}
break; // Found our scenario, exit loop
}
// If not "active", is this message the first one whose bottom is above a certain point (e.g., 10px from top of viewport)?
// This means it's the highest message that is (mostly) off-screen above or just at the top edge.
if (rect.bottom < 10) {
targetMessage = msg;
break; // This is the highest user message that is primarily off-screen above
}
}
// Fallback logic if no message was found by the loop above
// (e.g., all user messages are fully in view, or view is below all user messages)
if (!targetMessage && userMessages.length > 0) {
let potentialTarget = null;
// Try to find the last user message whose top is above the *center* of the viewport,
// giving priority to messages further up if multiple qualify.
for (let i = userMessages.length - 1; i >=0; i--) {
const msgRect = userMessages[i].getBoundingClientRect();
// Is the message top above the vertical midpoint of the viewport?
if (msgRect.top < window.innerHeight / 2) {
potentialTarget = userMessages[i]; // This message is a candidate
// If this candidate is also "active" at the top, we actually want the one *before* it.
if (msgRect.top >= -10 && msgRect.top < 50 && msgRect.bottom > 0 && i > 0) {
potentialTarget = userMessages[i-1];
}
break; // Found the highest suitable candidate by this logic
}
}
if (potentialTarget) {
targetMessage = potentialTarget;
} else {
// If still no target (e.g., all user messages are below the viewport center, or only one message exists)
// and the first user message isn't already "active", target the first user message.
const firstMsg = userMessages[0];
const firstMsgRect = firstMsg.getBoundingClientRect();
if (!(firstMsgRect.top >= -10 && firstMsgRect.top < 50 && firstMsgRect.bottom > 0) ) {
targetMessage = firstMsg;
} else if (userMessages.length === 1) { // Only one message, and it's active
targetMessage = firstMsg; // Will trigger the "already at first message" alert or re-scroll
}
}
}
if (targetMessage) {
const targetRect = targetMessage.getBoundingClientRect();
const firstUserMessageRect = userMessages[0].getBoundingClientRect();
// Check if the target is the first user message and if it's already effectively at the top
if (targetMessage === userMessages[0] && targetRect.top >= -10 && targetRect.top < 50 && targetRect.bottom > 0) {
alert("Already at the first user message.");
}
targetMessage.scrollIntoView({ behavior: 'smooth', block: 'start' });
highlightMessage(targetMessage);
} else if (userMessages.length > 0) {
// This is a safety net. If targetMessage is still null, but user messages exist,
// it implies we are likely already at the first message.
const firstMsg = userMessages[0];
const firstMsgRect = firstMsg.getBoundingClientRect();
if(firstMsgRect.top >= -10 && firstMsgRect.top < 50 && firstMsgRect.bottom > 0) {
alert("Already at the first user message.");
firstMsg.scrollIntoView({ behavior: 'smooth', block: 'start' }); // ensure it's perfectly at start
highlightMessage(firstMsg);
} else {
// If something unexpected happened and no target was set, scroll to the first message.
firstMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
highlightMessage(firstMsg);
}
}
// If userMessages.length === 0, it's caught at the beginning.
}
// --- Button Styling and Creation ---
GM_addStyle(`
#universalChatScrollBtn {
position: fixed !important;
bottom: 20px !important;
right: 20px !important;
z-index: 2147483647 !important; /* Max z-index */
padding: 10px 15px !important;
background-color: #1a73e8 !important; /* Google Blue */
color: white !important;
border: none !important;
border-radius: 8px !important;
font-size: 14px !important;
font-weight: bold;
font-family: 'Google Sans', Roboto, Arial, sans-serif !important;
cursor: pointer !important;
box-shadow: 0 4px 10px rgba(0,0,0,0.25) !important;
transition: background-color 0.2s ease-in-out, transform 0.1s ease-in-out !important;
}
#universalChatScrollBtn:hover {
background-color: #1665c7 !important; /* Darker Google Blue */
}
#universalChatScrollBtn:active {
transform: scale(0.96) !important;
background-color: #1357a9 !important;
}
`);
const scrollButton = document.createElement('button');
scrollButton.id = 'universalChatScrollBtn';
scrollButton.textContent = '⬆️ Prev. User Msg';
scrollButton.onclick = scrollToPreviousUserMessage;
// Append button when body is ready
if (document.body) {
document.body.appendChild(scrollButton);
} else {
window.addEventListener('DOMContentLoaded', () => {
if(document.body) document.body.appendChild(scrollButton);
});
}
// Keyboard shortcut: Ctrl+Shift+ArrowUp
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.shiftKey && e.key === 'ArrowUp') {
e.preventDefault();
scrollToPreviousUserMessage();
}
});
console.log("Universal Chat Scroller Active (Gemini, AI Studio, ChatGPT). Button added. Shortcut: Ctrl+Shift+ArrowUp");
})();