feat(push): smart push queueing with page-visibility fast path and app-ping/pong fallback

This commit is contained in:
2026-06-04 22:10:48 -04:00
parent fc0527e9e7
commit 4e6dfb4726
16 changed files with 192 additions and 60 deletions
+68 -12
View File
@@ -16,14 +16,12 @@ interface PushOptions {
tagPrefix: string;
}
/** Send a push notification for a session event — only if nobody is viewing this session. */
function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
const clients = sessionClients.get(sessionId);
if (clients && clients.size > 0) return;
// Skip push for child review sessions
if (sessionReviews.getAllChildIds().has(sessionId)) return;
/** Pending push timers: sessionId → timeout handle. Cancelled if client pongs within 2s. */
const pendingPushes = new Map<string, ReturnType<typeof setTimeout>>();
/** Actually send the push notification. */
function firePush(adapter: IAdapter, sessionId: string, { title, body, tagPrefix }: PushOptions): void {
console.log(`[push] firePush: "${title}" for session ${sessionId.slice(0, 8)}`);
const session = adapter.getSession(sessionId) as { cwd?: string } | null;
const projectName = basename(session?.cwd || '') || 'Unknown';
const badge = incrementPending(sessionId);
@@ -35,6 +33,49 @@ function triggerPush(adapter: IAdapter, sessionId: string, { title, body, tagPre
}).catch((err: Error) => console.error('[push]', err.message));
}
/**
* Queue a push notification for a session event.
*
* Fast path — no clients, or all clients reported hidden via page-visibility:
* send immediately.
* Fallback — some client is visible or visibility unknown:
* broadcast an app-ping; if any client JS responds with app-pong within 2s
* the notification is dropped (user is watching). Otherwise send after 2s.
*/
function queuePush(adapter: IAdapter, sessionId: string, opts: PushOptions): void {
// Skip push for child review sessions
if (sessionReviews.getAllChildIds().has(sessionId)) return;
// Cancel any pre-existing pending push for this session
const existing = pendingPushes.get(sessionId);
if (existing) clearTimeout(existing);
const clients = sessionClients.get(sessionId);
// No clients connected at all — send immediately
if (!clients || clients.size === 0) {
console.log(`[push] queuePush: no clients → immediate push`);
firePush(adapter, sessionId, opts);
return;
}
// All clients already reported page hidden — send immediately (fast path)
if ([...clients].every(c => !c.pageVisible)) {
console.log(`[push] queuePush: all clients hidden → immediate push`);
firePush(adapter, sessionId, opts);
return;
}
// Some client may be visible — ping JS and wait up to 2s for a pong response
console.log(`[push] queuePush: client(s) may be visible (pageVisible=${[...clients].map(c => c.pageVisible).join(',')}) → pinging, push in 2s if no pong`);
broadcast(sessionId, { type: WS.APP_PING, sessionId });
const timer = setTimeout(() => {
pendingPushes.delete(sessionId);
firePush(adapter, sessionId, opts);
}, 2000);
pendingPushes.set(sessionId, timer);
}
/**
* SessionManager — bridges adapter events to connected clients.
*
@@ -122,7 +163,7 @@ export function setupSessionManager(): void {
setTimeout(() => {
broadcast(sessionId, { type: WS.TURN_COMPLETE, sessionId });
}, 100);
triggerPush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
queuePush(adapter, sessionId, { title: 'Claude finished', body: 'Turn complete', tagPrefix: 'idle' });
});
adapter.on('permission-request', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
@@ -141,7 +182,7 @@ export function setupSessionManager(): void {
{ value: 'deny', label: 'Deny' },
],
});
triggerPush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
queuePush(adapter, sessionId, { title: 'Permission needed', body: data.toolName || 'tool', tagPrefix: 'perm' });
});
adapter.on('ask-question', (sessionId: string, data: { requestId?: string; toolName?: string; input?: any; [key: string]: unknown }) => {
@@ -157,7 +198,7 @@ export function setupSessionManager(): void {
options: parsed.options,
textInput: parsed.options ? undefined : { placeholder: 'Enter your response...' },
});
triggerPush(adapter, sessionId, { title: 'Question', body: questionText.substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
queuePush(adapter, sessionId, { title: 'Question', body: (parsed.question || '').substring(0, 50) || 'Waiting for answer', tagPrefix: 'ask' });
});
adapter.on('interactive-prompt', (sessionId: string, prompt: any) => {
@@ -166,7 +207,7 @@ export function setupSessionManager(): void {
: prompt.promptType === 'question' ? 'Question'
: prompt.promptType === 'plan' ? 'Plan approval'
: 'Action needed';
triggerPush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
queuePush(adapter, sessionId, { title: pushTitle, body: prompt.title || '', tagPrefix: 'prompt' });
});
adapter.on('status-update', (sessionId: string, status: Record<string, unknown>) => {
@@ -205,7 +246,7 @@ export function setupSessionManager(): void {
adapter.on('session-error', (sessionId: string, data: { errorType?: string; errorDetails?: string; [key: string]: unknown }) => {
broadcast(sessionId, { type: WS.SESSION_ERROR, ...data });
triggerPush(adapter, sessionId, {
queuePush(adapter, sessionId, {
title: 'Session Error',
body: data.errorType === 'rate_limit' ? 'Rate limited' : (data.errorDetails || data.errorType || 'Unknown error'),
tagPrefix: 'error',
@@ -359,6 +400,21 @@ export async function handleIncomingMessage(conn: ClientConnection, msg: ClientM
selectedOption: msg.selectedOption as string | undefined,
textValue: msg.textValue as string | undefined,
});
case WS.PAGE_VISIBILITY: {
conn.pageVisible = !!(msg as any).visible;
return;
}
case WS.APP_PONG: {
const sid = (msg as any).sessionId as string | undefined;
if (sid) {
const timer = pendingPushes.get(sid);
if (timer) {
clearTimeout(timer);
pendingPushes.delete(sid);
}
}
return;
}
default:
conn.send({ type: 'error', error: `Unknown message type: ${msg.type}` });
}