diff --git a/src/session.ts b/src/session.ts index 7f1ed406..926e01ca 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2763,7 +2763,17 @@ export class Session extends EventEmitter { // 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. 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'); } /** diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index 6522c01a..55bb0152 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -43,6 +43,7 @@ export interface SessionListenerRefs { exit: (code: number | null) => void; working: () => void; idle: () => void; + watchingChanged: () => void; taskCreated: (task: BackgroundTask) => void; taskUpdated: (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 ────────────────────────────── /** 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('working', refs.working); session.on('idle', refs.idle); + session.on('watchingChanged', refs.watchingChanged); session.on('taskCreated', refs.taskCreated); session.on('taskUpdated', refs.taskUpdated); session.on('taskCompleted', refs.taskCompleted); @@ -531,6 +544,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe session.off('exit', refs.exit); session.off('working', refs.working); session.off('idle', refs.idle); + session.off('watchingChanged', refs.watchingChanged); session.off('taskCreated', refs.taskCreated); session.off('taskUpdated', refs.taskUpdated); session.off('taskCompleted', refs.taskCompleted); diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index 1173fecb..3345c25d 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -22,6 +22,21 @@ describe('session listener wiring', () => { 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[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. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); diff --git a/test/session-watching.test.ts b/test/session-watching.test.ts index fc8f4d03..b28f9bbb 100644 --- a/test/session-watching.test.ts +++ b/test/session-watching.test.ts @@ -245,6 +245,40 @@ describe('Session.watching', () => { 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', () => { vi.useFakeTimers(); const screen: { text: string | null } = { text: WITH_MONITOR };