Skip to content

Commit f15a823

Browse files
committed
fix(webapp): show the cumulative watch-result count in the grouped toast
1 parent 8d830a0 commit f15a823

4 files changed

Lines changed: 97 additions & 7 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
The grouped "watch updates" notification now shows the total number of results waiting, instead of only the most recent batch's count.

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

Lines changed: 22 additions & 6 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, wakesToToast } from "./wake-poll";
26+
import { planWakeToasts, startWakePolling, wakesToToast } from "./wake-poll";
2727
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2828
import {
2929
showWatchWakesSummaryToast,
@@ -99,6 +99,11 @@ export function DashboardAgent({
9999
// which outlives the render that started it, so it has to be a ref.
100100
const visibleChat = useRef<string | null>(null);
101101

102+
// The count the still-visible grouped toast claims. Consecutive polls add to it so a
103+
// later batch grows the summary instead of overwriting it with only its own count;
104+
// reset when the user opens the panel from that toast.
105+
const summaryPending = useRef(0);
106+
102107
// Switching environment re-runs the layout loader but does not remount it, so the seeds
103108
// above would keep the old environment's counts.
104109
const seededEnvironment = useRef(environment.id);
@@ -224,11 +229,22 @@ export function DashboardAgent({
224229
const fresh = wakesToToast(data.wakes, toastedWakes.current);
225230
for (const wake of fresh) rememberToasted(wake.watchId);
226231

227-
if (fresh.length > WAKE_TOAST_MAX_INDIVIDUAL) {
228-
showWatchWakesSummaryToast(fresh.length, () => setPanelOpen(true));
229-
} else {
230-
for (const wake of [...fresh].reverse()) {
231-
showWatchWakeToast(wake, openChat);
232+
if (fresh.length > 0) {
233+
const { plan, pending } = planWakeToasts(
234+
fresh,
235+
summaryPending.current,
236+
WAKE_TOAST_MAX_INDIVIDUAL
237+
);
238+
summaryPending.current = pending;
239+
if (plan.mode === "summary") {
240+
showWatchWakesSummaryToast(plan.count, () => {
241+
summaryPending.current = 0;
242+
setPanelOpen(true);
243+
});
244+
} else {
245+
for (const wake of [...plan.wakes].reverse()) {
246+
showWatchWakeToast(wake, openChat);
247+
}
232248
}
233249
}
234250
} catch {

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

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2-
import { startWakePolling, UNREAD_POLL_INTERVAL_MS, wakesToToast } from "./wake-poll";
2+
import {
3+
planWakeToasts,
4+
startWakePolling,
5+
UNREAD_POLL_INTERVAL_MS,
6+
wakesToToast,
7+
} from "./wake-poll";
38

49
function harness() {
510
let hidden = false;
@@ -124,3 +129,42 @@ describe("wakesToToast", () => {
124129
expect(wakesToToast(undefined, new Set())).toEqual([]);
125130
});
126131
});
132+
133+
describe("planWakeToasts", () => {
134+
const MAX = 3;
135+
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
136+
137+
it("toasts a small batch individually but still counts it toward the running total", () => {
138+
const { plan, pending } = planWakeToasts(batch(2), 0, MAX);
139+
140+
expect(plan).toEqual({ mode: "individual", wakes: [0, 1] });
141+
expect(pending).toBe(2);
142+
});
143+
144+
it("summarizes when a single batch is over the max", () => {
145+
const { plan, pending } = planWakeToasts(batch(4), 0, MAX);
146+
147+
expect(plan).toEqual({ mode: "summary", count: 4 });
148+
expect(pending).toBe(4);
149+
});
150+
151+
it("accumulates across consecutive polls instead of showing only the latest", () => {
152+
// First batch of 2 is below the max: individual toasts, nothing pending yet.
153+
const first = planWakeToasts(batch(2), 0, MAX);
154+
expect(first.plan.mode).toBe("individual");
155+
156+
// A second batch of 3 pushes the running total to 5, so the grouped toast claims
157+
// the cumulative count, not just this batch's 3.
158+
const second = planWakeToasts(batch(3), first.pending, MAX);
159+
expect(second.plan).toEqual({ mode: "summary", count: 5 });
160+
expect(second.pending).toBe(5);
161+
});
162+
163+
it("grows the visible summary as later batches arrive", () => {
164+
const first = planWakeToasts(batch(4), 0, MAX);
165+
const second = planWakeToasts(batch(3), first.pending, MAX);
166+
167+
expect(second.plan).toEqual({ mode: "summary", count: 7 });
168+
expect(second.pending).toBe(7);
169+
});
170+
});

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@ export function wakesToToast<T extends { watchId: string; unread?: boolean }>(
2121
return (wakes ?? []).filter((wake) => wake.unread === true && !toasted.has(wake.watchId));
2222
}
2323

24+
export type WakeToastPlan<T> =
25+
| { mode: "summary"; count: number }
26+
| { mode: "individual"; wakes: T[] };
27+
28+
/**
29+
* Whether this poll's fresh wakes join a grouped summary or each get their own toast.
30+
* `pending` is the running count of unacknowledged wakes carried from earlier polls; a
31+
* batch that pushes the total past `max` shows the summary with that cumulative count, so
32+
* a later batch adds to it rather than replacing it with only its own, smaller number.
33+
* The returned `pending` is what the caller carries into the next poll; it resets to zero
34+
* once the user acknowledges (opens the panel).
35+
*/
36+
export function planWakeToasts<T>(
37+
fresh: T[],
38+
pending: number,
39+
max: number
40+
): { plan: WakeToastPlan<T>; pending: number } {
41+
const total = pending + fresh.length;
42+
if (total > max) {
43+
return { plan: { mode: "summary", count: total }, pending: total };
44+
}
45+
return { plan: { mode: "individual", wakes: fresh }, pending: total };
46+
}
47+
2448
export type WakePollOptions = {
2549
load: () => Promise<void>;
2650
isHidden: () => boolean;

0 commit comments

Comments
 (0)