Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions docs/windows-work.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/browser/actions/thinkingStatus.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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) => {
Expand Down
48 changes: 40 additions & 8 deletions tests/browser/thinkingStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,39 @@ class FakeControl {
readonly className = "";
readonly click = vi.fn();

constructor(readonly textContent: string) {}
constructor(
readonly textContent: string,
readonly attributes: Record<string, string> = {},
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<string, string> = {},
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,
Expand All @@ -45,6 +62,18 @@ async function evaluateThinkingStatus(labels: string[]) {
}

describe("thinking status", () => {
test.each<Record<string, string>>([{ "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" });
Expand All @@ -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();
});
});
Loading