fix(notifications): suppress permission notifs when session auto-accepts
Server now mirrors client-side Permission Auto-Accept via
POST /api/notifications/auto-accept and short-circuits permission.asked
dispatch (walking the session parent chain). Prior 500ms debounce raced
the client auto-response and leaked notifications.
Also hint under Summarize Last Message that templates must contain
{last_message} for the setting to take effect.
This commit is contained in:
@@ -786,6 +786,11 @@ export const NotificationSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
|
<span className="typography-ui-label text-foreground">Summarize Last Message</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pl-6 pb-1">
|
||||||
|
<span className="typography-meta text-muted-foreground">
|
||||||
|
Requires <code className="text-[var(--primary-base)]">{'{last_message}'}</code> in the notification template.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
|
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
|
||||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||||
|
|||||||
@@ -169,6 +169,15 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
return { autoAccept };
|
return { autoAccept };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirror state to the server so it can suppress permission
|
||||||
|
// notifications at the source (otherwise the 500ms debounce
|
||||||
|
// races with the client's auto-response and can leak).
|
||||||
|
void fetch('/api/notifications/auto-accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId, enabled }),
|
||||||
|
}).catch(() => { /* best-effort */ });
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,6 +269,21 @@ export const usePermissionStore = create<PermissionStore>()(
|
|||||||
autoAccept: nextAutoAccept,
|
autoAccept: nextAutoAccept,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
onRehydrateStorage: () => (state) => {
|
||||||
|
if (!state) return;
|
||||||
|
// Re-broadcast auto-accept state to the server after
|
||||||
|
// rehydration so server-side notification suppression
|
||||||
|
// survives page reloads / server restarts.
|
||||||
|
for (const [sid, enabled] of Object.entries(state.autoAccept || {})) {
|
||||||
|
if (enabled === true) {
|
||||||
|
void fetch('/api/notifications/auto-accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId: sid, enabled: true }),
|
||||||
|
}).catch(() => { /* best-effort */ });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
{ name: "permission-store" }
|
{ name: "permission-store" }
|
||||||
|
|||||||
@@ -646,6 +646,7 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
||||||
|
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
|
||||||
|
|
||||||
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
||||||
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
||||||
@@ -1098,6 +1099,7 @@ async function main(options = {}) {
|
|||||||
modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL,
|
modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL,
|
||||||
fetchFreeZenModels,
|
fetchFreeZenModels,
|
||||||
getCachedZenModels,
|
getCachedZenModels,
|
||||||
|
setAutoAcceptSession,
|
||||||
});
|
});
|
||||||
uiAuthController = bootstrapResult.uiAuthController;
|
uiAuthController = bootstrapResult.uiAuthController;
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
markSessionUnviewed,
|
markSessionUnviewed,
|
||||||
markUserMessageSent,
|
markUserMessageSent,
|
||||||
setPushInitialized,
|
setPushInitialized,
|
||||||
|
setAutoAcceptSession,
|
||||||
} = dependencies;
|
} = dependencies;
|
||||||
|
|
||||||
const ensureSessionWatcher = async () => {
|
const ensureSessionWatcher = async () => {
|
||||||
@@ -294,4 +295,20 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
|||||||
messageSent: true,
|
messageSent: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirror client-side Permission Auto-Accept state to the server so it can
|
||||||
|
// suppress permission notifications at the source (the 500ms debounce race
|
||||||
|
// otherwise leaks notifications for auto-accepted permissions).
|
||||||
|
app.post('/api/notifications/auto-accept', (req, res) => {
|
||||||
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
||||||
|
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : '';
|
||||||
|
const enabled = body.enabled === true;
|
||||||
|
if (!sessionId) {
|
||||||
|
return res.status(400).json({ error: 'sessionId required' });
|
||||||
|
}
|
||||||
|
if (typeof setAutoAcceptSession === 'function') {
|
||||||
|
setAutoAcceptSession(sessionId, enabled);
|
||||||
|
}
|
||||||
|
return res.json({ success: true, sessionId, enabled });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|||||||
const sessionParentIdCache = new Map();
|
const sessionParentIdCache = new Map();
|
||||||
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
// Sessions where the client has enabled Permission Auto-Accept. Mirrored
|
||||||
|
// from the client-side permissionStore via POST /api/notifications/auto-accept
|
||||||
|
// so the server can suppress permission notifications BEFORE dispatch (the
|
||||||
|
// 500ms debounce race otherwise leaks notifications for auto-accepted
|
||||||
|
// permissions when the replied round-trip is slower than the debounce).
|
||||||
|
const autoAcceptingSessions = new Set();
|
||||||
|
const setAutoAcceptSession = (sessionId, enabled) => {
|
||||||
|
if (typeof sessionId !== 'string' || sessionId.length === 0) return;
|
||||||
|
if (enabled) {
|
||||||
|
autoAcceptingSessions.add(sessionId);
|
||||||
|
} else {
|
||||||
|
autoAcceptingSessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildSessionDeepLinkUrl = (sessionId) => {
|
const buildSessionDeepLinkUrl = (sessionId) => {
|
||||||
if (!sessionId || typeof sessionId !== 'string') {
|
if (!sessionId || typeof sessionId !== 'string') {
|
||||||
return '/';
|
return '/';
|
||||||
@@ -80,6 +95,22 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirrors client-side autoRespondsPermission: a session auto-accepts if it
|
||||||
|
// OR any ancestor is flagged. Walks the parent chain via fetchSessionParentId.
|
||||||
|
const isSessionAutoAccepting = async (sessionId) => {
|
||||||
|
if (!sessionId || autoAcceptingSessions.size === 0) return false;
|
||||||
|
let current = sessionId;
|
||||||
|
const seen = new Set();
|
||||||
|
while (current && !seen.has(current)) {
|
||||||
|
if (autoAcceptingSessions.has(current)) return true;
|
||||||
|
seen.add(current);
|
||||||
|
const parent = await fetchSessionParentId(current);
|
||||||
|
if (!parent) return false;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const extractSessionIdFromPayload = (payload) => {
|
const extractSessionIdFromPayload = (payload) => {
|
||||||
if (!payload || typeof payload !== 'object') return null;
|
if (!payload || typeof payload !== 'object') return null;
|
||||||
const props = payload.properties;
|
const props = payload.properties;
|
||||||
@@ -390,6 +421,14 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Client may be in Permission Auto-Accept for this session (or any
|
||||||
|
// ancestor). Skip the whole notification path — the client responds
|
||||||
|
// directly and the user has opted out of approval prompts.
|
||||||
|
if (await isSessionAutoAccepting(sessionId)) {
|
||||||
|
if (requestKey) notifiedPermissionRequests.add(requestKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
const existingTimer = pushPermissionDebounceTimers.get(sessionId);
|
||||||
if (existingTimer) {
|
if (existingTimer) {
|
||||||
clearTimeout(existingTimer.timer);
|
clearTimeout(existingTimer.timer);
|
||||||
@@ -473,5 +512,6 @@ export const createNotificationTriggerRuntime = (deps) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
maybeSendPushForTrigger,
|
maybeSendPushForTrigger,
|
||||||
|
setAutoAcceptSession,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
modelsMetadataCacheTtl,
|
modelsMetadataCacheTtl,
|
||||||
fetchFreeZenModels,
|
fetchFreeZenModels,
|
||||||
getCachedZenModels,
|
getCachedZenModels,
|
||||||
|
setAutoAcceptSession,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
registerServerStatusRoutes(app, {
|
registerServerStatusRoutes(app, {
|
||||||
@@ -100,6 +101,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|||||||
markSessionUnviewed: sessionRuntime.markSessionUnviewed,
|
markSessionUnviewed: sessionRuntime.markSessionUnviewed,
|
||||||
markUserMessageSent: sessionRuntime.markUserMessageSent,
|
markUserMessageSent: sessionRuntime.markUserMessageSent,
|
||||||
setPushInitialized,
|
setPushInitialized,
|
||||||
|
setAutoAcceptSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
registerOpenChamberRoutes(app, {
|
registerOpenChamberRoutes(app, {
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|||||||
req.path.startsWith('/api/terminal') ||
|
req.path.startsWith('/api/terminal') ||
|
||||||
req.path.startsWith('/api/opencode') ||
|
req.path.startsWith('/api/opencode') ||
|
||||||
req.path.startsWith('/api/push') ||
|
req.path.startsWith('/api/push') ||
|
||||||
|
req.path.startsWith('/api/notifications') ||
|
||||||
req.path.startsWith('/api/text') ||
|
req.path.startsWith('/api/text') ||
|
||||||
req.path.startsWith('/api/voice') ||
|
req.path.startsWith('/api/voice') ||
|
||||||
req.path.startsWith('/api/tts') ||
|
req.path.startsWith('/api/tts') ||
|
||||||
|
|||||||
Reference in New Issue
Block a user