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>
|
||||
</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="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
|
||||
@@ -169,6 +169,15 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
@@ -260,6 +269,21 @@ export const usePermissionStore = create<PermissionStore>()(
|
||||
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" }
|
||||
|
||||
@@ -646,6 +646,7 @@ const notificationTriggerRuntime = createNotificationTriggerRuntime({
|
||||
});
|
||||
|
||||
const maybeSendPushForTrigger = (...args) => notificationTriggerRuntime.maybeSendPushForTrigger(...args);
|
||||
const setAutoAcceptSession = (...args) => notificationTriggerRuntime.setAutoAcceptSession(...args);
|
||||
|
||||
const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({
|
||||
waitForOpenCodePort: (...args) => waitForOpenCodePort(...args),
|
||||
@@ -1098,6 +1099,7 @@ async function main(options = {}) {
|
||||
modelsMetadataCacheTtl: MODELS_METADATA_CACHE_TTL,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
setAutoAcceptSession,
|
||||
});
|
||||
uiAuthController = bootstrapResult.uiAuthController;
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
markSessionUnviewed,
|
||||
markUserMessageSent,
|
||||
setPushInitialized,
|
||||
setAutoAcceptSession,
|
||||
} = dependencies;
|
||||
|
||||
const ensureSessionWatcher = async () => {
|
||||
@@ -294,4 +295,20 @@ export const registerNotificationRoutes = (app, dependencies) => {
|
||||
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 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) => {
|
||||
if (!sessionId || typeof sessionId !== 'string') {
|
||||
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) => {
|
||||
if (!payload || typeof payload !== 'object') return null;
|
||||
const props = payload.properties;
|
||||
@@ -390,6 +421,14 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
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);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer.timer);
|
||||
@@ -473,5 +512,6 @@ export const createNotificationTriggerRuntime = (deps) => {
|
||||
|
||||
return {
|
||||
maybeSendPushForTrigger,
|
||||
setAutoAcceptSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -47,6 +47,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
modelsMetadataCacheTtl,
|
||||
fetchFreeZenModels,
|
||||
getCachedZenModels,
|
||||
setAutoAcceptSession,
|
||||
} = options;
|
||||
|
||||
registerServerStatusRoutes(app, {
|
||||
@@ -100,6 +101,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
||||
markSessionUnviewed: sessionRuntime.markSessionUnviewed,
|
||||
markUserMessageSent: sessionRuntime.markUserMessageSent,
|
||||
setPushInitialized,
|
||||
setAutoAcceptSession,
|
||||
});
|
||||
|
||||
registerOpenChamberRoutes(app, {
|
||||
|
||||
@@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
||||
req.path.startsWith('/api/terminal') ||
|
||||
req.path.startsWith('/api/opencode') ||
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/notifications') ||
|
||||
req.path.startsWith('/api/text') ||
|
||||
req.path.startsWith('/api/voice') ||
|
||||
req.path.startsWith('/api/tts') ||
|
||||
|
||||
Reference in New Issue
Block a user