diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index aab1de3db6726..e2202254c91fd 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -243,12 +243,19 @@ 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", + "notifies once per %s request, not again as queued runs start behind it", 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 waiting = (turnId: string, pendingRequestId: string) => + shell({ + [flag]: true, + pendingRequestId, + latestTurn: { turnId, state: "running", completedAt: null }, + }); + state.shells.set("one", waiting("turn-1", "request-1")); + state.shells.set("one", waiting("turn-2", "request-1")); + state.shells.set("one", waiting("turn-2", "request-2")); const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed"; expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]); }, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index c8f0ca7df7a9a..81002179ea9c7 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, status and request key while the thread needs attention, otherwise null. */ + /** Status and request key, or status and run without a request, while the thread needs attention. */ readonly attention: string | null; /** The pending request's id, when it has one; null whenever attention is. */ readonly requestId: string | null; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 2051d4ad633a8..1de9cfcf87ee5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -261,9 +261,13 @@ export function startThreadNotifications( status === "failed" || status === "limited"; const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null; - const attention = needsAttention - ? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}` - : null; + // Queued messages become the latest run while a request stays pending, so a request + // with an id is keyed by that id alone and announces once. + const attention = !needsAttention + ? null + : requestId !== null + ? `${status}:${requestId}` + : `${thread.latestRun?.runId ?? ""}:${status}`; const completed = status === "ready" && thread.latestRun?.status === "completed"; const settled = completed && !monitorHoldsWakeCompletion(rawThread); const parsedCompletedAt = Date.parse(thread.latestRun?.completedAt ?? "");