Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ pub struct NotificationConfig {
/// Whether to show a toast notification when a dialog turn completes while the window is not focused.
#[serde(default = "default_true")]
pub dialog_completion_notify: bool,
/// Whether to show a toast notification when an approval request arrives while the window is not focused.
#[serde(default = "default_true")]
pub permission_request_notify: bool,
/// Whether to show built-in tip cards on startup (can be disabled by the user).
#[serde(default = "default_true")]
pub enable_startup_tips: bool,
Expand Down Expand Up @@ -1703,6 +1706,7 @@ impl Default for AppConfig {
position: "topRight".to_string(),
duration: 5000,
dialog_completion_notify: true,
permission_request_notify: true,
enable_startup_tips: true,
},
flow_chat: AppFlowChatConfig::default(),
Expand Down Expand Up @@ -1955,6 +1959,7 @@ impl Default for NotificationConfig {
position: "topRight".to_string(),
duration: 5000,
dialog_completion_notify: true,
permission_request_notify: true,
enable_startup_tips: true,
}
}
Expand Down Expand Up @@ -2106,7 +2111,7 @@ mod tests {
use super::{
AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig,
AgentProfileView, AppConfig, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy,
ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy,
ModelExchangeTracingMode, NotificationConfig, ReasoningMode, SubagentBatchExecutionPolicy,
SubagentModelSelection, UserSkillGroupsConfig, UserToolGroupsConfig,
};
use bitfun_runtime_ports::ToolPermissionConfig;
Expand All @@ -2120,6 +2125,15 @@ mod tests {
assert!(!config.prevent_sleep);
}

#[test]
fn permission_request_notifications_default_to_enabled() {
assert!(NotificationConfig::default().permission_request_notify);

let config: NotificationConfig = serde_json::from_value(serde_json::json!({}))
.expect("empty notification config should default");
assert!(config.permission_request_notify);
}

#[test]
fn agent_profile_defaults_keep_all_collections_empty() {
let config = AgentProfileConfig::default();
Expand Down Expand Up @@ -2397,6 +2411,7 @@ mod tests {
"position": "top-right",
"duration": 4000,
"dialog_completion_notify": true,
"permission_request_notify": false,
"enable_startup_tips": true
},
"ai_experience": {
Expand All @@ -2420,6 +2435,7 @@ mod tests {
.expect("minimal app config with quick_actions should deserialize");

let actions = &config.app.ai_experience.quick_actions;
assert!(!config.app.notifications.permission_request_notify);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].id, "custom_1");
assert_eq!(actions[0].label, "Run tests");
Expand All @@ -2429,6 +2445,10 @@ mod tests {
serialized["app"]["ai_experience"]["quick_actions"][0]["id"],
"custom_1"
);
assert_eq!(
serialized["app"]["notifications"]["permission_request_notify"],
false
);
}

#[test]
Expand Down
72 changes: 72 additions & 0 deletions src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import {
buildPermissionRequestNotificationCopy,
PermissionRequestNotificationBatcher,
PERMISSION_REQUEST_NOTIFY_COALESCE_MS,
shouldSendPermissionRequestNotification,
} from './permissionRequestNotifyPolicy';

describe('PermissionRequestNotificationBatcher', () => {
it('de-duplicates requests and aggregates requests from the same session round', () => {
vi.useFakeTimers();
const onBatchReady = vi.fn();
const batcher = new PermissionRequestNotificationBatcher(onBatchReady);

batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' });
batcher.enqueue({ requestId: 'request-2', sessionId: 'session-1', roundId: 'round-1' });
batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' });

vi.advanceTimersByTime(PERMISSION_REQUEST_NOTIFY_COALESCE_MS);

expect(onBatchReady).toHaveBeenCalledTimes(1);
expect(onBatchReady).toHaveBeenCalledWith({ requestCount: 2 });
batcher.dispose();
vi.useRealTimers();
});

it('keeps requests from different rounds in separate notifications', () => {
vi.useFakeTimers();
const onBatchReady = vi.fn();
const batcher = new PermissionRequestNotificationBatcher(onBatchReady);

batcher.enqueue({ requestId: 'request-1', sessionId: 'session-1', roundId: 'round-1' });
batcher.enqueue({ requestId: 'request-2', sessionId: 'session-1', roundId: 'round-2' });

vi.advanceTimersByTime(PERMISSION_REQUEST_NOTIFY_COALESCE_MS);

expect(onBatchReady).toHaveBeenCalledTimes(2);
expect(onBatchReady).toHaveBeenCalledWith({ requestCount: 1 });
batcher.dispose();
vi.useRealTimers();
});
});

describe('permission request notification policy', () => {
it('requires both a background window and an enabled preference', () => {
expect(shouldSendPermissionRequestNotification({
isBackground: false,
notificationsEnabled: true,
})).toBe(false);
expect(shouldSendPermissionRequestNotification({
isBackground: true,
notificationsEnabled: false,
})).toBe(false);
expect(shouldSendPermissionRequestNotification({
isBackground: true,
notificationsEnabled: true,
})).toBe(true);
});

it('uses generic copy without accepting request details', () => {
const t = (key: string, options?: Record<string, unknown>) => {
if (key === 'notify.permissionRequestTitle') return 'BitFun requires approval';
if (key === 'notify.permissionRequestBody') return 'An action is waiting for your approval.';
return `${options?.count} actions are waiting for your approval.`;
};

expect(buildPermissionRequestNotificationCopy({ requestCount: 2, t })).toEqual({
title: 'BitFun requires approval',
body: '2 actions are waiting for your approval.',
});
});
});
104 changes: 104 additions & 0 deletions src/web-ui/src/app/hooks/permissionRequestNotifyPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { PermissionRequestNotificationEvent } from '@/infrastructure/event-bus';

export const PERMISSION_REQUEST_NOTIFY_COALESCE_MS = 400;

const MAX_DEDUPLICATED_REQUESTS = 1_000;

interface PermissionRequestNotificationInput {
isBackground: boolean;
notificationsEnabled?: boolean;
}

interface PermissionRequestNotificationCopyInput {
requestCount: number;
t: (key: string, options?: Record<string, unknown>) => string;
}

export interface PermissionRequestNotificationBatch {
requestCount: number;
}

interface PendingBatch {
requestIds: Set<string>;
timer: ReturnType<typeof setTimeout>;
}

/**
* Keeps duplicate runtime events from creating repeated OS notifications and
* groups approval prompts raised by the same model round into one notification.
*/
export class PermissionRequestNotificationBatcher {
private readonly seenRequestIds = new Set<string>();
private readonly pendingBatches = new Map<string, PendingBatch>();

constructor(
private readonly onBatchReady: (batch: PermissionRequestNotificationBatch) => void,
private readonly coalesceMs = PERMISSION_REQUEST_NOTIFY_COALESCE_MS,
) {}

enqueue(request: PermissionRequestNotificationEvent): void {
if (!request.requestId || this.seenRequestIds.has(request.requestId)) {
return;
}

this.rememberRequestId(request.requestId);

const batchKey = request.roundId
? JSON.stringify([request.sessionId, request.roundId])
: `request:${request.requestId}`;
const existingBatch = this.pendingBatches.get(batchKey);
if (existingBatch) {
existingBatch.requestIds.add(request.requestId);
return;
}

const requestIds = new Set([request.requestId]);
const timer = setTimeout(() => {
const batch = this.pendingBatches.get(batchKey);
if (!batch) {
return;
}
this.pendingBatches.delete(batchKey);
this.onBatchReady({ requestCount: batch.requestIds.size });
}, this.coalesceMs);

this.pendingBatches.set(batchKey, { requestIds, timer });
}

dispose(): void {
for (const batch of this.pendingBatches.values()) {
clearTimeout(batch.timer);
}
this.pendingBatches.clear();
}

private rememberRequestId(requestId: string): void {
this.seenRequestIds.add(requestId);
while (this.seenRequestIds.size > MAX_DEDUPLICATED_REQUESTS) {
const oldestRequestId = this.seenRequestIds.values().next().value;
if (!oldestRequestId) {
return;
}
this.seenRequestIds.delete(oldestRequestId);
}
}
}

export function shouldSendPermissionRequestNotification({
isBackground,
notificationsEnabled,
}: PermissionRequestNotificationInput): boolean {
return isBackground && notificationsEnabled !== false;
}

export function buildPermissionRequestNotificationCopy({
requestCount,
t,
}: PermissionRequestNotificationCopyInput): { title: string; body: string } {
return {
title: t('notify.permissionRequestTitle'),
body: requestCount === 1
? t('notify.permissionRequestBody')
: t('notify.permissionRequestBatchBody', { count: requestCount }),
};
}
113 changes: 113 additions & 0 deletions src/web-ui/src/app/hooks/usePermissionRequestNotify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useRef } from 'react';
import { agentAPI } from '@/infrastructure/api';
import {
globalEventBus,
PERMISSION_REQUEST_NOTIFICATION_EVENT,
type PermissionRequestNotificationEvent,
} from '@/infrastructure/event-bus';
import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI';
import { configManager } from '@/infrastructure/config/services/ConfigManager';
import { useI18n } from '@/infrastructure/i18n';
import { createLogger } from '@/shared/utils/logger';
import {
buildPermissionRequestNotificationCopy,
PermissionRequestNotificationBatcher,
shouldSendPermissionRequestNotification,
} from './permissionRequestNotifyPolicy';

