feat(push): smart push queueing with page-visibility fast path and app-ping/pong fallback
This commit is contained in:
+68
-12
@@ -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}` });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user