How I made "jump to message" work in ChatGPT, Claude and Gemini
I'm a third-year student, and a lot of my studying and project work happens in AI chats. The chats get long. The useful answer is always somewhere around message 78, and finding it again means a lot of scrolling. So I b
I'm a third-year student, and a lot of my studying and project work happens in AI chats. The chats get long. The useful answer is always somewhere around message 78, and finding it again means a lot of scrolling.
So I built DogEar, a Chrome extension that adds a β under every answer in ChatGPT, Claude, Gemini, Perplexity and Grok. You star an answer, and later you click it in a list and the chat scrolls straight back to it.
That last part sounded like one line of code:
element.scrollIntoView();
It took most of the project. This post is about why, and how the jump finally became reliable.
The three things that make it hard
1. The message often isn't on the page.
Claude virtualises its chat: it only keeps the messages near your screen in the DOM and swaps them in and out as you scroll. So if you saved message 78 and you're now at message 400, document.querySelector won't find it, even though it still exists. "Not found" doesn't mean "gone".
2. Older messages load lazily, and every load moves everything.
ChatGPT, Gemini and Claude don't load a long chat all at once. Older messages arrive in batches as you scroll up. Each batch is inserted above everything else, so every position on the page shifts down.
3. The sites change constantly.
Five sites, each with its own markup, each redesigning every few months, none with a public API for this.
Step 1: recognise the message again
When you star an answer, DogEar saves a few ways to recognise it later:
- The site's own message ID, when there is one (ChatGPT, for example, tags each message with an ID in a data attribute).
- A hash of the text, with whitespace collapsed first, so small layout changes don't break it.
- The first few words of the text, as a last resort.
The hash is a small FNV-1a, which is plenty for telling answers apart in one chat:
function hashText(text) {
const s = normalize(text); // collapse whitespace, trim
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0).toString(16).padStart(8, '0');
}
Finding the message is then a short chain. If an item was saved with a message ID, only that ID counts. Text matching could land on a similar-looking answer, and a wrong jump is worse than no jump:
function findMessage(item) {
const answers = getMessages().filter(m => m.role === 'ai');
const found = item.msgId
? answers.find(m => m.msgId === item.msgId)
: answers.find(m => fingerprint(m.el).hash === item.hash)
|| answers.find(m => fingerprint(m.el).prefix === item.prefix);
return found ? found.el : null;
}
This works when the message is on the page. The hard part is when it isn't.
Step 2: remember where it was, the right way
My first version saved the answer's position as a fraction of the chat: "this answer is 40% of the way down". On a lazy-loading site, that breaks. When you saved it, maybe only the last 100 messages were loaded. When you jump later, the site loads older batches at the top, the chat gets taller, and 40% now points somewhere else entirely.
The fix was to measure from the other end. Older messages only ever load at the top, so the distance from an answer to the bottom of the chat doesn't change:
// How far the answer sits above the end of the chat, in px.
// Unlike a percentage, this stays right while older messages load at the top.
function fromEndOf(el) {
const box = scrollBoxOf(el); // the chat's scrolling container
return Math.round(box.scrollHeight - offsetIn(el, box));
}
To jump, DogEar computes the target from the current height of the chat:
const target = box.scrollHeight - item.fromEnd - box.clientHeight / 3;
If target is negative, the chat isn't long enough yet, which means the older messages haven't loaded. So it stays at the top and lets the site load batch after batch until the chat is tall enough, then scrolls straight to the spot.
Step 3: convince Claude to keep loading
This one took a while to find. Sitting at the top of a Claude chat and waiting doesn't load anything. Claude only loads the next batch while the chat is scrolling.
I found this by having the extension write a log of every jump to a file in my Downloads folder (much easier than copying things out of DevTools), and comparing the logs from jumps that worked with ones that hung.
The fix is a small nudge: if nothing new has loaded for half a second, scroll down 150 px and back to the top on the next frame. To Claude, that looks like someone scrolling:
if (box.scrollTop <= 0 && now - grewAt > 500 && now - nudgedAt > 500) {
box.scrollTop = 150; // a small scroll downβ¦
nudgedAt = now;
} else if (box.scrollTop > 0) {
box.scrollTop = 0; // β¦and back up on the next frame
}
It also watches whether the chat is still growing. As long as new batches keep arriving, it keeps waiting (up to 45 s for very long chats). If nothing has loaded for 2.5 s, it stops waiting and starts searching.
Step 4: turn off scroll anchoring
Even after landing in the right area, jumps would sometimes drift. Browsers have scroll anchoring: when content above the screen changes size, the browser adjusts the scroll position so what you're looking at stays put. That's great for reading, but here, as the site filled in taller messages above, anchoring kept pushing the page back down and undid the scroll.
The fix is one line while the search runs, restored afterwards:
box.style.overflowAnchor = 'none';
Step 5: search, then give up honestly
Sites estimate the height of off-screen messages, and new messages added after saving push the answer further up. So landing at fromEnd gets close, but not always exactly there. If the answer isn't right there:
- look one screen below,
- then sweep up step by step, checking every frame,
- then sweep down.
After about 15 seconds (longer while a long chat is still loading), it stops and shows a small "Couldn't find this message" note instead of scrolling forever. Every new jump also cancels the previous one with a simple token counter, so clicking twice quickly never leaves two searches fighting over the scroll position.
When it does find the answer, it highlights it with a gold glow for a few seconds. That's a Web Animation rather than a CSS one, because Claude switches off CSS animations on its page.
Jumping into a different chat
Sometimes the answer is in another chat. A content script can't scroll a page that isn't open yet, so DogEar saves a small "pending jump" (which item, which URL, the time), then opens the chat. When the new page loads, it checks: is there a pending jump for this URL, less than 60 seconds old? If so, it runs the same search, then clears it.
Keeping five sites working
All the site-specific knowledge lives in one file, adapters.js. Each site is one object: where the answers are, where its button row is, how to get a message ID, whether an answer is still being written:
// Simplified: the real one has backup selectors for older versions of the page
{
name: 'ChatGPT',
hosts: ['chatgpt.com'],
aiSelectors: [
'[data-chatgpt-search-unit-key$=":assistant"]',
'[data-message-author-role="assistant"]' // older ChatGPT pages
],
starLike: 'button[aria-label="Copy"], button:first-of-type', // the β copies this button's look
msgId: el => el.closest('[data-message-id]')?.getAttribute('data-message-id'),
// β¦
}
Several selectors per list are tried in order, so when a site changes, the older ones still work as backups.
The rest of the extension never knows which site it's on. When a site redesigns, the fix is usually one selector in one file. Two rules helped a lot:
- Prefer stable attributes (
data-testid,data-message-author-role,aria-*) over generated class names, which change on every deploy. - Fail quietly. If a site changes and nothing is found, the extension does nothing. It never breaks the page.
How I tested it
Before calling it done, I logged about 70 test jumps across all five sites: near and far, same chat and other chats, short and very long. The logs showed things I wouldn't have guessed, like ChatGPT reusing old message elements when you switch chats, so the same DOM node can belong to a different message. Now an answer only counts as "the same" if it's the same element and has the same message ID.
What I'd do differently
- Automated tests against the real sites. Right now, a redesign is noticed when someone reports it. A small script that opens each site and checks that the β still appears would catch that early.
- Smaller files. The Library page grew past 1,000 lines. It works, but it's harder to change than it should be.
The project
DogEar is free and open source (MIT), with plain JavaScript, no frameworks, no build step and no server. Everything stays in your browser.
- GitHub: https://github.com/rishitsharma017/DogEar
- Chrome Web Store: https://chromewebstore.google.com/detail/hmnaddjjpiemgmdjgmcdhgaggiiaibnb
- 45-second demo: https://www.youtube.com/watch?v=xKkEEgoeyUw
If you've dealt with virtualised lists or lazy-loaded feeds from the outside, I'd love to hear how you handled it.
I built DogEar with Claude Code as my pair programmer, and this article was drafted with AI help and edited by me.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.

