Skip to content

Commit a0d06e4

Browse files
committed
fix(webapp): send the agent wake signal to a browser that has never opened the panel
1 parent 18dffa6 commit a0d06e4

4 files changed

Lines changed: 81 additions & 15 deletions

File tree

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

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
writeAgentFullscreen,
2121
} from "./panel-layout";
2222
import { startWakePolling } from "./wake-poll";
23-
import { hasWatchActivity, subscribeWatchActivity } from "./watch-activity";
23+
import { shouldPollWakeFeed, subscribeWatchActivity } from "./watch-activity";
2424
import {
2525
showWatchWakesSummaryToast,
2626
showWatchWakeToast,
@@ -38,18 +38,22 @@ export function DashboardAgent({
3838
children,
3939
hasAccess = false,
4040
promotedPrompt,
41+
/** From the page load: unread wakes waiting for this user, whatever this browser remembers. */
42+
initialUnreadWakes = 0,
4143
}: {
4244
children: React.ReactNode;
4345
hasAccess?: boolean;
4446
promotedPrompt?: SuggestedPrompt;
47+
initialUnreadWakes?: number;
4548
}) {
4649
const organization = useOrganization();
4750
const project = useProject();
4851
const environment = useEnvironment();
4952
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
5053

5154
const [open, setOpen] = useState(false);
52-
const [unreadWakes, setUnreadWakes] = useState(0);
55+
// Seeded from the page load, so the launcher dot is right before the first poll answers.
56+
const [unreadWakes, setUnreadWakes] = useState(initialUnreadWakes);
5357
const toastedWakes = useRef(new Set<string>());
5458
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
5559
useEffect(() => {
@@ -137,15 +141,23 @@ export function DashboardAgent({
137141
setWatchRequest((current) => ({ spec, seq: (current?.seq ?? 0) + 1 }));
138142
}, []);
139143

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.
144+
// Nothing to be woken about means nothing to poll for. The page load's unread count is the
145+
// ungated signal; the browser's own memory of a watch starts the poll without a reload. Once
146+
// either says yes this tab keeps polling, so a wake reaches a tab open before the watch existed.
142147
const [watching, setWatching] = useState(false);
143148
useEffect(() => {
144-
if (hasWatchActivity(organization.id)) setWatching(true);
145-
return subscribeWatchActivity(() => {
146-
if (hasWatchActivity(organization.id)) setWatching(true);
147-
});
148-
}, [organization.id]);
149+
const sync = () => {
150+
if (
151+
shouldPollWakeFeed({
152+
serverUnreadWakes: initialUnreadWakes,
153+
organizationId: organization.id,
154+
})
155+
)
156+
setWatching(true);
157+
};
158+
sync();
159+
return subscribeWatchActivity(sync);
160+
}, [organization.id, initialUnreadWakes]);
149161

150162
useEffect(() => {
151163
if (!hasAccess || !watching) return;

apps/webapp/app/components/dashboard-agent/watch-activity.test.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ const windowStub = {
1717
void storageListeners.delete(listener),
1818
};
1919

20-
const { forgetWatchActivity, hasWatchActivity, rememberWatchActivity, subscribeWatchActivity } =
21-
await import("./watch-activity");
20+
const {
21+
forgetWatchActivity,
22+
hasWatchActivity,
23+
rememberWatchActivity,
24+
shouldPollWakeFeed,
25+
subscribeWatchActivity,
26+
} = await import("./watch-activity");
2227

2328
/** What another tab writing the key looks like here. */
2429
function otherTabWrote(organizationId: string) {
@@ -90,4 +95,21 @@ describe("watch activity", () => {
9095
expect(hasWatchActivity("org_0")).toBe(false);
9196
expect(hasWatchActivity("org_11")).toBe(true);
9297
});
98+
99+
describe("shouldPollWakeFeed", () => {
100+
it("polls in a fresh browser the page load says has an unread wake", () => {
101+
expect(shouldPollWakeFeed({ serverUnreadWakes: 1, organizationId: "org_1" })).toBe(true);
102+
});
103+
104+
it("stays quiet when neither the page load nor this browser knows of anything", () => {
105+
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(false);
106+
});
107+
108+
it("polls without a reload once this browser sees a watch", () => {
109+
rememberWatchActivity("org_1");
110+
111+
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_1" })).toBe(true);
112+
expect(shouldPollWakeFeed({ serverUnreadWakes: 0, organizationId: "org_2" })).toBe(false);
113+
});
114+
});
93115
});

apps/webapp/app/components/dashboard-agent/watch-activity.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/**
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.
2+
* Which organizations this browser has seen agent watches in. This is an accelerator, not the
3+
* gate: a watch created in this tab starts the poll without a reload. The ungated signal is the
4+
* unread count the page load carries — see {@link shouldPollWakeFeed}.
5+
* Shared through `localStorage`, so a watch created in one tab wakes the others.
56
*/
67

78
const STORAGE_KEY = "tdev:dashboard-agent:watching";
@@ -56,6 +57,17 @@ export function forgetWatchActivity(organizationId: string): void {
5657
}
5758
}
5859

60+
/**
61+
* Whether this browser should poll the wake feed. `serverUnreadWakes` comes from the page load,
62+
* so a fresh browser with an unread wake polls without ever opening the panel.
63+
*/
64+
export function shouldPollWakeFeed(params: {
65+
serverUnreadWakes: number;
66+
organizationId: string;
67+
}): boolean {
68+
return params.serverUnreadWakes > 0 || hasWatchActivity(params.organizationId);
69+
}
70+
5971
/** Fires when this browser learns of a watch, in this tab or — via `storage` — in another one. */
6072
export function subscribeWatchActivity(listener: () => void): () => void {
6173
listeners.add(listener);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { countUnreadWatchWakes } from "@internal/dashboard-agent-db";
12
import { Outlet, useLoaderData } from "@remix-run/react";
23
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
34
import { RouteErrorDisplay } from "~/components/ErrorDisplay";
45
import { DashboardAgent } from "~/components/dashboard-agent/DashboardAgent";
56
import { prisma } from "~/db.server";
7+
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
68
import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server";
79
import { logger } from "~/services/logger.server";
810
import { hasAdminDisplayAccess, requireUser } from "~/services/session.server";
@@ -96,19 +98,37 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
9698
})
9799
: null;
98100

101+
// One narrow read per page load, so the wake signal reaches a browser that has never opened
102+
// the panel. The poll never asks for this — it costs nothing per tick.
103+
let dashboardAgentUnreadWakes = 0;
104+
if (hasDashboardAgentAccess) {
105+
try {
106+
dashboardAgentUnreadWakes = await countUnreadWatchWakes(dashboardAgentDb, {
107+
organizationId: project.organization.id,
108+
userId: user.id,
109+
});
110+
} catch (error) {
111+
// The dashboard must load even when the agent's store doesn't answer.
112+
logger.error("Failed to count dashboard agent wakes", { error });
113+
}
114+
}
115+
99116
return {
100117
...project,
101118
hasDashboardAgentAccess,
102119
promotedDashboardAgentPrompt,
120+
dashboardAgentUnreadWakes,
103121
};
104122
};
105123

106124
export default function Page() {
107-
const { hasDashboardAgentAccess, promotedDashboardAgentPrompt } = useLoaderData<typeof loader>();
125+
const { hasDashboardAgentAccess, promotedDashboardAgentPrompt, dashboardAgentUnreadWakes } =
126+
useLoaderData<typeof loader>();
108127
return (
109128
<DashboardAgent
110129
hasAccess={hasDashboardAgentAccess}
111130
promotedPrompt={promotedDashboardAgentPrompt ?? undefined}
131+
initialUnreadWakes={dashboardAgentUnreadWakes}
112132
>
113133
<Outlet />
114134
</DashboardAgent>

0 commit comments

Comments
 (0)