From 1faf2c75a6b5419a051026f35c102ebdb4a1001d Mon Sep 17 00:00:00 2001 From: Jonathan Liebig Date: Wed, 14 Oct 2026 19:40:50 +0200 Subject: [PATCH] Fix thinking activity detection for current ChatGPT Stop control --- CHANGELOG.md | 3 ++ docs/windows-work.md | 5 +++ src/browser/actions/thinkingStatus.ts | 12 +++++-- tests/browser/thinkingStatus.test.ts | 48 ++++++++++++++++++++++----- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fc8123ba..8d4fee581a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ ### Fixed +- Recognize ChatGPT's current Stop control when reporting thinking activity, + instead of repeatedly reporting that no thinking status was detected. + - Wait for the new reply when answering expired question cards, and keep answers retryable when the composer rejects input before sending. diff --git a/docs/windows-work.md b/docs/windows-work.md index ca65c91d8a..25ac1bfe26 100644 --- a/docs/windows-work.md +++ b/docs/windows-work.md @@ -77,6 +77,11 @@ Read this when working on `ask_pro` from Windows and add new findings here. Future Windows gotchas belong here. +- October 14: thinking-status monitoring now uses the same visible composer Stop + selector as submission and response capture. The current icon-only + `aria-label="Stop"` control confirms activity without a separate thinking label; + hidden controls do not. The monitor never clicks Stop or Answer now. + - October 12: native clarification cards use `data-codex-composer-request-navigation` and show one question at a time. Pointer interaction requests a pause, but the live server returned 409 diff --git a/src/browser/actions/thinkingStatus.ts b/src/browser/actions/thinkingStatus.ts index 49b3aadd9e..f8c3aa38f4 100644 --- a/src/browser/actions/thinkingStatus.ts +++ b/src/browser/actions/thinkingStatus.ts @@ -1,6 +1,10 @@ import type { BrowserLogger, ChromeClient } from "../types.js"; import { formatElapsed } from "../format.js"; -import { ASSISTANT_ROLE_SELECTOR, CONVERSATION_TURN_SELECTOR } from "../constants.js"; +import { + ASSISTANT_ROLE_SELECTOR, + CONVERSATION_TURN_SELECTOR, + STOP_BUTTON_SELECTOR, +} from "../constants.js"; const THINKING_STALE_HINT_MS = 10 * 60_000; @@ -237,13 +241,15 @@ function buildThinkingStatusExpression(): string { [node.textContent, node.getAttribute?.('aria-label'), node.getAttribute?.('title')] .some((label) => normalize(label) === expected), ); - if (hasControl('answer now') && hasControl('stop answering')) { + const stopActive = hasControl('stop answering') || + Array.from(document.querySelectorAll(${JSON.stringify(STOP_BUTTON_SELECTOR)})).some(isVisible); + if (hasControl('answer now') && stopActive) { return { message: 'pro gate active', source: 'inline', }; } - if (hasControl('stop answering')) { + if (stopActive) { return { message: 'active', source: 'inline' }; } const looksLikeThinking = (node) => { diff --git a/tests/browser/thinkingStatus.test.ts b/tests/browser/thinkingStatus.test.ts index 48dc752bd8..b687f015bb 100644 --- a/tests/browser/thinkingStatus.test.ts +++ b/tests/browser/thinkingStatus.test.ts @@ -6,22 +6,39 @@ class FakeControl { readonly className = ""; readonly click = vi.fn(); - constructor(readonly textContent: string) {} + constructor( + readonly textContent: string, + readonly attributes: Record = {}, + readonly visible = true, + ) {} - getAttribute() { - return null; + getAttribute(name: string) { + return this.attributes[name] ?? null; } getBoundingClientRect() { - return { width: 100, height: 30, top: 0, left: 0, right: 100, bottom: 30 }; + return { width: this.visible ? 100 : 0, height: 30, top: 0, left: 0, right: 100, bottom: 30 }; } } -async function evaluateThinkingStatus(labels: string[]) { - const controls = labels.map((label) => new FakeControl(label)); +async function evaluateThinkingStatus( + labels: string[], + attributes: Record = {}, + visible = true, +) { + const controls = labels.map((label) => new FakeControl(label, attributes, visible)); const document = { - querySelectorAll: (selector: string) => - selector === 'button, [role="button"]' ? controls : [], + querySelectorAll: (selector: string) => { + if (selector === 'button, [role="button"]') return controls; + if (selector === '[data-testid="stop-button"], button[aria-label="Stop"]') { + return controls.filter( + (control) => + control.getAttribute("aria-label") === "Stop" || + control.getAttribute("data-testid") === "stop-button", + ); + } + return []; + }, }; const window = { innerWidth: 1920, @@ -45,6 +62,18 @@ async function evaluateThinkingStatus(labels: string[]) { } describe("thinking status", () => { + test.each>([{ "aria-label": "Stop" }, { "data-testid": "stop-button" }])( + "reports activity for the composer stop control %j without clicking it", + async (attributes) => { + const active = await evaluateThinkingStatus([""], attributes); + const hidden = await evaluateThinkingStatus([""], attributes, false); + + expect(active.snapshot).toMatchObject({ message: "active", source: "inline" }); + expect(active.controls[0].click).not.toHaveBeenCalled(); + expect(hidden.snapshot).toBeNull(); + }, + ); + test("reports active tool work with only Stop answering visible", async () => { const { snapshot, controls } = await evaluateThinkingStatus(["Stop answering"]); expect(snapshot).toMatchObject({ message: "active", source: "inline" }); @@ -53,10 +82,13 @@ describe("thinking status", () => { test("reports the passive Pro gate only while both controls are visible", async () => { const active = await evaluateThinkingStatus(["Answer now", "Stop answering"]); + const current = await evaluateThinkingStatus(["Answer now", ""], { "aria-label": "Stop" }); const incomplete = await evaluateThinkingStatus(["Answer now"]); expect(active.snapshot).toMatchObject({ message: "pro gate active", source: "inline" }); expect(active.controls.every((control) => control.click.mock.calls.length === 0)).toBe(true); + expect(current.snapshot).toMatchObject({ message: "pro gate active", source: "inline" }); + expect(current.controls.every((control) => control.click.mock.calls.length === 0)).toBe(true); expect(incomplete.snapshot).toBeNull(); }); });