// ==UserScript==
// @name OpenRouter Chat Enhancements
// @namespace http://tampermonkey.net/
// @license MIT
// @version 1.4.0
// @description Navigation hotkeys, message highlight, floating speaker, scroll protections, perfect collapse/expand handling, and enhanced edit scroll lock.
// @author cdr-x
// @match https://openrouter.ai/chat*
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @run-at document-end
// @downloadURL https://update.greasyfork.icu/scripts/532789/OpenRouter%20Chat%20Enhancements.user.js
// @updateURL https://update.greasyfork.icu/scripts/532789/OpenRouter%20Chat%20Enhancements.meta.js
// ==/UserScript==
(function () {
'use strict';
// Inject highlight CSS for selected message
const highlightStyle = document.createElement('style');
highlightStyle.textContent = `
.openrouter-nav-highlight {
outline: 2px solid #3b82f6 !important;
background: rgba(59,130,246,0.08) !important;
border-radius: 0.5rem !important;
transition: outline 0.15s, background 0.15s;
}
`;
document.head.appendChild(highlightStyle);
/*********************** SETTINGS MODULE **********************/
// MODULE_VERSION: SettingsModule@1.0
// Handles persistence and configuration
class SettingsModule {
constructor() {
this.modifierKey = "Alt";
this.panelEnabled = true;
this.EDIT_LOCK_DURATION_MS = 3000;
this.COLLAPSE_SCROLL_LOCK_MS = 500;
this.ANTI_HYSTERESIS_MS = 50;
}
init() {
this.modifierKey = GM_getValue('or_modifierKey', "Alt");
this.panelEnabled = GM_getValue('or_panelEnabled', true);
}
save() {
GM_setValue('or_modifierKey', this.modifierKey);
GM_setValue('or_panelEnabled', this.panelEnabled);
}
}
// Export to global namespace
window.SettingsModule = SettingsModule;
/*********************** UI MODULE **********************/
// MODULE_VERSION: UIModule@1.2
// Manages visual components (navigation panel)
class UIModule {
constructor() {
// Remove speakerElem, speakerImg, and speakerName properties since they're no longer used
this.panelElem = null;
}
init() {
if (this.settings && this.settings.panelEnabled) {
this.createPanel();
}
}
// Remove the commented out createSpeakerFloat method entirely since it's no longer needed
createPanel() {
this.clearPanel();
if (!this.settings || !this.settings.panelEnabled) return;
this.panelElem = document.createElement("div");
this.panelElem.id = "openrouter-nav-panel";
this.panelElem.innerHTML = `
`;
document.body.appendChild(this.panelElem);
}
clearPanel() {
if (this.panelElem) { this.panelElem.remove(); this.panelElem = null; }
}
// Modify updateSpeaker to avoid duplicate speaker visualization
updateSpeaker(msgDiv) {
// This method is kept for backward compatibility but doesn't create UI elements anymore
// The actual speaker visualization is now handled by NavigationModule.showSpeakerForMessage
return; // Just return without doing anything
}
// Keep these methods as they're used by NavigationModule.showSpeakerForMessage
getSpeakerName(msgDiv) {
const hdr = this.msgHeader(msgDiv);
if (!hdr) return "";
const a = hdr.querySelector('span a');
if (a) return a.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
const span = hdr.querySelector('span');
if (span) return span.textContent.replace(/\|.*/,'').replace('(edited)','').trim();
return "";
}
getSpeakerAvatar(msgDiv) {
const hdr = this.msgHeader(msgDiv);
if (!hdr) return "";
const img = hdr.querySelector("picture img, img");
if (img) return img.src;
return "";
}
msgHeader(msgDiv) {
return msgDiv.querySelector('.group.flex.flex-col.gap-2.items-start > .flex.gap-2, .group.flex.flex-col.gap-2.items-end > .flex.gap-2') ||
msgDiv.querySelector('.flex.gap-2.items-center, .flex.gap-2.flex-row-reverse');
}
}
// Export to global namespace
window.UIModule = UIModule;
/*********************** NAVIGATION MODULE **********************/
// MODULE_VERSION: NavigationModule@1.2
// Core message tracking and scrolling logic
class NavigationModule {
constructor() {
this.scrollContainer = null;
this.allMessages = [];
this.highlighted = null;
this.blockHighlightUntil = 0;
this.lastInteractedMsg = null;
this.latestInputEdit = 0;
this.lastEditingMsg = null;
this.editPasteProhibit = false;
this.collapseRestoreMsg = null;
this.speakerTooltip = null; // Add this line
this.initSpeakerTooltip(); // Add this line
}
init(ui, settings) {
this.ui = ui;
this.settings = settings;
this.scrollContainer = this.findScrollContainer();
if (!this.scrollContainer) {
console.warn("OpenRouter Chat Enhancements: Main chat container not found. Initialization aborted. The page might still be loading.");
return;
}
// Removed this.setupObservers();
this.ui.settings = settings; // pass settings to UI for panel visibility
this.ui.init();
this.updateMsgList();
this.panelAndPageListeners();
this.setupScrollListener();
this.setupInputListeners();
this.setupVisibilityAndResizeListeners();
}
findScrollContainer() {
return document.querySelector('main div.overflow-y-scroll') ||
document.querySelector('main div[style*="overflow-y: auto;"]') ||
document.querySelector('main div[style*="overflow-y: scroll;"]') ||
document.querySelector('main');
}
findMessageContainers() {
if (!this.scrollContainer) return [];
// Relaxed: include all visible message containers, regardless of child structure
return Array.from(
this.scrollContainer.querySelectorAll('div.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0')
).filter(d => d.offsetParent !== null);
}
msgContentElem(msgDiv) {
return msgDiv.querySelector('.overflow-auto') || msgDiv.querySelector('div.flex.max-w-full.flex-col.relative.overflow-auto');
}
msgToggleExpandBtn(msgDiv) {
return msgDiv.querySelector(
'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button'
);
}
updateMsgList() {
let prevId = this.highlighted?.dataset?.ormsgid;
this.allMessages = this.findMessageContainers();
this.allMessages.forEach((m, i) => {
if (!m.dataset.ormsgid) m.dataset.ormsgid = "msg-" + Date.now() + "-" + Math.random();
});
if (prevId) {
this.highlighted = this.allMessages.find(m => m.dataset?.ormsgid === prevId);
}
if (!this.highlighted && this.allMessages.length > 0) {
this.highlighted = this.allMessages[this.allMessages.length - 1];
}
this.allMessages.forEach(m => m.classList.toggle('openrouter-nav-highlight', m === this.highlighted));
if (this.highlighted) {
this.ui.updateSpeaker(this.highlighted);
} else {
this.ui.updateSpeaker(null);
}
}
highlightMsg(msgDiv, opts = {}) {
if (msgDiv === null) {
if (this.highlighted) this.highlighted.classList.remove('openrouter-nav-highlight');
this.highlighted = null;
this.ui.updateSpeaker(null);
this.showSpeakerForMessage(null); // Add this line to hide when no message is selected
return;
}
if (!msgDiv || !document.body.contains(msgDiv)) return;
if (this.editPasteProhibit && this.lastEditingMsg && this.lastEditingMsg !== msgDiv) return;
if (Date.now() < this.blockHighlightUntil && !opts.force) return;
if (this.highlighted) this.highlighted.classList.remove('openrouter-nav-highlight');
this.highlighted = msgDiv;
this.highlighted.classList.add('openrouter-nav-highlight');
this.ui.updateSpeaker(this.highlighted);
this.lastInteractedMsg = this.highlighted;
this.showSpeakerForMessage(this.highlighted); // Add this line
if (opts.scrollIntoView) {
this.highlighted.scrollIntoView({ behavior: "smooth", block: opts.block || "center" });
if (opts.scrollTop) {
let ct = this.msgContentElem(this.highlighted);
if (ct) ct.scrollTop = 0;
}
if (opts.scrollBottom) {
let ct = this.msgContentElem(this.highlighted);
if (ct) ct.scrollTop = ct.scrollHeight;
}
}
}
navToMsg(dir = 1) {
if (!this.allMessages.length) return;
let idx = this.highlighted ? this.allMessages.indexOf(this.highlighted) : -1;
let nextIdx = idx + dir;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx > this.allMessages.length - 1) nextIdx = this.allMessages.length - 1;
this.blockHighlightUntil = Date.now() + 350;
if (this.allMessages[nextIdx]) this.highlightMsg(this.allMessages[nextIdx], { scrollIntoView: true, force: true });
}
scrollMsgTop() {
if (!this.highlighted) return;
let ct = this.msgContentElem(this.highlighted);
if (ct) ct.scrollTop = 0;
this.highlighted.scrollIntoView({ behavior: "smooth", block: "start" });
this.blockHighlightUntil = Date.now() + 300;
}
scrollMsgBottom() {
if (!this.highlighted) return;
let ct = this.msgContentElem(this.highlighted);
if (ct) ct.scrollTop = ct.scrollHeight;
this.highlighted.scrollIntoView({ behavior: "smooth", block: "end" });
this.blockHighlightUntil = Date.now() + 300;
}
toggleMsgExpand() {
if (!this.highlighted) return;
const btn = this.msgToggleExpandBtn(this.highlighted);
if (!btn) return;
this.handleToggleScroll(this.highlighted);
btn.click();
}
handleToggleScroll(msgDiv) {
this.collapseRestoreMsg = msgDiv;
const scrollContainer = this.findScrollContainer();
const scrollTopBefore = scrollContainer.scrollTop;
const msgTopBefore = msgDiv.offsetTop;
const visualTop = msgTopBefore - scrollTopBefore;
setTimeout(() => {
let msg = this.allMessages.find(m => m.dataset.ormsgid === this.collapseRestoreMsg.dataset.ormsgid);
if (msg) {
const msgTopAfter = msg.offsetTop;
scrollContainer.scrollTop = msgTopAfter - visualTop;
this.highlightMsg(msg, { force: true });
this.ensureScrollInBounds(msg);
}
this.collapseRestoreMsg = null;
this.blockHighlightUntil = Date.now() + this.settings.COLLAPSE_SCROLL_LOCK_MS;
}, 210);
}
refreshActiveMsg() {
if (!this.highlighted) return;
// Use the full SVG path selector from old script for refresh button
const refreshSelectors = [
'svg path[d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"]',
'button[aria-label="Refresh"]',
'button[title="Refresh"]',
'button svg[viewBox="0 0 24 24"] path[d*="M17.65 6.35A10 10 0 1 1 6.35 17.65"]',
'button svg[viewBox="0 0 24 24"] path[d*="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1-18 0z"]'
];
let refreshButton = null;
for (const selector of refreshSelectors) {
const el = this.highlighted.querySelector(selector);
if (el) {
refreshButton = el.closest('button') || el;
break;
}
}
if (refreshButton) {
refreshButton.click();
}
}
updateHighlightOnScroll() {
if (Date.now() < this.blockHighlightUntil) return;
if (this.editPasteProhibit && this.lastEditingMsg) {
this.ensureScrollInBounds(this.lastEditingMsg);
return;
}
let best = null, maxVH = 0;
const containerRect = this.scrollContainer.getBoundingClientRect();
this.allMessages.forEach(m => {
const rect = m.getBoundingClientRect();
let top = Math.max(rect.top, containerRect.top);
let bot = Math.min(rect.bottom, containerRect.bottom);
let visH = Math.max(0, bot - top);
if (visH > maxVH && visH > 48) {
maxVH = visH;
best = m;
}
});
if (best && best !== this.highlighted) {
this.highlightMsg(best);
}
}
enforceScrollBoundOnEdit() {
const act = document.activeElement;
if (act && act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0') && (act.matches('input:not([type="checkbox"]):not([type="radio"]), textarea, [contenteditable="true"]'))) {
const activeMsg = act.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
if (activeMsg && document.body.contains(activeMsg)) {
this.lastEditingMsg = activeMsg;
this.latestInputEdit = Date.now();
this.editPasteProhibit = true;
this.highlightMsg(activeMsg, { force: true });
this.ensureScrollInBounds(activeMsg);
if (this.scrollLockTimeout) clearTimeout(this.scrollLockTimeout);
this.scrollLockTimeout = setTimeout(() => {
if (Date.now() - this.latestInputEdit >= this.settings.EDIT_LOCK_DURATION_MS) {
this.editPasteProhibit = false;
this.lastEditingMsg = null;
this.scrollLockTimeout = null;
}
}, this.settings.EDIT_LOCK_DURATION_MS);
}
}
}
ensureScrollInBounds(msgDiv) {
if (!msgDiv || !this.scrollContainer) return;
const msgRect = msgDiv.getBoundingClientRect();
const scRect = this.scrollContainer.getBoundingClientRect();
if (msgRect.top < scRect.top || msgRect.bottom > scRect.bottom) {
msgDiv.scrollIntoView({ behavior: "auto", block: "center" });
}
}
disableContainerScroll() {
if (this.scrollContainer) this.scrollContainer.style.overflowY = 'hidden';
}
enableContainerScroll() {
if (this.scrollContainer) this.scrollContainer.style.overflowY = 'auto';
}
panelAndPageListeners() {
this.scrollContainer.addEventListener('click', e => {
const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
});
this.scrollContainer.addEventListener('focusin', e => {
const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
});
this.scrollContainer.addEventListener('mousedown', e => {
const msg = e.target.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0, [data-ormsgid]');
if (msg && this.allMessages.includes(msg)) this.highlightMsg(msg, { force: true });
});
const observer = new MutationObserver(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => this.updateMsgList());
});
});
observer.observe(this.scrollContainer, { childList: true, subtree: true });
const expandCollapseSelector = 'div.group.flex.flex-col.gap-2.items-start > div.flex.max-w-full.flex-col.relative.overflow-auto.gap-1.items-start.w-full > div > div > button, ' +
'div.group.flex.flex-col.gap-2.items-end > div.flex.max-w.full.flex-col.relative.overflow-auto.gap-1.items-end.w-full > div > div > button';
this.scrollContainer.addEventListener('mousedown', e => {
const btn = e.target.closest(expandCollapseSelector);
if (btn) {
const msgDiv = btn.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
if (msgDiv && this.allMessages.includes(msgDiv)) {
this.handleToggleScroll(msgDiv);
}
}
});
}
setupScrollListener() {
let lastScrollUpd = 0;
this.scrollContainer.addEventListener('scroll', () => {
if (Date.now() - lastScrollUpd > this.settings.ANTI_HYSTERESIS_MS) {
this.updateHighlightOnScroll();
lastScrollUpd = Date.now();
}
if (this.editPasteProhibit && this.lastEditingMsg) {
this.ensureScrollInBounds(this.lastEditingMsg);
}
}, { passive: true });
// Add wheel event listener to allow mouse wheel scrolling without interference
this.scrollContainer.addEventListener('wheel', (e) => {
// Do not blur or interfere with scrolling on wheel
// Just allow the event to propagate normally
// This prevents any focus blur that might block scrolling
e.stopPropagation();
}, { passive: true });
}
setupInputListeners() {
document.addEventListener('input', () => this.enforceScrollBoundOnEdit(), true);
document.addEventListener('paste', (e) => {
this.enforceScrollBoundOnEdit();
this.disableContainerScroll();
setTimeout(() => this.enableContainerScroll(), 100);
}, true);
document.addEventListener('cut', () => this.enforceScrollBoundOnEdit(), true);
document.addEventListener('focusout', () => {
if (this.editPasteProhibit && Date.now() - this.latestInputEdit > this.settings.EDIT_LOCK_DURATION_MS / 2) {
this.editPasteProhibit = false;
this.lastEditingMsg = null;
}
}, true);
setInterval(() => this.updateMsgList(), 880);
}
setupVisibilityAndResizeListeners() {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === "visible") setTimeout(() => this.updateMsgList(), 500);
});
window.addEventListener('resize', () => { setTimeout(() => this.updateHighlightOnScroll(), 80); });
}
initSpeakerTooltip() { // Add this new method
this.speakerTooltip = document.createElement('div');
this.speakerTooltip.id = 'speaker-tooltip-ch';
Object.assign(this.speakerTooltip.style, {
position: 'fixed',
top: '10px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(40, 40, 40, 0.9)',
color: 'white',
padding: '5px 10px',
borderRadius: '8px',
zIndex: '10001',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex', // Use flex for image and text
alignItems: 'center', // Align items vertically
gap: '8px', // Space between image and text
opacity: '0', // Initially hidden, controlled by showSpeakerForMessage
visibility: 'hidden', // Initially hidden
boxShadow: '0 2px 10px rgba(0,0,0,0.5)',
transition: 'opacity 0.2s ease-in-out, transform 0.2s ease-in-out, visibility 0.2s ease-in-out'
});
document.body.appendChild(this.speakerTooltip);
}
showSpeakerForMessage(messageElement) { // Add this new method
if (!this.speakerTooltip) this.initSpeakerTooltip();
if (!messageElement) {
this.speakerTooltip.style.opacity = '0';
this.speakerTooltip.style.transform = 'translateX(-50%) translateY(-20px)';
this.speakerTooltip.style.visibility = 'hidden';
return;
}
// Use the same speaker name retrieval logic as in the UI module for consistency
let speakerName = "Unknown Speaker";
if (this.ui && this.ui.getSpeakerName) {
speakerName = this.ui.getSpeakerName(messageElement) || speakerName;
} else {
// Fallback to previous logic if UI module is not available
const speakerElement = messageElement.querySelector(
'.font-semibold, div[class*="speaker" i], span[class*="name" i], [data-testid*="speaker" i], [aria-label*="speaker" i]'
);
if (speakerElement) {
speakerName = speakerElement.textContent.trim();
}
}
// Try to find speaker avatar
let speakerAvatarSrc = null;
const imgAvatar = messageElement.querySelector('img[alt]:not([alt=""]):not([alt*="logo"])');
if (imgAvatar) {
speakerAvatarSrc = imgAvatar.src;
} else {
const divAvatars = messageElement.querySelectorAll('div[style*="background-image"]');
for (let divAvatar of divAvatars) {
const style = divAvatar.style.backgroundImage;
if (style && style.includes('url(')) {
if (divAvatar.offsetWidth > 10 && divAvatar.offsetWidth < 100 && divAvatar.offsetHeight > 10 && divAvatar.offsetHeight < 100) {
speakerAvatarSrc = style.substring(style.indexOf('url("') + 4, style.lastIndexOf(')')).replace(/["|']/g, "");
break;
}
}
}
}
speakerName = speakerName.replace(/avatar/i, "").trim();
if (!speakerName || speakerName.toLowerCase() === 'user' || speakerName.toLowerCase() === 'assistant') {
const firstStrongBold = messageElement.querySelector('strong, b');
if (firstStrongBold && firstStrongBold.parentElement.children.length === 1) {
speakerName = firstStrongBold.textContent.trim();
}
}
this.speakerTooltip.innerHTML = '';
if (speakerAvatarSrc) {
const avatarImg = document.createElement('img');
avatarImg.src = speakerAvatarSrc;
Object.assign(avatarImg.style, {
width: '24px',
height: '24px',
borderRadius: '50%',
objectFit: 'cover'
});
this.speakerTooltip.appendChild(avatarImg);
}
const nameSpan = document.createElement('span');
nameSpan.textContent = speakerName;
this.speakerTooltip.appendChild(nameSpan);
this.speakerTooltip.style.visibility = 'visible';
this.speakerTooltip.style.opacity = '1';
this.speakerTooltip.style.transform = 'translateX(-50%) translateY(0)';
}
}
// Export to global namespace
window.NavigationModule = NavigationModule;
/*********************** HOTKEY MODULE **********************/
// MODULE_VERSION: HotkeyModule@1.1
// Centralizes keyboard event handling
class HotkeyModule {
constructor(settings, navigation, ui) {
this.settings = settings;
this.navigation = navigation;
this.ui = ui;
this.lastFocusedMsg = null;
this.patchedRetry = null;
this.patchRetryFunction();
}
patchRetryFunction() {
// Wait for the React context or message dispatch to be available
const tryPatch = () => {
try {
// Access the message dispatch from the navigation or ui
// Heuristic: navigation has a messages object with a dispatch method
if (this.navigation && this.navigation.ui && this.navigation.ui.messageDispatch) {
const dispatch = this.navigation.ui.messageDispatch;
if (dispatch && dispatch.retry && !dispatch.retry.__patched) {
const originalRetry = dispatch.retry;
const self = this;
dispatch.retry = async function(message, options) {
// Ignore isProcessing flag by not checking it
// Just call the original retry
return originalRetry.call(this, message, options);
};
dispatch.retry.__patched = true;
this.patchedRetry = dispatch.retry;
return true;
}
}
// Fallback: try to find global retry function
if (window && window.__RETRY_FUNCTION__ && !window.__RETRY_FUNCTION__.__patched) {
const originalRetry = window.__RETRY_FUNCTION__;
window.__RETRY_FUNCTION__ = async function(message, options) {
return originalRetry(message, options);
};
window.__RETRY_FUNCTION__.__patched = true;
this.patchedRetry = window.__RETRY_FUNCTION__;
return true;
}
} catch (e) {
// Ignore errors
}
return false;
};
const intervalId = setInterval(() => {
if (tryPatch()) {
clearInterval(intervalId);
}
}, 200);
}
async concurrentRetry(message) {
if (this.patchedRetry) {
await this.patchedRetry(message);
} else {
// Fallback: click refresh button
this.navigation.refreshActiveMsg();
}
}
init() {
document.addEventListener('keydown', this.handleKey.bind(this));
}
isModifier(event) {
if (this.settings.modifierKey === "None") return !event.ctrlKey && !event.altKey;
if (this.settings.modifierKey === "Ctrl") return event.ctrlKey && !event.altKey;
if (this.settings.modifierKey === "Alt") return event.altKey && !event.ctrlKey;
return false;
}
isEditing() {
const act = document.activeElement;
return act && (act.matches('input, textarea, [contenteditable]'));
}
focusMainInput() {
// Find all visible, enabled, non-readonly textareas/inputs
const candidates = Array.from(document.querySelectorAll('textarea, input[type="text"], input:not([type])'))
.filter(el => el.offsetParent !== null && !el.disabled && !el.readOnly);
if (!candidates.length) return;
// Pick the one closest to the bottom of the viewport (main chat input is usually at the bottom)
let best = candidates[0];
let maxBottom = -Infinity;
candidates.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.bottom > maxBottom) {
maxBottom = rect.bottom;
best = el;
}
});
best.focus();
if (best.value && best.selectionStart !== undefined) best.selectionStart = best.value.length;
}
async handleKey(e) {
let cancelledEdit = false; // Flag to track if Escape cancelled an edit
// --- ESCAPE HANDLING ---
if (e.key === "Escape") {
const act = document.activeElement;
const activeMsgContainer = act?.closest('.duration-200.group.my-2.flex.flex-col.gap-2.md\\:my-0');
// If editing a message (textarea inside a message)
if (activeMsgContainer && act.matches('textarea, [contenteditable]')) {
// Enhanced Cancel Button Finder with multiple strategies
let cancelBtn = null;
// Strategy 1: Look for buttons with "Cancel" text or aria-label
const buttonsInMsg = Array.from(activeMsgContainer.querySelectorAll('button, [role="button"][type="button"], [type="button"]'));
cancelBtn = buttonsInMsg.find(btn =>
/cancel/i.test(btn.textContent || btn.innerText || btn.getAttribute('aria-label') || '')
);
// Strategy 2: Look for buttons that appear during edit mode (often positioned near the textarea)
if (!cancelBtn) {
const editControls = act.closest('div')?.nextElementSibling;
if (editControls && editControls.querySelectorAll('button').length) {
const controlButtons = Array.from(editControls.querySelectorAll('button'));
// First button is often "Cancel" in edit controls
cancelBtn = controlButtons[0];
}
}
// Strategy 3: Look for buttons with specific classes that might indicate cancel functionality
if (!cancelBtn) {
cancelBtn = activeMsgContainer.querySelector('button[class*="cancel" i], button[class*="secondary" i]');
}
if (cancelBtn) {
const msgToKeepSelected = activeMsgContainer;
// Determine current scroll position relative to the message
const msgRect = activeMsgContainer.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const msgCenter = msgRect.top + (msgRect.height / 2);
const isAboveHalfway = msgCenter < (viewportHeight / 2);
cancelBtn.click();
// Re-highlight the same message after cancelling edit with smart scrolling
setTimeout(() => {
if (msgToKeepSelected && document.body.contains(msgToKeepSelected)) {
this.navigation.highlightMsg(msgToKeepSelected, {
scrollIntoView: true,
force: true,
// If above halfway point, scroll to top; otherwise scroll to bottom
scrollTop: isAboveHalfway,
scrollBottom: !isAboveHalfway
});
}
if (this.navigation.scrollContainer) {
this.navigation.scrollContainer.focus({ preventScroll: true });
}
}, 50); // Short delay
cancelledEdit = true; // Set the flag
e.preventDefault();
return; // Stop further Escape processing for this event
}
}
// If main chat input is focused (robust: bottom-most visible textarea/input in a form)
if (act && (act.matches('textarea, input[type="text"], input:not([type])'))) {
// Always blur the main chat input on Escape
act.blur();
// Restore highlight to last selected message
if (this.lastFocusedMsg && document.body.contains(this.lastFocusedMsg)) {
this.navigation.highlightMsg(this.lastFocusedMsg, { scrollIntoView: true, force: true });
}
e.preventDefault();
return;
}
// If a message is selected AND we didn't just cancel an edit, deselect
if (!cancelledEdit && this.navigation.highlighted) {
this.lastFocusedMsg = this.navigation.highlighted;
this.navigation.highlightMsg(null);
// Optionally, focus the scroll container
if (this.navigation.scrollContainer) this.navigation.scrollContainer.focus();
e.preventDefault();
return;
}
}
// --- CTRL+I: Focus main chat input ---
if ((e.ctrlKey || e.metaKey) && !e.altKey && e.key.toLowerCase() === "i") {
this.focusMainInput();
e.preventDefault();
return;
}
// --- Only process below if not editing or if allowed ---
if (
e.target.matches('input, textarea, [contenteditable]') &&
!["Home", "End", "PageUp", "PageDown"].includes(e.key)
) return;
if (!this.isModifier(e)) return;
let handled = false;
switch (e.key) {
// --- INVERTED NAVIGATION: j = up, k = down ---
case 'j':
this.navigation.navToMsg(-1); // up
handled = true;
break;
case 'k':
this.navigation.navToMsg(1); // down
handled = true;
break;
// --- Expand/Collapse ---
case 'l':
case 'h':
this.navigation.toggleMsgExpand();
handled = true;
break;
// --- Home/End: scroll within selected message (not in edit mode) ---
case 'Home':
if (!this.isEditing() && this.navigation.highlighted) {
this.navigation.scrollMsgTop();
handled = true;
}
break;
case 'End':
if (!this.isEditing() && this.navigation.highlighted) {
this.navigation.scrollMsgBottom();
handled = true;
}
break;
// --- Refresh selected message ---
case 'r':
if (this.navigation.highlighted) {
// Find the retry button in the highlighted message
const retryBtn = this.navigation.highlighted.querySelector('button[aria-label="Retry"], button[title="Retry"], button[aria-label*="Retry"], button[title*="Retry"]');
if (retryBtn) {
// If disabled, temporarily enable it to allow click
const wasDisabled = retryBtn.disabled;
if (wasDisabled) {
retryBtn.disabled = false;
}
retryBtn.click();
if (wasDisabled) {
// Restore disabled state after click
setTimeout(() => {
retryBtn.disabled = true;
}, 100);
}
handled = true;
} else {
// Fallback: refresh active message
this.navigation.refreshActiveMsg();
handled = true;
}
}
break;
// --- Copy button for selected message ---
case 'c':
if (this.navigation.highlighted) {
let copyBtn = this.navigation.highlighted.querySelector('button[aria-label*="Copy"], button[title*="Copy"], button svg[aria-label*="Copy"], button svg[title*="Copy"]');
if (!copyBtn) {
// Try fallback: first button with copy icon
copyBtn = Array.from(this.navigation.highlighted.querySelectorAll('button')).find(btn =>
btn.innerHTML.match(/copy/i)
);
}
if (copyBtn) {
copyBtn.click();
handled = true;
}
}
break;
// --- Edit button for selected message ---
case 'e':
if (this.navigation.highlighted) {
// If already editing, just focus the existing textarea
if (this.navigation.highlighted.querySelector('textarea, [contenteditable]')) {
const existingTextarea = this.navigation.highlighted.querySelector('textarea, [contenteditable]');
if (existingTextarea) {
existingTextarea.focus();
// Move cursor to end
if (existingTextarea.setSelectionRange) {
const len = existingTextarea.value.length;
existingTextarea.setSelectionRange(len, len);
}
}
handled = true;
break;
}
let editBtn = null;
const buttons = Array.from(this.navigation.highlighted.querySelectorAll('button'));
// Priority 1: Button with specific SVG path (most reliable if path is stable)
editBtn = buttons.find(btn => btn.querySelector('svg path[d^="m16.862 4.487"]'));
// Priority 2: Button with text content "Edit" (from old script, good for accessibility)
if (!editBtn) {
editBtn = buttons.find(btn => (btn.textContent || btn.innerText || "").trim().toLowerCase() === 'edit');
}
// Priority 3: Button with aria-label or title containing "Edit"
if (!editBtn) {
editBtn = buttons.find(btn => {
const ariaLabel = btn.getAttribute('aria-label') || "";
const title = btn.getAttribute('title') || "";
return /edit/i.test(ariaLabel) || /edit/i.test(title);
});
}
// Priority 4: Button containing an SVG with a title or aria-label "Edit"
if (!editBtn) {
editBtn = buttons.find(btn => {
const svg = btn.querySelector('svg');
if (!svg) return false;
const svgTitle = svg.querySelector('title')?.textContent;
const svgAriaLabel = svg.getAttribute('aria-label');
return /edit/i.test(svgTitle || "") || /edit/i.test(svgAriaLabel || "");
});
}
if (editBtn) {
const msgContainer = this.navigation.highlighted;
// Store a reference to the message before clicking
const msgId = msgContainer.dataset.ormsgid;
// Set up a MutationObserver to detect when the textarea appears
let editObserver = null;
const setupObserver = () => {
if (editObserver) return; // Only set up once
const currentMsg = document.querySelector(`[data-ormsgid="${msgId}"]`);
if (!currentMsg) return;
editObserver = new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' || mutation.type === 'subtree') {
const editableSelectors = [
'textarea',
'[contenteditable="true"]',
'[contenteditable]',
'div[role="textbox"]',
'.ProseMirror',
'[data-slate-editor]'
];
for (const selector of editableSelectors) {
const editArea = currentMsg.querySelector(selector);
if (editArea) {
// Focus immediately when detected
editArea.focus();
// Move cursor to the end
if (editArea.setSelectionRange) {
const len = editArea.value.length;
editArea.setSelectionRange(len, len);
} else if (editArea.isContentEditable) {
try {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editArea);
range.collapse(false); // to the end
sel.removeAllRanges();
sel.addRange(range);
} catch (e) {
// Fallback if range manipulation fails
editArea.focus();
}
}
// Disconnect after successful focus
observer.disconnect();
editObserver = null;
return;
}
}
}
}
});
// Observe the message container for changes
editObserver.observe(currentMsg, {
childList: true,
subtree: true,
attributes: true,
characterData: true
});
// Set a timeout to disconnect the observer if it doesn't find anything
setTimeout(() => {
if (editObserver) {
editObserver.disconnect();
editObserver = null;
}
}, 3000); // 3 second timeout
};
// Click the edit button
editBtn.click();
// Set up the observer immediately
setupObserver();
// Also use our previous approach with multiple attempts as a fallback
const focusEditArea = (attempt = 1) => {
const currentMsg = document.querySelector(`[data-ormsgid="${msgId}"]`);
if (!currentMsg) return;
const editableSelectors = [
'textarea',
'[contenteditable="true"]',
'[contenteditable]',
'div[role="textbox"]',
'.ProseMirror',
'[data-slate-editor]'
];
let editArea = null;
for (const selector of editableSelectors) {
editArea = currentMsg.querySelector(selector);
if (editArea) break;
}
if (editArea) {
// Focus with a slight delay to ensure the element is ready
setTimeout(() => {
editArea.focus();
// Move cursor to the end
if (editArea.setSelectionRange) {
const len = editArea.value.length;
editArea.setSelectionRange(len, len);
} else if (editArea.isContentEditable) {
try {
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(editArea);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
} catch (e) {
editArea.focus();
}
}
}, 10);
} else if (attempt < 5) { // Try up to 5 times
// Use exponential backoff for retry timing
setTimeout(() => focusEditArea(attempt + 1), Math.pow(2, attempt) * 50);
}
};
// Try to focus immediately
focusEditArea(1);
// And also after a short delay
setTimeout(() => focusEditArea(2), 100);
// And after a longer delay as a last resort
setTimeout(() => focusEditArea(3), 300);
handled = true;
}
}
break;
// ...existing code...
}
if (handled) e.preventDefault();
}
}
// Export to global namespace
window.HotkeyModule = HotkeyModule;
/******************** INIT ENTRYPOINT ********************/
async function initPowerNav() {
// Initialize core modules
const settings = new SettingsModule();
const ui = new UIModule();
const navigation = new NavigationModule();
const hotkeys = new HotkeyModule(settings, navigation, ui);
// Setup modules
settings.init();
// Register menu commands for settings
GM_registerMenuCommand("Set Hotkey Modifier: (Alt/Ctrl/None)", () => {
const val = prompt('Use which key as the hotkey modifier? (Alt, Ctrl, None)', settings.modifierKey);
if (!val) return;
const normalized = val.trim().toLowerCase();
const ok = { alt: "Alt", ctrl: "Ctrl", none: "None" }[normalized];
if (ok) {
settings.modifierKey = ok;
settings.save();
alert("Modifier set to: " + ok);
} else {
alert("Invalid. Must be Alt, Ctrl or None.");
}
});
GM_registerMenuCommand("Toggle Navigation Panel", () => {
settings.panelEnabled = !settings.panelEnabled;
settings.save();
if (settings.panelEnabled) {
ui.createPanel();
} else {
ui.clearPanel();
}
alert("Navigation panel " + (settings.panelEnabled ? "enabled" : "disabled") + ". Refresh page if needed.");
});
navigation.init(ui, settings);
hotkeys.init();
}
// Export init to global namespace
window.initPowerNav = initPowerNav;
// Delay initialization slightly to allow dynamic content loading after document-end
setTimeout(initPowerNav, 500);
})();