Skip to content

Commit d26a28b

Browse files
committed
perf(webapp): only poll the wake feed for a browser that knows a watch exists
1 parent e109a24 commit d26a28b

4 files changed

Lines changed: 185 additions & 6 deletions

File tree

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
writeAgentFullscreen,
2121
} from "./panel-layout";
2222
import { startWakePolling } from "./wake-poll";
23+
import { hasWatchActivity, subscribeWatchActivity } from "./watch-activity";
2324
import {
2425
showWatchWakesSummaryToast,
2526
showWatchWakeToast,
@@ -136,8 +137,18 @@ export function DashboardAgent({
136137
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
137138
}, []);
138139

140+
// Nothing to be woken about means nothing to poll for. Once this browser has seen a watch it
141+
// keeps polling, so a wake still reaches a tab that was open before the watch existed.
142+
const [watching, setWatching] = useState(false);
139143
useEffect(() => {
140-
if (!hasAccess) return;
144+
if (hasWatchActivity(organization.id)) setWatching(true);
145+
return subscribeWatchActivity(() => {
146+
if (hasWatchActivity(organization.id)) setWatching(true);
147+
});
148+
}, [organization.id]);
149+
150+
useEffect(() => {
151+
if (!hasAccess || !watching) return;
141152

142153
let cancelled = false;
143154
const load = async () => {
@@ -183,7 +194,7 @@ export function DashboardAgent({
183194
cancelled = true;
184195
stop();
185196
};
186-
}, [hasAccess, actionPath, setPanelOpen, openChat]);
197+
}, [hasAccess, watching, actionPath, setPanelOpen, openChat]);
187198

188199
// Zeroes the dot right away; the poll restores the truth if another chat has one.
189200
const markChatRead = useCallback(

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { DashboardAgentDraft } from "./DashboardAgentDraft";
1919
import { WatchCard } from "./WatchCard";
2020
import { watchDraftFor } from "./watch-card";
21+
import { forgetWatchActivity, rememberWatchActivity } from "./watch-activity";
2122
import type { TurnActivity } from "./DashboardAgentMessages";
2223
import { DashboardAgentHeader } from "./DashboardAgentHeader";
2324
import type { DashboardAgentChat as DashboardAgentChatListItem } from "./DashboardAgentHistory";
@@ -146,10 +147,14 @@ export function DashboardAgentPanel({
146147
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
147148
const read = justRead.current;
148149
justRead.current = new Set();
150+
const chats = data.chats ?? [];
151+
// Reloaded after every turn and after a watch is created, so this is where the browser
152+
// learns whether the wake feed is worth polling.
153+
const pending = chats.some((chat) => chat.hasActiveWatch || chat.hasUnreadWake);
154+
if (pending) rememberWatchActivity(organization.id);
155+
else forgetWatchActivity(organization.id);
149156
setChats(
150-
(data.chats ?? []).map((chat) =>
151-
read.has(chat.id) ? { ...chat, hasUnreadWake: false } : chat
152-
)
157+
chats.map((chat) => (read.has(chat.id) ? { ...chat, hasUnreadWake: false } : chat))
153158
);
154159
} catch (error) {
155160
console.error("Dashboard agent: failed to load chat history", error);
@@ -160,7 +165,7 @@ export function DashboardAgentPanel({
160165
})();
161166
historyInFlight.current = request;
162167
return request;
163-
}, [actionPath, toast]);
168+
}, [actionPath, organization.id, toast]);
164169

