fix: OS notification lifecycle — dismiss on resolve, respect snooze

- Track notifications by requestId (Map instead of Set) so they can
  be dismissed individually when the approval is resolved
- Dismiss OS notification in respondToToolApproval (via UI or action)
- Dismiss OS notification on timeout auto-resolve
- Dismiss all OS notifications on cleanupRun (team stop/exit)
- Respect snoozedUntil — skip notification if user snoozed all
This commit is contained in:
iliya 2026-03-21 13:34:03 +02:00
parent 4d65bad24f
commit 0d74619241

View file

@ -1951,7 +1951,7 @@ export class TeamProvisioningService {
private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null; private toolApprovalEventEmitter: ((event: ToolApprovalEvent) => void) | null = null;
private mainWindowRef: import('electron').BrowserWindow | null = null; private mainWindowRef: import('electron').BrowserWindow | null = null;
private activeApprovalNotifications = new Set<import('electron').Notification>(); private activeApprovalNotifications = new Map<string, import('electron').Notification>();
setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void { setToolApprovalEventEmitter(emitter: (event: ToolApprovalEvent) => void): void {
this.toolApprovalEventEmitter = emitter; this.toolApprovalEventEmitter = emitter;
@ -5372,6 +5372,10 @@ export class TeamProvisioningService {
const config = ConfigManager.getInstance().getConfig(); const config = ConfigManager.getInstance().getConfig();
if (!config.notifications.enabled || !config.notifications.notifyOnToolApproval) return; if (!config.notifications.enabled || !config.notifications.notifyOnToolApproval) return;
// Respect snooze — consistent with other notification types
const snoozedUntil = config.notifications.snoozedUntil;
if (snoozedUntil && Date.now() < snoozedUntil) return;
const { Notification: ElectronNotification } = require('electron') as typeof import('electron'); const { Notification: ElectronNotification } = require('electron') as typeof import('electron');
if (!ElectronNotification.isSupported()) return; if (!ElectronNotification.isSupported()) return;
@ -5395,10 +5399,10 @@ export class TeamProvisioningService {
: {}), : {}),
}); });
// Prevent GC from collecting the notification (macOS issue) // Track by requestId so we can close it when approval is resolved via UI
this.activeApprovalNotifications.add(notification); this.activeApprovalNotifications.set(approval.requestId, notification);
const cleanup = (): void => { const cleanup = (): void => {
this.activeApprovalNotifications.delete(notification); this.activeApprovalNotifications.delete(approval.requestId);
}; };
notification.on('click', () => { notification.on('click', () => {
@ -5436,6 +5440,23 @@ export class TeamProvisioningService {
notification.show(); notification.show();
} }
/** Dismiss the OS notification for a resolved/dismissed approval. */
dismissApprovalNotification(requestId: string): void {
const notification = this.activeApprovalNotifications.get(requestId);
if (notification) {
notification.close();
this.activeApprovalNotifications.delete(requestId);
}
}
/** Dismiss all OS notifications for a team's approvals (e.g. on team stop). */
private dismissAllApprovalNotifications(): void {
for (const [, notification] of this.activeApprovalNotifications) {
notification.close();
}
this.activeApprovalNotifications.clear();
}
private formatToolApprovalBody(toolName: string, toolInput: Record<string, unknown>): string { private formatToolApprovalBody(toolName: string, toolInput: Record<string, unknown>): string {
switch (toolName) { switch (toolName) {
case 'Bash': case 'Bash':
@ -5511,6 +5532,7 @@ export class TeamProvisioningService {
} }
run.pendingApprovals.delete(requestId); run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
this.emitToolApprovalEvent({ this.emitToolApprovalEvent({
autoResolved: true, autoResolved: true,
@ -5683,6 +5705,7 @@ export class TeamProvisioningService {
} finally { } finally {
run.pendingApprovals.delete(requestId); run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
} }
} }
@ -6244,6 +6267,7 @@ export class TeamProvisioningService {
for (const requestId of run.pendingApprovals.keys()) { for (const requestId of run.pendingApprovals.keys()) {
this.clearApprovalTimeout(requestId); this.clearApprovalTimeout(requestId);
this.inFlightResponses.delete(requestId); this.inFlightResponses.delete(requestId);
this.dismissApprovalNotification(requestId);
} }
this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId }); this.emitToolApprovalEvent({ dismissed: true, teamName: run.teamName, runId: run.runId });
run.pendingApprovals.clear(); run.pendingApprovals.clear();