From 6d7d6dc740efed88690e0f35317af36d59843728 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:00:46 +0000 Subject: [PATCH 1/3] test(web): reproduce repeated approval notifications for queued runs (#24882) These tests reproduce #24882: each run queued behind a pending approval becomes the shell's latest run, changing the attention key, so the same pending request notifies again. Co-Authored-By: Claude Opus 5.5 --- ...ificationCoordinator.badge.browser.test.ts | 25 +++++++++++++++++-- 1 file changed, 23 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 f253dd6be22c7..a040d9f3c0fc8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -92,6 +92,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 }, }; @@ -130,9 +131,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, @@ -245,3 +246,23 @@ it("holds a completion through a continuation gap and notifies once it settles", await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); + +it("notifies a pending approval once while runs queue behind it", async () => { + await using app = setup(); + app.start(); + const waiting = (turnId: string, pendingRequestId = "request-1") => ({ + hasPendingApprovals: true, + pendingRequestId, + latestTurn: { turnId, state: "running", completedAt: null }, + }); + state.shells.set("one", shell(waiting("turn-1"))); + // Each queued message becomes the shell's latest run while the approval stays pending. + state.shells.set("one", shell(waiting("turn-2"))); + state.shells.set("one", shell(waiting("turn-3"))); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Approval needed"]); + state.shells.set("one", shell(waiting("turn-3", "request-2"))); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual([ + "Approval needed", + "Approval needed", + ]); +}); From 107c2c98c679435b134ee2d9f817615c99ae0fae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:04:25 +0000 Subject: [PATCH 2/3] fix(web): a pending approval notifies once while runs queue behind it The thread notifier keyed attention on the latest run id, and every message queued behind a waiting thread becomes its latest run, so one pending approval re-announced itself per queued run. Approval and input attention now key on the pending runtime request id; failed, limited and guest-request attention still key on the run. Co-Authored-By: Claude Opus 5.5 --- .../ThreadNotificationCoordinator.logic.ts | 2 +- .../components/ThreadNotificationCoordinator.tsx | 13 ++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..3150239c3d55a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -3,7 +3,7 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbo export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run and status key while the thread needs attention, otherwise null. */ + /** Request or run, and status, 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 a782e24da6522..fe83225b50f7b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -186,14 +186,13 @@ export function startThreadNotifications( const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; + // A request outlives the runs queued behind it, so it keys its own attention. const attention = - status === "input" || - status === "request" || - status === "approval" || - status === "failed" || - status === "limited" - ? `${thread.latestRun?.runId ?? ""}:${status}` - : null; + status === "input" || status === "approval" + ? `${rawThread.pendingRuntimeRequest?.id ?? ""}:${status}` + : status === "request" || status === "failed" || status === "limited" + ? `${thread.latestRun?.runId ?? ""}:${status}` + : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); const settled = status === "ready" && thread.latestRun?.status === "completed"; const state = { From 8e83c58ac34c2479ac8b3b33402dfe2e8a877393 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:14:35 +0000 Subject: [PATCH 3/3] fix(web): overlapping requests no longer re-announce the older one Key approval and input attention on the status alone. The shell shows only the newest pending request, so a request id key announced an older request again once a newer overlapping one was answered. Co-Authored-By: Claude Opus 5.5 --- ...ificationCoordinator.badge.browser.test.ts | 23 ++++++++++++------- .../ThreadNotificationCoordinator.logic.ts | 2 +- .../ThreadNotificationCoordinator.tsx | 5 ++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index a040d9f3c0fc8..3ca09a78823ed 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -247,22 +247,29 @@ it("holds a completion through a continuation gap and notifies once it settles", expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); -it("notifies a pending approval once while runs queue behind it", async () => { +it.each([ + ["hasPendingApprovals", "Approval needed"], + ["hasPendingUserInput", "Input needed"], +] as const)("announces a pending %s once while runs queue behind it", async (flag, title) => { await using app = setup(); app.start(); const waiting = (turnId: string, pendingRequestId = "request-1") => ({ - hasPendingApprovals: true, + [flag]: true, pendingRequestId, latestTurn: { turnId, state: "running", completedAt: null }, }); state.shells.set("one", shell(waiting("turn-1"))); - // Each queued message becomes the shell's latest run while the approval stays pending. + // Each queued message becomes the shell's latest run while the request stays pending. state.shells.set("one", shell(waiting("turn-2"))); state.shells.set("one", shell(waiting("turn-3"))); - expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Approval needed"]); + // The shell shows the newest of overlapping requests, then the older one again. state.shells.set("one", shell(waiting("turn-3", "request-2"))); - expect(TestNotification.sent.map((sent) => sent.title)).toEqual([ - "Approval needed", - "Approval needed", - ]); + state.shells.set("one", shell(waiting("turn-3"))); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual([title]); + state.shells.set( + "one", + shell({ latestTurn: { turnId: "turn-3", state: "running", completedAt: null } }), + ); + state.shells.set("one", shell(waiting("turn-3", "request-3"))); + expect(TestNotification.sent.map((sent) => sent.title)).toEqual([title, title]); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 3150239c3d55a..2c4467dbe7d5b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -3,7 +3,7 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbo export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Request or run, and status, while the thread needs attention, otherwise null. */ + /** Attention status, keyed by run where a run can raise it again, 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 fe83225b50f7b..e89be7e7345ed 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -186,10 +186,11 @@ export function startThreadNotifications( const thread = presentThreadShell(environmentId, rawThread, subagentRequest); let status = resolveSidebarThreadStatus(thread); if (status === "ready" && thread.latestRun?.status === "failed") status = "failed"; - // A request outlives the runs queued behind it, so it keys its own attention. + // Runs queue behind a pending request and the shell shows only the newest of several, + // so neither the run nor the request id may re-announce one that is still waiting. const attention = status === "input" || status === "approval" - ? `${rawThread.pendingRuntimeRequest?.id ?? ""}:${status}` + ? status : status === "request" || status === "failed" || status === "limited" ? `${thread.latestRun?.runId ?? ""}:${status}` : null;