165170
// Bumped on each open so a slower earlier open can't overwrite a newer one.
166171
const openChatRequestSeq = useRef(0);
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
type StorageListener = (event: { key: string | null }) => void;
4+
5+
const store = new Map<string, string>();
6+
const storageListeners = new Set<StorageListener>();
7+
8+
// A minimal `window`: these tests run without a DOM.
9+
const windowStub = {
10+
localStorage: {
11+
getItem: (key: string) => store.get(key) ?? null,
12+
setItem: (key: string, value: string) => void store.set(key, value),
13+
},
14+
addEventListener: (_type: string, listener: StorageListener) =>
15+
void storageListeners.add(listener),
16+
removeEventListener: (_type: string, listener: StorageListener) =>
17+
void storageListeners.delete(listener),
18+
};
19+
20+
const { forgetWatchActivity, hasWatchActivity, rememberWatchActivity, subscribeWatchActivity } =
21+
await import("./watch-activity");
22+
23+
/** What another tab writing the key looks like here. */
24+
function otherTabWrote(organizationId: string) {
25+
store.set("tdev:dashboard-agent:watching", JSON.stringify([organizationId]));
26+
for (const listener of storageListeners) listener({ key: "tdev:dashboard-agent:watching" });
27+
}
28+
29+
describe("watch activity", () => {
30+
beforeEach(() => {
31+
store.clear();
32+
storageListeners.clear();
33+
vi.stubGlobal("window", windowStub);
34+
});
35+
afterEach(() => {
36+
vi.unstubAllGlobals();
37+
});
38+
39+
it("knows nothing until a watch shows up", () => {
40+
expect(hasWatchActivity("org_1")).toBe(false);
41+
42+
rememberWatchActivity("org_1");
43+
expect(hasWatchActivity("org_1")).toBe(true);
44+
expect(hasWatchActivity("org_2")).toBe(false);
45+
});
46+
47+
it("survives a reload", () => {
48+
rememberWatchActivity("org_1");
49+
storageListeners.clear();
50+
51+
expect(hasWatchActivity("org_1")).toBe(true);
52+
});
53+
54+
it("tells a tab that was already open", () => {
55+
const woken: number[] = [];
56+
const unsubscribe = subscribeWatchActivity(() => woken.push(1));
57+
58+
rememberWatchActivity("org_1");
59+
expect(woken).toHaveLength(1);
60+
61+
unsubscribe();
62+
rememberWatchActivity("org_2");
63+
expect(woken).toHaveLength(1);
64+
});
65+
66+
it("tells a tab about a watch another tab created", () => {
67+
const woken: number[] = [];
68+
const unsubscribe = subscribeWatchActivity(() => woken.push(1));
69+
70+
otherTabWrote("org_1");
71+
72+
expect(woken).toHaveLength(1);
73+
expect(hasWatchActivity("org_1")).toBe(true);
74+
unsubscribe();
75+
});
76+
77+
it("forgets one organization without forgetting the others", () => {
78+
rememberWatchActivity("org_1");
79+
rememberWatchActivity("org_2");
80+
81+
forgetWatchActivity("org_1");
82+
83+
expect(hasWatchActivity("org_1")).toBe(false);
84+
expect(hasWatchActivity("org_2")).toBe(true);
85+
});
86+
87+
it("remembers at most ten organizations", () => {
88+
for (let index = 0; index < 12; index++) rememberWatchActivity(`org_${index}`);
89+
90+
expect(hasWatchActivity("org_0")).toBe(false);
91+
expect(hasWatchActivity("org_11")).toBe(true);
92+
});
93+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Which organizations this browser has seen agent watches in. The wake feed costs a request per
3+
* open tab per minute, so only a browser that knows a watch exists polls it — and once it knows,
4+
* it keeps polling. Shared through `localStorage`, so a watch created in one tab wakes the others.
5+
*/
6+
7+
const STORAGE_KEY = "tdev:dashboard-agent:watching";
8+
9+
// Newest ids only, so the key can't grow unbounded.
10+
const MAX_REMEMBERED = 10;
11+
12+
const listeners = new Set<() => void>();
13+
14+
function read(): string[] {
15+
try {
16+
const raw = window.localStorage.getItem(STORAGE_KEY);
17+
return raw ? (JSON.parse(raw) as string[]) : [];
18+
} catch {
19+
// Storage unavailable; treated as "nothing known yet".
20+
return [];
21+
}
22+
}
23+
24+
export function hasWatchActivity(organizationId: string): boolean {
25+
if (typeof window === "undefined") return false;
26+
return read().includes(organizationId);
27+
}
28+
29+
/** Called whenever a watch shows up for this org: the poll starts from here. */
30+
export function rememberWatchActivity(organizationId: string): void {
31+
if (typeof window === "undefined" || hasWatchActivity(organizationId)) return;
32+
try {
33+
window.localStorage.setItem(
34+
STORAGE_KEY,
35+
JSON.stringify([...read(), organizationId].slice(-MAX_REMEMBERED))
36+
);
37+
} catch {
38+
// Same as the read. This tab still starts polling for the rest of the session.
39+
}
40+
for (const listener of listeners) listener();
41+
}
42+
43+
/**
44+
* Called when nothing is left to be woken about. The current tab keeps polling for the rest of
45+
* the session; the next reload starts quiet.
46+
*/
47+
export function forgetWatchActivity(organizationId: string): void {
48+
if (typeof window === "undefined" || !hasWatchActivity(organizationId)) return;
49+
try {
50+
window.localStorage.setItem(
51+
STORAGE_KEY,
52+
JSON.stringify(read().filter((id) => id !== organizationId))
53+
);
54+
} catch {
55+
// Same as the read.
56+
}
57+
}
58+
59+
/** Fires when this browser learns of a watch, in this tab or — via `storage` — in another one. */
60+
export function subscribeWatchActivity(listener: () => void): () => void {
61+
listeners.add(listener);
62+
const onStorage = (event: StorageEvent) => {
63+
if (event.key === null || event.key === STORAGE_KEY) listener();
64+
};
65+
window.addEventListener("storage", onStorage);
66+
return () => {
67+
listeners.delete(listener);
68+
window.removeEventListener("storage", onStorage);
69+
};
70+
}

0 commit comments

Comments
 (0)