fix(watching): tell the page when the badge goes away

Reported from a manual test: a Codex session went on showing the watching badge
after its background terminal had finished. The server was right and the page
was stale — `Session.watching` changes while the session's status does not, and
nothing broadcast it.

The label is usually SET on the idle transition, which broadcasts anyway, so the
badge always appeared correctly. It CLEARS when the work ends, and a CLI can end
background work without taking a turn: codex repaints its background-terminal
row away and stays idle, so `_confirmIdle()` concludes without emitting `idle`
(that emit is guarded by `wasWorking || isInitialReady`) and no other event
fires. Every open page kept drawing a badge the server had already dropped.

`_readWatching()` now emits `watchingChanged` when, and only when, the label
really changes, and the wiring pushes the session state on it. No new SSE event:
the badge reads off the session payload every surface already has.

A/B measured on an isolated beta with the page loaded and then left untouched.
Without this commit the server dropped the label at t+50s and the page still
showed the badge at t+100s; with it, page and server cleared in the same
ten-second window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Grundberg
2026-09-22 17:59:45 +02:00
co-authored by Claude Opus 5
parent 8d45b92eba
commit b13672596f
4 changed files with 74 additions and 1 deletions
+11 -1
View File
@@ -2763,7 +2763,17 @@ export class Session extends EventEmitter {
// Called only from the probe, and only with what a capture returned: `null` is // Called only from the probe, and only with what a capture returned: `null` is
// "the screen could not be read", which is not evidence that nothing is running. // "the screen could not be read", which is not evidence that nothing is running.
if (!pattern || paneText === null) return; if (!pattern || paneText === null) return;
this._watching = watchingLabel(paneText, pattern, this._watchingWindow); const label = watchingLabel(paneText, pattern, this._watchingWindow);
if (label === this._watching) return;
this._watching = label;
// ⚠️ This CHANGES while the session's status does not, so it needs an event of its
// own. The label is usually set on the idle transition, which broadcasts anyway, but
// it CLEARS when the work ends — and for a CLI whose background work ends without
// taking a turn (measured on codex: a background terminal finishing repaints the row
// away and nothing else happens) the session is idle before and after. Without this,
// the server knew the badge was gone and every open page went on drawing it until
// some unrelated event arrived.
this.emit('watchingChanged');
} }
/** /**
+14
View File
@@ -43,6 +43,7 @@ export interface SessionListenerRefs {
exit: (code: number | null) => void; exit: (code: number | null) => void;
working: () => void; working: () => void;
idle: () => void; idle: () => void;
watchingChanged: () => void;
taskCreated: (task: BackgroundTask) => void; taskCreated: (task: BackgroundTask) => void;
taskUpdated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void;
taskCompleted: (task: BackgroundTask) => void; taskCompleted: (task: BackgroundTask) => void;
@@ -263,6 +264,17 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
} }
}, },
/**
* Pushes the session state when `Session.watching` changes without the status
* changing with it. That is the badge appearing or, more often, going away: a CLI can
* finish its background work without taking a turn, so the row is idle before and
* after and no other broadcast fires. There is no SSE event of its own, because the
* badge reads off the session payload every surface already has.
*/
watchingChanged: () => {
deps.broadcastSessionStateDebounced(session.id);
},
// ─── Background Task Events ────────────────────────────── // ─── Background Task Events ──────────────────────────────
/** Broadcasts `task:created` — new background task discovered */ /** Broadcasts `task:created` — new background task discovered */
@@ -495,6 +507,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('exit', refs.exit); session.on('exit', refs.exit);
session.on('working', refs.working); session.on('working', refs.working);
session.on('idle', refs.idle); session.on('idle', refs.idle);
session.on('watchingChanged', refs.watchingChanged);
session.on('taskCreated', refs.taskCreated); session.on('taskCreated', refs.taskCreated);
session.on('taskUpdated', refs.taskUpdated); session.on('taskUpdated', refs.taskUpdated);
session.on('taskCompleted', refs.taskCompleted); session.on('taskCompleted', refs.taskCompleted);
@@ -531,6 +544,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('exit', refs.exit); session.off('exit', refs.exit);
session.off('working', refs.working); session.off('working', refs.working);
session.off('idle', refs.idle); session.off('idle', refs.idle);
session.off('watchingChanged', refs.watchingChanged);
session.off('taskCreated', refs.taskCreated); session.off('taskCreated', refs.taskCreated);
session.off('taskUpdated', refs.taskUpdated); session.off('taskUpdated', refs.taskUpdated);
session.off('taskCompleted', refs.taskCompleted); session.off('taskCompleted', refs.taskCompleted);
+15
View File
@@ -22,6 +22,21 @@ describe('session listener wiring', () => {
expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external'); expect(registerAttachment).toHaveBeenNthCalledWith(2, 'wiring-attach-source-test', '/tmp/report.pdf', 'external');
}); });
it('pushes the session state when the watching label changes on its own', () => {
// The badge appears on the idle transition, which broadcasts anyway. It goes AWAY
// when the background work ends, and a CLI can do that without taking a turn — codex
// repaints its background-terminal row away and stays idle — so nothing else fires
// and every open page would keep drawing a badge the server had already dropped.
const session = new Session({ id: 'wiring-watching-test', workingDir: '/tmp', mode: 'codex' });
const broadcastSessionStateDebounced = vi.fn();
const deps = { broadcastSessionStateDebounced } as unknown as Parameters<typeof createSessionListeners>[1];
const refs = createSessionListeners(session, deps);
refs.watchingChanged();
expect(broadcastSessionStateDebounced).toHaveBeenCalledWith('wiring-watching-test');
});
/** The listener reads the setting asynchronously; let its promise chain settle. */ /** The listener reads the setting asynchronously; let its promise chain settle. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); const flush = () => new Promise((resolve) => setTimeout(resolve, 5));
+34
View File
@@ -245,6 +245,40 @@ describe('Session.watching', () => {
expect(session.watching).toBeNull(); expect(session.watching).toBeNull();
}); });
it('announces the change, because the session status does not move with it', () => {
// Measured on codex: a background terminal finishing repaints the row away and the
// session is idle before and after, so no other event fires. Without this one the
// server drops the label and every open page goes on drawing the badge.
vi.useFakeTimers();
const screen = { text: WITH_MONITOR };
const session = withFakePane(() => screen.text);
const changes: (string | null)[] = [];
session.on('watchingChanged', () => changes.push(session.watching));
runAndSettle(session);
expect(changes).toEqual(['1 monitor']);
// A repaint that carries the composer glyph but no chip: the pane went quiet again
// without a turn, which is exactly the case the event exists for.
screen.text = NOTHING_RUNNING;
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
expect(changes).toEqual(['1 monitor', null]);
expect(session.status).toBe('idle');
});
it('says nothing while the answer stays the same', () => {
vi.useFakeTimers();
const session = withFakePane(WITH_MONITOR);
const changes: (string | null)[] = [];
session.on('watchingChanged', () => changes.push(session.watching));
runAndSettle(session);
runAndSettle(session);
runAndSettle(session);
expect(changes).toEqual(['1 monitor']);
});
it('keeps its last answer when the screen cannot be read', () => { it('keeps its last answer when the screen cannot be read', () => {
vi.useFakeTimers(); vi.useFakeTimers();
const screen: { text: string | null } = { text: WITH_MONITOR }; const screen: { text: string | null } = { text: WITH_MONITOR };