const log = createLogger('usePermissionRequestNotify');

const PERMISSION_REQUEST_NOTIFY_CONFIG_KEY = 'app.notifications.permission_request_notify';

/**
* Sends one generic desktop notification for user-actionable permission
* requests received while BitFun is in the background. Core runtime and ACP
* requests share the same policy, de-duplication, and round-level batching.
*/
export const usePermissionRequestNotify = () => {
const { t } = useI18n('common');
const windowFocusedRef = useRef(true);

useEffect(() => {
let disposed = false;
const handleFocus = () => { windowFocusedRef.current = true; };
const handleBlur = () => { windowFocusedRef.current = false; };
windowFocusedRef.current = document.hasFocus();
const isBackground = () => document.hidden || !windowFocusedRef.current;

const sendBatchNotification = async (requestCount: number) => {
let enabled = true;
try {
enabled = await configManager.getConfig<boolean>(PERMISSION_REQUEST_NOTIFY_CONFIG_KEY);
} catch (error) {
log.warn('Failed to read permission_request_notify config', error);
}

if (!shouldSendPermissionRequestNotification({
isBackground: isBackground(),
notificationsEnabled: enabled,
})) {
return;
}

const notificationCopy = buildPermissionRequestNotificationCopy({ requestCount, t });
await systemAPI.sendSystemNotification(notificationCopy.title, notificationCopy.body);
};

const batcher = new PermissionRequestNotificationBatcher(({ requestCount }) => {
void sendBatchNotification(requestCount);
});

const enqueueWhenBackground = (request: PermissionRequestNotificationEvent) => {
if (isBackground()) {
batcher.enqueue(request);
}
};

window.addEventListener('focus', handleFocus);
window.addEventListener('blur', handleBlur);

const unlistenPermissionRequests = agentAPI.onPermissionRequestEvent((event) => {
if (event.event !== 'asked') {
return;
}
enqueueWhenBackground({
requestId: event.request.requestId,
sessionId: event.request.sessionId,
roundId: event.request.roundId,
});
});
const unlistenAcpPermissionRequests = globalEventBus.on<PermissionRequestNotificationEvent>(
PERMISSION_REQUEST_NOTIFICATION_EVENT,
enqueueWhenBackground,
);

void (async () => {
try {
await agentAPI.subscribePermissionRequests();
const pendingRequests = await agentAPI.listPendingPermissionRequests();
if (disposed) {
return;
}
pendingRequests.forEach((request) => {
enqueueWhenBackground({
requestId: request.requestId,
sessionId: request.sessionId,
roundId: request.roundId,
});
});
} catch (error) {
log.warn('Failed to subscribe to permission request events', error);
}
})();

return () => {
disposed = true;
window.removeEventListener('focus', handleFocus);
window.removeEventListener('blur', handleBlur);
unlistenPermissionRequests();
unlistenAcpPermissionRequests();
batcher.dispose();
};
}, [t]);
};
2 changes: 2 additions & 0 deletions src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceCont
import { useWindowControls } from '../hooks/useWindowControls';
import { isWindowFullscreenShortcut } from '../hooks/windowFullscreenShortcut';
import { useAssistantBootstrap } from '../hooks/useAssistantBootstrap';
import { usePermissionRequestNotify } from '../hooks/usePermissionRequestNotify';
import { useApp } from '../hooks/useApp';
import { useSceneStore } from '../stores/sceneStore';
import { useShortcut } from '@/infrastructure/hooks/useShortcut';
Expand Down Expand Up @@ -77,6 +78,7 @@ interface WindowModeHint {
const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
const { t } = useI18n('components');
const { t: tCommon } = useI18n('common');
usePermissionRequestNotify();
const {
currentWorkspace,
hasWorkspace,
Expand Down
Loading