Skip to content

Commit 8069b42

Browse files
committed
fix(webapp): don't re-toast a watch wake another browser already read
The poll deduped fresh wakes against a 50-id localStorage set alone, so inside the feed's 15-minute window a second browser — or one whose site data was cleared — toasted wakes the user had already read. The payload's own `unread` flag now gates the toast as well. A wake landing in an open chat stays unread until that chat's next read, so it still toasts.
1 parent 38ed62f commit 8069b42

3 files changed

Lines changed: 42 additions & 3 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgent.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
} from "./panel-layout";
2424
import { nextPendingTurnChatId } from "./pending-turn";
2525
import { nextVisibleChat } from "./unread-counts";
26-
import { startWakePolling } from "./wake-poll";
26+
import { startWakePolling, wakesToToast } from "./wake-poll";
2727
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2828
import {
2929
showWatchWakesSummaryToast,
@@ -217,7 +217,7 @@ export function DashboardAgent({
217217
// A chat open in the panel is being read right now, so it isn't unread work.
218218
setUnreadWork(Math.max(0, (data.unreadWork ?? 0) - (open && visibleChat.current ? 1 : 0)));
219219

220-
const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
220+
const fresh = wakesToToast(data.wakes, toastedWakes.current);
221221
for (const wake of fresh) rememberToasted(wake.watchId);
222222

223223
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {

apps/webapp/app/components/dashboard-agent/wake-poll.test.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { startWakePolling, UNREAD_POLL_INTERVAL_MS } from "./wake-poll";
2+
import { startWakePolling, UNREAD_POLL_INTERVAL_MS, wakesToToast } from "./wake-poll";
33

44
function harness() {
55
let hidden = false;
@@ -98,3 +98,29 @@ describe("startWakePolling", () => {
9898
expect(vi.getTimerCount()).toBe(0);
9999
});
100100
});
101+
102+
describe("wakesToToast", () => {
103+
const wake = (watchId: string, unread: boolean) => ({ watchId, unread });
104+
105+
it("skips a wake another machine already read, and keeps the unread one", () => {
106+
const wakes = [wake("watch_read", false), wake("watch_new", true)];
107+
108+
expect(wakesToToast(wakes, new Set())).toEqual([wake("watch_new", true)]);
109+
});
110+
111+
it("still skips what this browser toasted, read or not", () => {
112+
const wakes = [wake("watch_seen", true), wake("watch_new", true)];
113+
114+
expect(wakesToToast(wakes, new Set(["watch_seen"]))).toEqual([wake("watch_new", true)]);
115+
});
116+
117+
// The read POST is what clears it, and that only runs once the chat is looked at.
118+
it("toasts a wake that landed in an open chat, because it is still unread", () => {
119+
expect(wakesToToast([wake("watch_in_view", true)], new Set())).toHaveLength(1);
120+
});
121+
122+
it("treats a wake with no unread flag as already seen rather than guessing", () => {
123+
expect(wakesToToast([{ watchId: "watch_old" }], new Set())).toEqual([]);
124+
expect(wakesToToast(undefined, new Set())).toEqual([]);
125+
});
126+
});

apps/webapp/app/components/dashboard-agent/wake-poll.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,19 @@ export const UNREAD_POLL_INTERVAL_MS = 60_000;
88
// Added to each delay so open tabs never settle into polling on the same second.
99
export const UNREAD_POLL_JITTER_MS = 15_000;
1010

11+
/**
12+
* Which of the feed's wakes this tab should toast. The feed is recent deliveries, not
13+
* unread ones, and the local memory of what was toasted is per browser — so `unread` is the
14+
* only signal shared across machines that a wake has already been seen. A wake landing in
15+
* an open chat stays unread until that chat's next read, so it still toasts.
16+
*/
17+
export function wakesToToast<T extends { watchId: string; unread?: boolean }>(
18+
wakes: T[] | undefined,
19+
toasted: ReadonlySet<string>
20+
): T[] {
21+
return (wakes ?? []).filter((wake) => wake.unread === true && !toasted.has(wake.watchId));
22+
}
23+
1124
export type WakePollOptions = {
1225
load: () => Promise<void>;
1326
isHidden: () => boolean;

0 commit comments

Comments
 (0)