From 7f4311dac3ad60439d9e9ce05d40735f418ffd12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 23:27:33 +0000 Subject: [PATCH 1/4] test(web): reproduce #32711, a replacing pending request does not notify again When a second approval or user-input request replaces the pending one in the same run, the notification coordinator keeps the same attention key and sends no second notification. These tests reproduce #32711. --- ...otificationCoordinator.badge.browser.test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index ae2ed3d10de3c..257994ccc76de 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -106,6 +106,7 @@ const thread = { archivedAt: null as string | null, hasPendingApprovals: false, hasPendingUserInput: false, + pendingRequestId: "request-1", session: null as { status: string } | null, latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, }; @@ -144,9 +145,9 @@ function toV2ThreadShell(input: typeof thread) { ? "failed" : "running", pendingRuntimeRequest: input.hasPendingUserInput - ? { id: "request-1", kind: "user_input", createdAt: SHELL_NOW } + ? { id: input.pendingRequestId, kind: "user_input", createdAt: SHELL_NOW } : input.hasPendingApprovals - ? { id: "request-1", kind: "command", createdAt: SHELL_NOW } + ? { id: input.pendingRequestId, kind: "command", createdAt: SHELL_NOW } : null, latestVisibleMessage: null, latestUserMessageAt: null, @@ -240,6 +241,18 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "notifies again when a new %s request replaces the pending one in the same run", + async (flag) => { + await using app = setup(); + app.start(); + state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-1" })); + state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-2" })); + const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed"; + expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]); + }, +); + it("presents completion notifications and sound as soon as the shell completes", async () => { await using app = setup(); state.mode = "notifications-and-sound"; From 3f468c4669bf736b58d70ad31e0be4b9dedc73db Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 23:30:56 +0000 Subject: [PATCH 2/4] fix(web): notify when a new request replaces the pending one in a run The notification attention key was only the run id and status, so a second approval or question that replaced the first in the same run produced no notification. The key now also carries the id of the request behind the status, taken from resolveBackgroundThreadRequest so that subagent requests and auth refreshes keep their previous keys. Fixes pingdotgg/t3code#32711 --- apps/web/src/components/ThreadNotificationCoordinator.logic.ts | 2 +- apps/web/src/components/ThreadNotificationCoordinator.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 1fe126ad37da3..70916ba742c31 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,7 +1,7 @@ export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run and status key while the thread needs attention, otherwise null. */ + /** Run, status and request key while the thread needs attention, otherwise null. */ readonly attention: string | null; /** The thread is idle and its latest run completed. */ readonly settled: boolean; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a7134b481d131..f752edf848ce5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -30,6 +30,7 @@ import { threadNotificationTag, unlockNotificationAudio, } from "../threadNotifications"; +import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement"; import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, @@ -258,7 +259,7 @@ export function startThreadNotifications( status === "approval" || status === "failed" || status === "limited" - ? `${thread.latestRun?.runId ?? ""}:${status}` + ? `${thread.latestRun?.runId ?? ""}:${status}:${resolveBackgroundThreadRequest(thread).requestId ?? ""}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); const settled = status === "ready" && thread.latestRun?.status === "completed"; From 1f1e60e925748675d6f3d65580266d67a7e008e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 12:34:18 +0000 Subject: [PATCH 3/4] fix(web): do not re-announce a request already notified in the same stretch With the request id in the attention key, answering the newer of two parallel requests flipped the key back to the older one and announced it a third time. The gate now remembers the attention keys a thread has shown since it last stopped needing attention and notifies only new ones. AI-assisted; verified with the tests and tools listed above. --- ...ThreadNotificationCoordinator.logic.test.ts | 11 +++++++++-- .../ThreadNotificationCoordinator.logic.ts | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index a89e4159cae10..664524e39251d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -54,17 +54,24 @@ describe("createThreadNotificationGate", () => { expect(notify).toHaveBeenCalledTimes(2); }); - it("notifies attention once and still notifies the subsequent completion", () => { + it("notifies each attention once per stretch and still notifies the subsequent completion", () => { const { notify, observe } = harness(); observe(working); - const attention = { attention: "run-1:approval", settled: false, completedAt: null }; + const attention = { attention: "run-1:approval:a", settled: false, completedAt: null }; observe(attention); expect(notify.mock.calls).toEqual([["thread", "input"]]); observe({ ...attention }); expect(notify).toHaveBeenCalledTimes(1); + observe({ ...attention, attention: "run-1:approval:b" }); + observe(attention); + expect(notify).toHaveBeenCalledTimes(2); + observe(working); + observe(attention); observe(working); observe(completed(1)); expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "input"], ["thread", "input"], ["thread", "completion"], ]); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 70916ba742c31..37548996fb210 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -17,6 +17,19 @@ export function createThreadNotificationGate( const skip = (key: Key, at: number | null) => { if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); }; + // Attention keys seen since the thread last stopped needing attention. + const attended = new Map>(); + const attend = (key: Key, attention: string | null): boolean => { + if (attention === null) { + attended.delete(key); + return false; + } + const seen = attended.get(key); + if (seen?.has(attention)) return false; + if (seen) seen.add(attention); + else attended.set(key, new Set([attention])); + return true; + }; return { observe( key: Key, @@ -26,8 +39,9 @@ export function createThreadNotificationGate( ): void { // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + const fresh = attend(key, next.attention); if (!prior || options.archived) return; - if (next.attention && next.attention !== prior.attention) { + if (fresh) { notify(key, "input"); return; } @@ -39,9 +53,11 @@ export function createThreadNotificationGate( /** Forgets threads that left the snapshot. */ retain(keys: ReadonlySet): void { for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); + for (const key of attended.keys()) if (!keys.has(key)) attended.delete(key); }, clear(): void { handled.clear(); + attended.clear(); }, }; } From 12d0972ba02040e9760d960e141adedc023c4dde Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 12:43:37 +0000 Subject: [PATCH 4/4] fix(web): keep notifying id-less subagent requests Subagent and side-chat requests carry no request id, so their attention key is the same for every such request in a run. Remembering keys seen in the stretch silenced a second subagent approval after the thread went back to its own question. The seen-in-stretch suppression now applies only to attention with a request id; id-less attention notifies whenever the key changes, as before. AI-assisted; verified with the tests and tools listed above. --- ...hreadNotificationCoordinator.logic.test.ts | 21 ++++++++++++++++--- .../ThreadNotificationCoordinator.logic.ts | 21 ++++++++++++------- .../ThreadNotificationCoordinator.tsx | 11 ++++++---- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 664524e39251d..1c98f89aa56ca 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -7,11 +7,13 @@ import { const working: ThreadNotificationObservation = { attention: null, + requestId: null, settled: false, completedAt: null, }; const completed = (at: number): ThreadNotificationObservation => ({ attention: null, + requestId: null, settled: true, completedAt: at, }); @@ -57,19 +59,32 @@ describe("createThreadNotificationGate", () => { it("notifies each attention once per stretch and still notifies the subsequent completion", () => { const { notify, observe } = harness(); observe(working); - const attention = { attention: "run-1:approval:a", settled: false, completedAt: null }; + const attention = { + attention: "run-1:approval:a", + requestId: "a", + settled: false, + completedAt: null, + }; observe(attention); expect(notify.mock.calls).toEqual([["thread", "input"]]); observe({ ...attention }); expect(notify).toHaveBeenCalledTimes(1); - observe({ ...attention, attention: "run-1:approval:b" }); + observe({ ...attention, attention: "run-1:approval:b", requestId: "b" }); observe(attention); expect(notify).toHaveBeenCalledTimes(2); observe(working); observe(attention); + expect(notify).toHaveBeenCalledTimes(3); + const subagent = { ...attention, attention: "run-1:approval:", requestId: null }; + observe(subagent); + observe(attention); + observe(subagent); + expect(notify).toHaveBeenCalledTimes(5); observe(working); observe(completed(1)); expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "input"], ["thread", "input"], ["thread", "input"], ["thread", "input"], @@ -81,7 +96,7 @@ describe("createThreadNotificationGate", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe({ attention: "run-1:limited", requestId: null, settled: false, completedAt: null }); observe(completed(1)); expect(notify.mock.calls).toEqual([ ["thread", "completion"], diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 37548996fb210..6414f2e88f1a3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -3,6 +3,8 @@ export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { /** Run, status and request key while the thread needs attention, otherwise null. */ readonly attention: string | null; + /** The pending request's id, when it has one; null whenever attention is. */ + readonly requestId: string | null; /** The thread is idle and its latest run completed. */ readonly settled: boolean; /** When the latest run completed, if it did. */ @@ -17,17 +19,22 @@ export function createThreadNotificationGate( const skip = (key: Key, at: number | null) => { if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); }; - // Attention keys seen since the thread last stopped needing attention. + // Attention keys with a request id seen since the thread last stopped needing attention. const attended = new Map>(); - const attend = (key: Key, attention: string | null): boolean => { - if (attention === null) { + const attend = ( + key: Key, + prior: ThreadNotificationObservation | undefined, + next: ThreadNotificationObservation, + ): boolean => { + if (next.attention === null) { attended.delete(key); return false; } + if (next.requestId === null) return next.attention !== prior?.attention; const seen = attended.get(key); - if (seen?.has(attention)) return false; - if (seen) seen.add(attention); - else attended.set(key, new Set([attention])); + if (seen?.has(next.attention)) return false; + if (seen) seen.add(next.attention); + else attended.set(key, new Set([next.attention])); return true; }; return { @@ -39,7 +46,7 @@ export function createThreadNotificationGate( ): void { // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); - const fresh = attend(key, next.attention); + const fresh = attend(key, prior, next); if (!prior || options.archived) return; if (fresh) { notify(key, "input"); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index f752edf848ce5..4d772458a028e 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -253,14 +253,16 @@ export function startThreadNotifications( const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; - const attention = + const needsAttention = status === "input" || status === "request" || status === "approval" || status === "failed" || - status === "limited" - ? `${thread.latestRun?.runId ?? ""}:${status}:${resolveBackgroundThreadRequest(thread).requestId ?? ""}` - : null; + status === "limited"; + const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null; + const attention = needsAttention + ? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}` + : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); const settled = status === "ready" && thread.latestRun?.status === "completed"; const state = { @@ -269,6 +271,7 @@ export function startThreadNotifications( thread, status, attention, + requestId, settled, completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, };