From d608e47bd57529687f10d887c574e30b367473d0 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 2 Nov 2026 01:37:39 -0600 Subject: [PATCH 1/2] fix(web): play completion sounds without delay - Preload notification audio on the user's first gesture - Present completion notifications as soon as the shell completes --- ...ificationCoordinator.badge.browser.test.ts | 38 ++--- ...hreadNotificationCoordinator.logic.test.ts | 154 +++++------------- .../ThreadNotificationCoordinator.logic.ts | 38 +---- .../ThreadNotificationCoordinator.tsx | 2 - apps/web/src/threadNotifications.test.ts | 10 ++ apps/web/src/threadNotifications.ts | 30 +++- .../client-runtime/src/state/threadInbox.ts | 2 +- 7 files changed, 83 insertions(+), 191 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 8b5c2e0b43a01..ae2ed3d10de3c 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -1,4 +1,3 @@ -import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; import type { AnyRouter } from "@tanstack/react-router"; import { EnvironmentId } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -6,7 +5,7 @@ import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; -import { fakeTimers, stubGlobal } from "../../test/render"; +import { stubGlobal } from "../../test/render"; const state = vi.hoisted(() => ({ mode: "notifications", @@ -241,24 +240,18 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }, ); -it("holds a completion through a continuation gap and notifies once it settles", async () => { +it("presents completion notifications and sound as soon as the shell completes", async () => { await using app = setup(); + state.mode = "notifications-and-sound"; app.start(); - using _timers = fakeTimers(); - const finished = (turnId: string, completedAt: string) => ({ - latestTurn: { turnId, state: "completed", completedAt }, - }); - state.shells.set("one", shell(finished("turn-1", "2026-09-13T07:00:01.000Z"))); state.shells.set( "one", - shell({ latestTurn: { turnId: "turn-2", state: "running", completedAt: null } }), + shell({ + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:00:01.000Z" }, + }), ); - state.shells.set("one", shell(finished("turn-2", "2026-09-13T07:00:02.000Z"))); - expect(TestNotification.sent).toEqual([]); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS - 1); - expect(TestNotification.sent).toEqual([]); - await vi.advanceTimersByTimeAsync(1); expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); + expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); }); it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( @@ -277,7 +270,6 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( }; state.shells.set("one", shell({ id: "working" }, { id: "asking" }, { id: "offline" }, done)); app.start(); - using _timers = fakeTimers(); state.shells.set("one", shell({ id: "working" }, asking, { id: "offline" }, done)); state.shells.set("one", { ...shell({ id: "working" }, asking, { id: "offline" }, done), @@ -296,37 +288,31 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( expect(TestNotification.sent.map(({ title }) => title)).toEqual([ "Approval needed", + "Thread completed", flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", ]); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); state.shells.set("one", { ...shell(completed, asking, offline, done), status: "cached" }); state.shells.set("one", shell(completed, asking, offline, done)); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); expect(TestNotification.sent.map(({ title }) => title)).toEqual([ "Approval needed", - flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", "Thread completed", + flag === "hasPendingApprovals" ? "Approval needed" : "Input needed", ]); }, ); it.each([false, true])( - "rechecks a pending completion after reconnect (continued: %s)", + "does not replay a notified completion after reconnect (continued: %s)", async (continued) => { await using app = setup(); 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); state.shells.set("one", { ...completed, status: "cached" }); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); - expect(TestNotification.sent).toEqual([]); + expect(TestNotification.sent.map(({ title }) => title)).toEqual(["Thread completed"]); state.shells.set("one", continued ? shell() : completed); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); - expect(TestNotification.sent.map(({ title }) => title)).toEqual( - continued ? [] : ["Thread completed"], - ); + expect(TestNotification.sent.map(({ title }) => title)).toEqual(["Thread completed"]); }, ); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 2bba09f54dcb1..a89e4159cae10 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -1,5 +1,4 @@ -import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { createThreadNotificationGate, @@ -30,164 +29,85 @@ function harness() { } describe("createThreadNotificationGate", () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - afterEach(() => { - vi.useRealTimers(); - }); - - it("drops a completion when the thread resumes within the grace", () => { - const { notify, observe } = harness(); - observe(working); - observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); - observe(working); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(notify).not.toHaveBeenCalled(); - }); - - it("notifies a real completion once after the grace", () => { + it("notifies a completion immediately and only once", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS - 1); - expect(notify).not.toHaveBeenCalled(); - vi.advanceTimersByTime(1); expect(notify.mock.calls).toEqual([["thread", "completion"]]); observe({ ...completed(1) }); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(notify).toHaveBeenCalledTimes(1); - }); - - it("notifies attention at once and replaces a pending completion", () => { - const { notify, observe } = harness(); observe(working); observe(completed(1)); - vi.advanceTimersByTime(500); - observe({ attention: "run-1:approval", settled: false, completedAt: null }); - expect(notify.mock.calls).toEqual([["thread", "input"]]); - vi.advanceTimersByTime(60_000); - // The request resolves and the same run reads as completed again. - observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(notify.mock.calls).toEqual([["thread", "input"]]); + expect(notify).toHaveBeenCalledTimes(1); }); - it("notifies one completion across continuation gaps", () => { + it("notifies each newer completion immediately", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - vi.advanceTimersByTime(300); observe(working); - vi.advanceTimersByTime(5_000); observe(completed(2)); - vi.advanceTimersByTime(300); - observe(working); - vi.advanceTimersByTime(5_000); - observe(completed(3)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); - expect(notify.mock.calls).toEqual([["thread", "completion"]]); + expect(notify.mock.calls).toEqual([ + ["thread", "completion"], + ["thread", "completion"], + ]); + observe(completed(1)); + expect(notify).toHaveBeenCalledTimes(2); }); - it("keeps a completion interrupted by attention after a working gap from firing", () => { + it("notifies attention once and still notifies the subsequent completion", () => { const { notify, observe } = harness(); observe(working); - observe(completed(1)); - vi.advanceTimersByTime(500); + const attention = { attention: "run-1:approval", settled: false, completedAt: null }; + observe(attention); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + observe({ ...attention }); + expect(notify).toHaveBeenCalledTimes(1); observe(working); - observe({ attention: "run-1:approval", settled: false, completedAt: null }); - vi.advanceTimersByTime(60_000); observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(notify.mock.calls).toEqual([["thread", "input"]]); + expect(notify.mock.calls).toEqual([ + ["thread", "input"], + ["thread", "completion"], + ]); }); - it("adds nothing when attention follows a completion that already fired", () => { + it("does not replay a completion after attention resolves", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); observe({ attention: "run-1:limited", settled: false, completedAt: null }); observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); expect(notify.mock.calls).toEqual([ ["thread", "completion"], ["thread", "input"], ]); }); - it("still notifies a newer completion after attention", () => { + it("never notifies existing, archived or stopped completions, including when unarchived", () => { const { notify, observe } = harness(); - observe(working); observe(completed(1)); - vi.advanceTimersByTime(500); - observe({ attention: "run-2:approval", settled: false, completedAt: null }); observe(working); + observe(completed(2), { ...visible, quiet: true }); observe(completed(2)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); - expect(notify.mock.calls).toEqual([ - ["thread", "input"], - ["thread", "completion"], - ]); - }); - - it("ignores unrelated updates while a completion waits", () => { - const { notify, observe } = harness(); observe(working); - observe(completed(1)); - vi.advanceTimersByTime(1_500); - observe({ ...completed(1) }); - vi.advanceTimersByTime(500); - expect(notify).toHaveBeenCalledTimes(1); - }); - - it("notifies a run that settles again after a brief runtime blip", () => { - const { notify, observe } = harness(); - observe(working); - observe(completed(1)); - vi.advanceTimersByTime(500); + observe(completed(3), { ...visible, archived: true }); + observe(completed(3)); + expect(notify).not.toHaveBeenCalled(); observe(working); - observe(completed(1)); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); + observe(completed(4)); expect(notify.mock.calls).toEqual([["thread", "completion"]]); }); - it("restarts the grace when a newer completion replaces a pending one", () => { - const { notify, observe } = harness(); - observe(working); - observe(completed(1)); - vi.advanceTimersByTime(1_500); - observe(completed(2)); - vi.advanceTimersByTime(1_500); - expect(notify).not.toHaveBeenCalled(); - vi.advanceTimersByTime(500); - expect(notify).toHaveBeenCalledTimes(1); - }); - - it("drops a pending completion on archive, stop request, removal or clear", () => { - for (const end of [ - (h: ReturnType) => h.observe(completed(1), { ...visible, archived: true }), - (h: ReturnType) => - h.observe({ ...completed(1) }, { ...visible, quiet: true }), - (h: ReturnType) => h.gate.retain(new Set()), - (h: ReturnType) => h.gate.clear(), + it("forgets handled completions when a thread is removed or the gate is cleared", () => { + for (const reset of [ + (gate: ReturnType>) => gate.retain(new Set()), + (gate: ReturnType>) => gate.clear(), ]) { - const h = harness(); - h.observe(working); - h.observe(completed(1)); - end(h); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(h.notify).not.toHaveBeenCalled(); + const { gate, notify, observe } = harness(); + observe(completed(1)); + reset(gate); + gate.observe("thread", undefined, working, visible); + gate.observe("thread", working, completed(1), visible); + expect(notify.mock.calls).toEqual([["thread", "completion"]]); } }); - - it("never notifies a stopped run or the first observation", () => { - const { notify, observe } = harness(); - observe(completed(1)); - observe(working); - observe(completed(2), { ...visible, quiet: true }); - vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); - expect(notify).not.toHaveBeenCalled(); - }); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index fdbfb7ae1f49d..1fe126ad37da3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,5 +1,3 @@ -import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; - export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { @@ -11,23 +9,11 @@ export interface ThreadNotificationObservation { readonly completedAt: number | null; } -/** - * Attention notifies at once. Completion waits out the Working shelf's exit grace, - * because an orchestrator run can end moments before its continuation starts. Any - * observation that is not settled during the wait cancels it. - */ export function createThreadNotificationGate( notify: (key: Key, kind: ThreadNotificationKind) => void, ) { - const pending = new Map }>(); // The newest completion already notified, or deliberately skipped. const handled = new Map(); - // The newest completion seen settled, kept while the thread works or waits. - const lastSettled = new Map(); - const cancel = (key: Key) => { - clearTimeout(pending.get(key)?.timer); - pending.delete(key); - }; const skip = (key: Key, at: number | null) => { if (at !== null && at > (handled.get(key) ?? -Infinity)) handled.set(key, at); }; @@ -38,44 +24,24 @@ export function createThreadNotificationGate( next: ThreadNotificationObservation, options: { readonly archived: boolean; readonly quiet: boolean }, ): void { - if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); - if (!next.settled || options.archived || options.quiet) cancel(key); // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet) skip(key, next.completedAt); if (!prior || options.archived) return; if (next.attention && next.attention !== prior.attention) { - // Attention replaces the completion before it, so that run must not notify later. - skip(key, lastSettled.get(key) ?? null); - cancel(key); notify(key, "input"); return; } const at = next.completedAt; if (!next.settled || at === null || at <= (handled.get(key) ?? -Infinity)) return; - if (pending.get(key)?.at === at) return; - cancel(key); - pending.set(key, { - at, - timer: setTimeout(() => { - pending.delete(key); - handled.set(key, at); - notify(key, "completion"); - }, WORKING_EXIT_GRACE_MS), - }); + handled.set(key, at); + notify(key, "completion"); }, /** Forgets threads that left the snapshot. */ retain(keys: ReadonlySet): void { - for (const key of pending.keys()) if (!keys.has(key)) cancel(key); 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(); - lastSettled.clear(); }, }; } diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 3af74e1d34139..ebdecb7d4d5ad 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -73,7 +73,6 @@ export function startThreadNotifications( setNotificationBadge(0); }; - // Completions fire from a timer, so read settings, focus and route when presenting. const present = ( environmentId: EnvironmentId, state: NotificationState, @@ -261,7 +260,6 @@ export function startThreadNotifications( observeThreads(environmentId, entry, threads); return; } - entry.gate.cancelPending(); entry.reconnecting = true; }, { immediate: true }, diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts index 412edfbb43309..ec94c110fdb1d 100644 --- a/apps/web/src/threadNotifications.test.ts +++ b/apps/web/src/threadNotifications.test.ts @@ -49,6 +49,16 @@ afterEach(() => { }); describe("playNotificationSound", () => { + it("preloads both sounds on unlock and reuses them for playback", async () => { + const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked(); + expect(fetch).toHaveBeenCalledTimes(2); + unlockNotificationAudio(); + await playNotificationSound("completion", () => true); + await playNotificationSound("input", () => true); + expect(fetch).toHaveBeenCalledTimes(2); + expect(audio.started).toBe(2); + }); + it("plays one sound for threads that complete together", async () => { const { playNotificationSound } = await loadUnlocked(); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 22ec58e0d7af0..8226173da9b46 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -85,10 +85,29 @@ const buffers = new Map>(); /** When each sound's current playback ends, on the audio context's clock. */ const playingUntil = new Map(); +function loadNotificationBuffer(context: AudioContext, url: string): Promise { + let buffer = buffers.get(url); + if (!buffer) { + buffer = fetch(url) + .then((response) => response.arrayBuffer()) + .then((data) => context.decodeAudioData(data)) + .catch((error: unknown) => { + buffers.delete(url); + throw error; + }); + buffers.set(url, buffer); + } + return buffer; +} + /** Called from a gesture so browsers allow later background playback. */ export function unlockNotificationAudio() { audioContext ??= new AudioContext(); void audioContext.resume().catch(() => undefined); + // Fetch and decode during interaction so the first alert is ready when the thread finishes. + for (const url of [completionUrl, inputUrl]) { + void loadNotificationBuffer(audioContext, url).catch(() => undefined); + } } export async function playNotificationSound( @@ -99,14 +118,7 @@ export async function playNotificationSound( const context = audioContext; const url = kind === "completion" ? completionUrl : inputUrl; try { - let buffer = buffers.get(url); - if (!buffer) { - buffer = fetch(url) - .then((response) => response.arrayBuffer()) - .then((data) => context.decodeAudioData(data)); - buffers.set(url, buffer); - } - const decoded = await buffer; + const decoded = await loadNotificationBuffer(context, url); if (!shouldPlay() || context.state !== "running") return; // Coalesce same-sound bursts after decode to avoid clipping; input and completion alerts keep separate slots. if (context.currentTime < (playingUntil.get(url) ?? 0)) return; @@ -116,6 +128,6 @@ export async function playNotificationSound( source.start(); playingUntil.set(url, context.currentTime + decoded.duration); } catch { - buffers.delete(url); + // A failed preload is retried on the next gesture or alert. } } diff --git a/packages/client-runtime/src/state/threadInbox.ts b/packages/client-runtime/src/state/threadInbox.ts index 43294c02a9b40..ba5e119438290 100644 --- a/packages/client-runtime/src/state/threadInbox.ts +++ b/packages/client-runtime/src/state/threadInbox.ts @@ -112,7 +112,7 @@ type ThreadKeyInput = Pick; * run can end moments before its continuation starts, so clients wait this long * before treating the thread as done. */ -export const WORKING_EXIT_GRACE_MS = 2_000; +const WORKING_EXIT_GRACE_MS = 2_000; /** * Keep one at module scope so placement survives routes that unmount the list. Call From c0ea965d09e421b40d8cc0b2bdc77efdbf5cdb22 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Mon, 2 Nov 2026 01:50:25 -0600 Subject: [PATCH 2/2] fix(web): preload notification audio once per context --- apps/web/src/threadNotifications.test.ts | 15 +++++++++++++++ apps/web/src/threadNotifications.ts | 4 +++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/web/src/threadNotifications.test.ts b/apps/web/src/threadNotifications.test.ts index ec94c110fdb1d..f48267c16f318 100644 --- a/apps/web/src/threadNotifications.test.ts +++ b/apps/web/src/threadNotifications.test.ts @@ -59,6 +59,21 @@ describe("playNotificationSound", () => { expect(audio.started).toBe(2); }); + it("retries failed preloads on an alert rather than every gesture", async () => { + vi.mocked(fetch).mockRejectedValue(new Error("offline")); + const { unlockNotificationAudio, playNotificationSound } = await loadUnlocked(); + await playNotificationSound("completion", () => true); + expect(fetch).toHaveBeenCalledTimes(2); + + unlockNotificationAudio(); + expect(fetch).toHaveBeenCalledTimes(2); + + vi.mocked(fetch).mockResolvedValue(new Response(new ArrayBuffer(0))); + await playNotificationSound("completion", () => true); + expect(fetch).toHaveBeenCalledTimes(3); + expect(audio.started).toBe(1); + }); + it("plays one sound for threads that complete together", async () => { const { playNotificationSound } = await loadUnlocked(); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 8226173da9b46..8784f79baceef 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -102,8 +102,10 @@ function loadNotificationBuffer(context: AudioContext, url: string): Promise undefined); + if (!firstUnlock) return; // Fetch and decode during interaction so the first alert is ready when the thread finishes. for (const url of [completionUrl, inputUrl]) { void loadNotificationBuffer(audioContext, url).catch(() => undefined); @@ -128,6 +130,6 @@ export async function playNotificationSound( source.start(); playingUntil.set(url, context.currentTime + decoded.duration); } catch { - // A failed preload is retried on the next gesture or alert. + // A failed load is retried on the next alert. } }