How to detect that ChatGPT, Claude or Gemini finished an answer
If you have ever built a browser extension, a userscript, or an automation that reacts to an AI chat answer, you run into the same wall: ChatGPT, Claude and Gemini are three different single-page apps, none of them expose a public "generation finished" event, and the DOM changes on every redesign.
There is a reliable signal that survives most redesigns, though: the "Stop generating" button. All three products show one while an answer is streaming and remove it the moment the answer is done. Here is how to detect it safely — with one polling loop, no page-content access, and no MutationObserver overhead — and how to turn that signal into an "answer is ready" notification that fires even when the tab is in the background.
This is the exact approach used in DevPause, a free Chrome extension that adds utility buttons under AI answers. The code below is lifted almost verbatim from its content script.
Why not MutationObserver
The obvious first instinct is to attach a MutationObserver to the chat container and react to every DOM mutation. In practice this is noisy and expensive: ChatGPT alone mutates the DOM dozens of times per second while streaming tokens — observing all of that just to answer one boolean question ("is it still generating?") is wasted CPU, and you still have to filter the mutation list down to "did the stop button appear or disappear," which is exactly what a direct query already tells you.
A plain setInterval that checks for the stop button's presence is simpler, cheaper, and — because the check is a single querySelector — trivial to audit for privacy: it either finds the button or it doesn't, and nothing else on the page is touched.
Step 1: per-site selectors for the stop button
Each site uses a different selector, and each one has changed at least once since launch, so the code keeps a short list of candidates per site rather than a single string:
const STOP_SELECTORS = {
"chatgpt.com": [
'button[data-testid="stop-button"]',
'button[aria-label="Stop streaming"]',
'button[aria-label="Stop generating"]',
],
"claude.ai": [
'button[aria-label="Stop response"]',
'button[aria-label="Stop Response"]',
],
"gemini.google.com": [
'button[aria-label="Stop response"]',
"button.send-button.stop",
'button[aria-label="Stop generating"]',
],
};
function generating() {
try {
return STOP_SELECTORS[site].some((s) => document.querySelector(s) !== null);
} catch {
return false;
}
}
The try/catch matters more than it looks: a selector that becomes invalid after a site redesign (an unescaped character, a removed attribute) should fail closed — "not generating" — rather than throw and break the whole content script.
Note what this function does not do: it never reads textContent, never inspects the message list, never looks at the input field. The only question it can answer is "does an element matching one of these selectors currently exist." That is a deliberate privacy boundary, not an accident.
Step 2: a tick loop instead of an observer
const POLL_MS = 700;
const MIN_ANSWER_MS = 1500; // shorter than this is a flicker, not a real answer
const STUCK_MS = 90000; // "stop" button stuck this long: the answer may have stalled
let busySince = 0;
function tick() {
const now = Date.now();
const busy = generating();
const here = document.visibilityState === "visible" && document.hasFocus();
if (busy) {
if (!busySince) busySince = now;
} else if (busySince) {
const waitedMs = now - busySince;
busySince = 0;
if (waitedMs >= MIN_ANSWER_MS) {
// the answer just finished - this is the one event everything else is built on
onAnswerDone({ waitedMs, away: !here });
}
}
}
setInterval(tick, POLL_MS);
700ms is a deliberate middle ground: fast enough that a human never perceives the delay between the button disappearing and the extension reacting, slow enough that polling thirty times a minute has no measurable effect on page performance.
The MIN_ANSWER_MS guard exists because the stop button on some sites blinks in and out for a fraction of a second on short answers or regenerations — without a minimum duration, that flicker would fire a false "answer done" event on every message.
Step 3: react even when the tab is in the background
The actual payoff of this signal is the Page Visibility API combination: a background tab can still run setInterval, so you can detect "the answer finished while the user was in another tab" and only then show a notification — a notification while the user is already watching the tab would just be noise.
function onAnswerDone({ waitedMs, away }) {
if (away) {
chrome.runtime.sendMessage({ type: "done", waitedMs, away: true });
// background.js turns this into a badge count + a system notification
}
}
This is also where a "stuck" signal becomes useful: if the stop button has been present for longer than STUCK_MS (90 seconds in DevPause), that is a reasonable proxy for "something went wrong" rather than "a very long answer," and worth surfacing differently than a normal completion.
Step 4: turning the finished DOM node into plain text
Detecting that an answer is done is only half the problem if your goal is to do something with its content — e.g. a "copy as clean text" button. Reading innerText on a ChatGPT or Claude answer node pulls in markdown artifacts (**bold**, stray # headers, screen-reader-only duplicate text) that look fine rendered but are garbage once pasted elsewhere.
The trick is to walk the DOM tree the browser already rendered, rather than trying to re-parse markdown:
function walk(node, ctx) {
if (node.nodeType === 3) return node.nodeValue.replace(/\s+/g, " ");
if (node.nodeType !== 1) return "";
const tag = node.tagName.toUpperCase();
if (SKIP.has(tag)) return ""; // buttons, svg, script, style...
if (/\b(sr-only|visually-hidden)\b/.test(node.className)) return ""; // a11y-only duplicate text
if (tag === "UL" || tag === "OL") return listToLines(node, ctx);
if (tag === "TABLE") return tableToLines(node);
if (/^H[1-6]$/.test(tag)) return `\n\n${inner(node, ctx).trim()}\n\n`;
return BLOCK.has(tag) ? `\n${inner(node, ctx)}\n` : inner(node, ctx);
}
Walking the live, already-rendered DOM rather than the markdown source has one advantage that is easy to miss: it is the same tree the user is looking at, so lists, tables and code blocks come out exactly as they are structured on screen — no separate markdown parser to keep in sync with three different chat UIs.
Putting it together
None of this requires a server, a background fetch, or reading the conversation text at any point other than the explicit "copy" click a user makes. The whole detection layer is: one querySelector list per site, a 700ms interval, and the Page Visibility API. That is also why it is auditable in a single sitting — which matters if, like DevPause, the extension asks for host permissions and wants anyone to be able to check what it actually does with them.
Frequently asked questions
Does this work if the site redesigns its button?
It degrades safely: the selector list stops matching, generating() falls back to "not generating," and the extension simply stops detecting that specific site's completion event until the selector list is updated. It never throws or breaks other buttons.
Why not just use the Fetch/XHR response instead of the DOM?
ChatGPT, Claude and Gemini stream answers over connections that are awkward to intercept reliably from a content script across three different backends, and doing so would mean touching network data rather than just the rendered page. Watching one button is a much smaller, more auditable surface.
DevPause packages this detection logic together with the "copy clean," "shorter / simpler / as a table," "continue," wide-screen and answer-ready-notification buttons as a free Chrome extension for chatgpt.com, claude.ai and gemini.google.com. It is ad-supported (one small text line while an answer streams) rather than subscription-based.