Skip to content

Commit 6b26b28

Browse files
committed
fix(webapp): stop the grouped watch-updates toast counting wakes the user already opened
1 parent 036b3e5 commit 6b26b28

4 files changed

Lines changed: 118 additions & 36 deletions

File tree

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

Lines changed: 49 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ import {
2323
} from "./panel-layout";
2424
import { nextPendingTurnChatId } from "./pending-turn";
2525
import { nextVisibleChat } from "./unread-counts";
26-
import { planWakeToasts, startWakePolling, wakesToToast } from "./wake-poll";
26+
import { createWakePendingCount, startWakePolling, wakesToToast } from "./wake-poll";
2727
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2828
import {
29+
dismissWatchWakesSummaryToast,
2930
showWatchWakesSummaryToast,
3031
showWatchWakeToast,
3132
WAKE_TOAST_MAX_INDIVIDUAL,
@@ -101,8 +102,8 @@ export function DashboardAgent({
101102

102103
// The count the still-visible grouped toast claims. Consecutive polls add to it so a
103104
// 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);
105+
// reset when the user opens the panel, whichever route they took.
106+
const wakePending = useRef(createWakePendingCount());
106107

107108
// Switching environment re-runs the layout loader but does not remount it, so the seeds
108109
// above would keep the old environment's counts.
@@ -146,35 +147,58 @@ export function DashboardAgent({
146147
undefined
147148
);
148149

149-
const setPanelOpen = useCallback((next: boolean) => {
150-
setOpen(next);
151-
// Pending requests must be dropped or a stale one re-applies on the next open.
152-
if (!next) {
150+
// The single entry point for opening the panel — every open route must go through it.
151+
// Opening acknowledges the wakes counted so far, and the visible summary goes with the
152+
// count it was claiming.
153+
const openPanel = useCallback(() => {
154+
wakePending.current.acknowledge();
155+
dismissWatchWakesSummaryToast();
156+
setOpen(true);
157+
}, []);
158+
159+
const setPanelOpen = useCallback(
160+
(next: boolean) => {
161+
if (next) {
162+
openPanel();
163+
return;
164+
}
165+
setOpen(false);
166+
// Pending requests must be dropped or a stale one re-applies on the next open.
153167
visibleChat.current = null;
154168
setFullscreen(false);
155169
writeAgentFullscreen(false);
156170
setRequestedMessage(undefined);
157171
setOpenChatRequest(undefined);
158172
setWatchRequest(undefined);
159-
}
160-
}, []);
173+
},
174+
[openPanel]
175+
);
161176

162-
const openChat = useCallback((chatId: string) => {
163-
setOpen(true);
164-
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
165-
}, []);
177+
const openChat = useCallback(
178+
(chatId: string) => {
179+
openPanel();
180+
setOpenChatRequest((current) => ({ chatId, seq: (current?.seq ?? 0) + 1 }));
181+
},
182+
[openPanel]
183+
);
166184

167-
const openWith = useCallback((text: string) => {
168-
const trimmed = text.trim();
169-
if (!trimmed) return;
170-
setOpen(true);
171-
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
172-
}, []);
185+
const openWith = useCallback(
186+
(text: string) => {
187+
const trimmed = text.trim();
188+
if (!trimmed) return;
189+
openPanel();
190+
setRequestedMessage((current) => ({ text: trimmed, seq: (current?.seq ?? 0) + 1 }));
191+
},
192+
[openPanel]
193+
);
173194

174-
const openWithWatch = useCallback((spec: WatchSpec) => {
175-
setOpen(true);
176-
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
177-
}, []);
195+
const openWithWatch = useCallback(
196+
(spec: WatchSpec) => {
197+
openPanel();
198+
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
199+
},
200+
[openPanel]
201+
);
178202

179203
// Nothing to be woken about means nothing to poll for. The page load's unread count and
180204
// active-watch flag are the ungated signals; the browser's own memory of a watch starts the
@@ -230,17 +254,9 @@ export function DashboardAgent({
230254
for (const wake of fresh) rememberToasted(wake.watchId);
231255

232256
if (fresh.length > 0) {
233-
const { plan, pending } = planWakeToasts(
234-
fresh,
235-
summaryPending.current,
236-
WAKE_TOAST_MAX_INDIVIDUAL
237-
);
238-
summaryPending.current = pending;
257+
const plan = wakePending.current.plan(fresh, WAKE_TOAST_MAX_INDIVIDUAL);
239258
if (plan.mode === "summary") {
240-
showWatchWakesSummaryToast(plan.count, () => {
241-
summaryPending.current = 0;
242-
setPanelOpen(true);
243-
});
259+
showWatchWakesSummaryToast(plan.count, () => setPanelOpen(true));
244260
} else {
245261
for (const wake of [...plan.wakes].reverse()) {
246262
showWatchWakeToast(wake, openChat);

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export function showWatchWakeToast(wake: WatchWake, onOpenChat: (chatId: string)
115115
);
116116
}
117117

118+
// One id for all summaries: a later poll rewrites the count in place instead of stacking a
119+
// second never-expiring toast on top of the first.
120+
const WAKES_SUMMARY_TOAST_ID = "watch-wakes-summary";
121+
118122
/** One persistent toast standing in for a batch too large to narrate one by one. */
119123
export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void) {
120124
show(
@@ -126,8 +130,14 @@ export function showWatchWakesSummaryToast(count: number, onOpenChat: () => void
126130
onOpenChat={onOpenChat}
127131
/>
128132
),
129-
// One id for all summaries: a later poll rewrites the count in place instead
130-
// of stacking a second never-expiring toast on top of the first.
131-
"watch-wakes-summary"
133+
WAKES_SUMMARY_TOAST_ID
132134
);
133135
}
136+
137+
/**
138+
* Takes the summary off screen. Its count only means anything until the user opens the
139+
* panel; left up, a later poll would rewrite it to a smaller number.
140+
*/
141+
export function dismissWatchWakesSummaryToast() {
142+
toast.dismiss(WAKES_SUMMARY_TOAST_ID);
143+
}

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
3+
createWakePendingCount,
34
planWakeToasts,
45
startWakePolling,
56
UNREAD_POLL_INTERVAL_MS,
@@ -168,3 +169,37 @@ describe("planWakeToasts", () => {
168169
expect(second.pending).toBe(7);
169170
});
170171
});
172+
173+
describe("createWakePendingCount", () => {
174+
const MAX = 3;
175+
const batch = (n: number) => Array.from({ length: n }, (_, i) => i);
176+
177+
it("carries unacknowledged wakes into the summary", () => {
178+
const count = createWakePendingCount();
179+
180+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
181+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
182+
});
183+
184+
it("does not count wakes the user already opened", () => {
185+
const count = createWakePendingCount();
186+
187+
// Two individual toasts, both opened — from the toast, ⌘J, anywhere.
188+
expect(count.plan(batch(2), MAX).mode).toBe("individual");
189+
count.acknowledge();
190+
191+
// Only the two new wakes are waiting, so they toast individually rather than
192+
// claiming "4 watch updates".
193+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "individual", wakes: [0, 1] });
194+
});
195+
196+
it("starts the next summary from the wakes that arrived after the open", () => {
197+
const count = createWakePendingCount();
198+
199+
expect(count.plan(batch(4), MAX)).toEqual({ mode: "summary", count: 4 });
200+
count.acknowledge();
201+
202+
expect(count.plan(batch(2), MAX).mode).toBe("individual");
203+
expect(count.plan(batch(2), MAX)).toEqual({ mode: "summary", count: 4 });
204+
});
205+
});

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ export function planWakeToasts<T>(
4545
return { plan: { mode: "individual", wakes: fresh }, pending: total };
4646
}
4747

48+
/**
49+
* The running pending count, owned by one holder so the poll and the panel cannot drift.
50+
* Every wake counts until the user opens the panel — by whichever route, including a single
51+
* wake toast — and opening it clears the count so a later grouped toast claims only wakes
52+
* still waiting.
53+
*/
54+
export function createWakePendingCount() {
55+
let pending = 0;
56+
57+
return {
58+
plan<T>(fresh: T[], max: number): WakeToastPlan<T> {
59+
const result = planWakeToasts(fresh, pending, max);
60+
pending = result.pending;
61+
return result.plan;
62+
},
63+
acknowledge() {
64+
pending = 0;
65+
},
66+
};
67+
}
68+
4869
export type WakePollOptions = {
4970
load: () => Promise<void>;
5071
isHidden: () => boolean;

0 commit comments

Comments
 (0)