From f2fdc5a6f6e60cd1c75b0015a35ca1bdce3cc6c9 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 08:29:15 -0500 Subject: [PATCH 1/4] fix(web): changes made while disconnected still notify after reconnect The coordinator cleared its notification baseline whenever the shell left the live state, so a thread that finished or asked for input during a dropped connection looked new after resync and never notified. Keep the last live baseline instead; alerts raised before the drop still do not repeat. Co-Authored-By: Claude Opus 5.5 --- ...ficationCoordinator.badge.browser.test.tsx | 23 +++++++++++++++++++ .../ThreadNotificationCoordinator.tsx | 7 +++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index dd537f2a8d0b4..5d3ac0aaf6ea4 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -215,3 +215,26 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( expect(notification.close).toHaveBeenCalled(); }, ); + +it("notifies for changes made while the environment was disconnected, once", async () => { + await using app = setup(); + const asking = { id: "asking", hasPendingApprovals: true }; + state.shells.set("one", shell({ id: "working" }, { id: "asking" })); + await app.render(); + state.shells.set("one", shell({ id: "working" }, asking)); + await app.render(); + state.shells.set("one", { ...shell({ id: "working" }, asking), status: "cached" }); + await app.render(); + const completed = { + id: "working", + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }; + state.shells.set("one", { ...shell(completed, asking), status: "synchronizing" }); + await app.render(); + state.shells.set("one", shell(completed, asking)); + await app.render(); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + "Thread completed", + ]); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index c9355fc7f8206..6ce1174a9196b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -121,10 +121,9 @@ function EnvironmentNotifications({ const previous = useRef(new Map()); useEffect(() => { - if (threads === null) { - previous.current.clear(); - return; - } + // Keep the last live baseline through a disconnect, so what changed meanwhile still + // notifies once the shell is live again, while alerts already raised stay quiet. + if (threads === null) return; const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); for (const rawThread of threads) { From bffd7de52019a1c74248d4dee464e374eba71475 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 03:58:36 +0000 Subject: [PATCH 2/4] fix(web): preserve reconnect alerts with completion deduplication --- ...ficationCoordinator.badge.browser.test.tsx | 76 +++++++++++++------ .../ThreadNotificationCoordinator.tsx | 7 +- 2 files changed, 54 insertions(+), 29 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 720dfbb8716a5..0b5b5cdd8ec8b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -241,27 +241,55 @@ 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 for changes made while the environment was disconnected, once", async () => { - await using app = setup(); - const asking = { id: "asking", hasPendingApprovals: true }; - state.shells.set("one", shell({ id: "working" }, { id: "asking" })); - await app.render(); - using _timers = fakeTimers(); - state.shells.set("one", shell({ id: "working" }, asking)); - await app.render(); - state.shells.set("one", { ...shell({ id: "working" }, asking), status: "cached" }); - await app.render(); - const completed = { - id: "working", - latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, - }; - state.shells.set("one", { ...shell(completed, asking), status: "synchronizing" }); - await app.render(); - state.shells.set("one", shell(completed, asking)); - await app.render(); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); - expect(TestNotification.sent.map(({ title }) => title)).toEqual([ - "Approval needed", - "Thread completed", - ]); -}); +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "notifies for offline completion and %s once across reconnects", + async (flag) => { + await using app = setup(); + const asking = { id: "asking", hasPendingApprovals: true }; + const offline = { id: "offline", [flag]: true }; + const done = { + id: "done", + latestTurn: { + turnId: "done-turn", + state: "completed", + completedAt: "2026-09-13T07:00:00.000Z", + }, + }; + state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done)); + await app.render(); + using _timers = fakeTimers(); + state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done)); + await app.render(); + state.shells.set("one", { + ...shell({ id: "working" }, asking, { id: "offline" }, done), + status: "cached", + }); + await app.render(); + const completed = { + id: "working", + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }; + state.shells.set("one", { + ...shell(completed, asking, offline, done), + status: "synchronizing", + }); + await app.render(); + state.shells.set("one", shell(completed, asking, offline, done)); + await app.render(); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + ]); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" }); + await app.render(); + state.shells.set("one", shell(completed, asking, offline, done)); + await app.render(); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ + "Approval needed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", + "Thread completed", + ]); + }, +); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index b6b6b119947e2..f917c783274d1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -231,11 +231,8 @@ function EnvironmentNotifications({ useEffect(() => { const pendingCompletions = gate.current; if (!pendingCompletions) return; - if (threads === null) { - pendingCompletions.clear(); - previous.current.clear(); - return; - } + // Keep the last live baseline and completion history through reconnects. + if (threads === null) return; const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); const changed: Array<[NotificationState | undefined, NotificationState]> = []; From 0479278d75476cdc87de16b9b161d7a5d9f49377 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 04:04:55 +0000 Subject: [PATCH 3/4] fix(web): recheck deferred completions after reconnect --- ...ficationCoordinator.badge.browser.test.tsx | 24 +++++++++++++++++++ .../ThreadNotificationCoordinator.logic.ts | 3 +++ .../ThreadNotificationCoordinator.tsx | 15 +++++++++--- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx index 0b5b5cdd8ec8b..02f94e362a06f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.tsx @@ -293,3 +293,27 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( ]); }, ); + +it.each([false, true])( + "rechecks a pending completion after reconnect (continued: %s)", + async (continued) => { + await using app = setup(); + await app.render(); + using _timers = fakeTimers(); + const completed = shell({ + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, + }); + state.shells.set("one", completed); + await app.render(); + state.shells.set("one", { ...completed, status: "cached" }); + await app.render(); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent).toEqual([]); + state.shells.set("one", continued ? shell() : completed); + await app.render(); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(TestNotification.sent.map(({ title }) => title)).toEqual( + continued ? [] : ["Thread completed"], + ); + }, +); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..fdbfb7ae1f49d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -69,6 +69,9 @@ export function createThreadNotificationGate( for (const key of handled.keys()) if (!keys.has(key)) handled.delete(key); for (const key of lastSettled.keys()) if (!keys.has(key)) lastSettled.delete(key); }, + cancelPending(): void { + for (const key of pending.keys()) cancel(key); + }, clear(): void { for (const key of pending.keys()) cancel(key); handled.clear(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index f917c783274d1..d33646f18b346 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -126,6 +126,7 @@ function EnvironmentNotifications({ strict: false, }); const previous = useRef(new Map()); + const reconnecting = useRef(false); // Completions fire from a timer, so read settings, focus and route when presenting. const present = useEffectEvent((threadId: ThreadId, kind: ThreadNotificationKind) => { @@ -231,8 +232,11 @@ function EnvironmentNotifications({ useEffect(() => { const pendingCompletions = gate.current; if (!pendingCompletions) return; - // Keep the last live baseline and completion history through reconnects. - if (threads === null) return; + if (threads === null) { + pendingCompletions.cancelPending(); + reconnecting.current = true; + return; + } const next = new Map(); const subagentRequests = pendingSubagentRequestsByThread(threads); const changed: Array<[NotificationState | undefined, NotificationState]> = []; @@ -240,7 +244,11 @@ function EnvironmentNotifications({ if (rawThread.lineage.relationshipToParent === "subagent") continue; const prior = previous.current.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; - if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { + if ( + !reconnecting.current && + prior?.raw === rawThread && + prior.subagentRequest === subagentRequest + ) { next.set(rawThread.id, prior); continue; } @@ -269,6 +277,7 @@ function EnvironmentNotifications({ next.set(thread.id, state); changed.push([prior, state]); } + reconnecting.current = false; previous.current = next; pendingCompletions.retain(new Set(next.keys())); for (const [prior, state] of changed) { From e2efd5632c6f9c50a6ab40d4b3c8c2e9422203c9 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 27 Oct 2026 01:49:16 +0000 Subject: [PATCH 4/4] test(web): migrate reconnect cases to notification service --- ...dNotificationCoordinator.badge.browser.test.ts | 15 ++++----------- 1 file changed, 4 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 7c4bd3d0c4323..0d04fcc76085d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -261,15 +261,14 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, }; state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done)); - await app.render(); + app.start(); using _timers = fakeTimers(); state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done)); - await app.render(); state.shells.set("one", { ...shell({ id: "working" }, asking, { id: "offline" }, done), status: "cached", }); - await app.render(); + const completed = { id: "working", latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, @@ -278,18 +277,15 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( ...shell(completed, asking, offline, done), status: "synchronizing", }); - await app.render(); state.shells.set("one", shell(completed, asking, offline, done)); - await app.render(); + expect(TestNotification.sent.map(({ title }) => title)).toEqual([ "Approval needed", flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", ]); await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" }); - await app.render(); state.shells.set("one", shell(completed, asking, offline, done)); - await app.render(); await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map(({ title }) => title)).toEqual([ "Approval needed", @@ -303,19 +299,16 @@ it.each([false, true])( "rechecks a pending completion after reconnect (continued: %s)", async (continued) => { await using app = setup(); - await app.render(); + app.start(); using _timers = fakeTimers(); const completed = shell({ latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:05:00.000Z" }, }); state.shells.set("one", completed); - await app.render(); state.shells.set("one", { ...completed, status: "cached" }); - await app.render(); await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent).toEqual([]); state.shells.set("one", continued ? shell() : completed); - await app.render(); await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map(({ title }) => title)).toEqual( continued ? [] : ["Thread completed"],