Skip to content

Commit 379b5f8

Browse files
committed
feat(webapp): poll for unread work while the panel is closed
1 parent 6c04097 commit 379b5f8

3 files changed

Lines changed: 31 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
@@ -67,6 +67,9 @@ export function DashboardAgent({
6767
// Work that finished behind a closed panel. Counted server-side on page load and refreshed
6868
// with the chat list; the wake poll doesn't carry it.
6969
const [unreadWork, setUnreadWork] = useState(initialUnreadWork);
70+
// A turn this tab started may finish after the panel closes; that is exactly the case the
71+
// dot exists for, so the poll has to be running when it lands.
72+
const [turnStarted, setTurnStarted] = useState(false);
7073
const toastedWakes = useRef(new Set<string>());
7174
// The toast source is recent deliveries, not unread, so the dedupe must survive a reload.
7275
useEffect(() => {
@@ -165,14 +168,16 @@ export function DashboardAgent({
165168
shouldPollWakeFeed({
166169
serverUnreadWakes: initialUnreadWakes,
167170
serverHasActiveWatches: hasActiveWatches,
171+
serverUnreadWork: initialUnreadWork,
172+
turnInFlight: turnStarted,
168173
organizationId: organization.id,
169174
})
170175
)
171176
setWatching(true);
172177
};
173178
sync();
174179
return subscribeWatchActivity(sync);
175-
}, [organization.id, initialUnreadWakes, hasActiveWatches]);
180+
}, [organization.id, initialUnreadWakes, hasActiveWatches, initialUnreadWork, turnStarted]);
176181

177182
useEffect(() => {
178183
if (!hasAccess || !watching) return;
@@ -185,13 +190,19 @@ export function DashboardAgent({
185190
signal: AbortSignal.timeout(UNREAD_REQUEST_TIMEOUT_MS),
186191
});
187192
if (!res.ok) return;
188-
const data = (await res.json()) as { unreadWakes?: number; wakes?: WatchWake[] };
193+
const data = (await res.json()) as {
194+
unreadWakes?: number;
195+
unreadWork?: number;
196+
wakes?: WatchWake[];
197+
};
189198
if (cancelled) return;
190199
// The wakes list carries read ones too, so only unread ones are subtracted.
191200
const unreadInView = (data.wakes ?? []).filter(
192201
(wake) => wake.unread && wake.chatId === visibleChat.current
193202
).length;
194203
setUnreadWakes(Math.max(0, (data.unreadWakes ?? 0) - unreadInView));
204+
// A chat open in the panel is being read right now, so it isn't unread work.
205+
setUnreadWork(Math.max(0, (data.unreadWork ?? 0) - (open && visibleChat.current ? 1 : 0)));
195206

196207
const fresh = (data.wakes ?? []).filter((wake) => !toastedWakes.current.has(wake.watchId));
197208
for (const wake of fresh) rememberToasted(wake.watchId);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,17 @@ export function forgetWatchActivity(organizationId: string): void {
6565
export function shouldPollWakeFeed(params: {
6666
serverUnreadWakes: number;
6767
serverHasActiveWatches?: boolean;
68+
/** Chats holding work their owner hasn't seen, as the page load counted them. */
69+
serverUnreadWork?: number;
70+
/** This tab sent a turn that may still be running behind a closed panel. */
71+
turnInFlight?: boolean;
6872
organizationId: string;
6973
}): boolean {
7074
return (
7175
params.serverUnreadWakes > 0 ||
7276
params.serverHasActiveWatches === true ||
77+
(params.serverUnreadWork ?? 0) > 0 ||
78+
params.turnInFlight === true ||
7379
hasWatchActivity(params.organizationId)
7480
);
7581
}

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
cancelWatch,
44
chatExists,
55
countUnreadWatchWakes,
6+
countChatsWithUnreadWork,
67
countUserMessages,
78
createChat,
89
getChatMessages,
@@ -132,13 +133,20 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
132133
});
133134
if (!scoped) return json({ error: "Project not found" }, { status: 404 });
134135

135-
return json(
136-
await readWatchWakeFeed(dashboardAgentDb, {
136+
const [feed, unreadWork] = await Promise.all([
137+
readWatchWakeFeed(dashboardAgentDb, {
137138
organizationId: scoped.organizationId,
138139
userId,
139140
deliveredAfter: new Date(Date.now() - 15 * 60 * 1000),
140-
})
141-
);
141+
}),
142+
// The dot has two sources; the poll is where a closed panel learns about either.
143+
countChatsWithUnreadWork(dashboardAgentDb, {
144+
organizationId: scoped.organizationId,
145+
userId,
146+
}),
147+
]);
148+
149+
return json({ ...feed, unreadWork });
142150
}
143151

144152
const project = await findProjectBySlug(organizationSlug, projectParam, userId);

0 commit comments

Comments
 (0)