feat: add notifyOnSubtasks setting to control subtask notifications (#227)
* feat: add notifyOnSubtasks setting to control subtask notifications - Add notifyOnSubtasks toggle in NotificationSettings (moved to general section) - Update useEventStream.ts to skip notifications for subtasks when disabled - Add server-side push notification control for subtasks - Update UI store, persistence, and desktop settings types * feat: apply notifyOnSubtasks across web push + desktop notifications --------- Co-authored-by: Jovines <jovines@qq.com>
This commit is contained in:
committed by
GitHub
co-authored by
Jovines
parent
344f369093
commit
de468361ec
@@ -8,10 +8,13 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
|
||||
export const NotificationSettings: React.FC = () => {
|
||||
const isWeb = isWebRuntime();
|
||||
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
|
||||
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
|
||||
const notificationMode = useUIStore(state => state.notificationMode);
|
||||
const setNotificationMode = useUIStore(state => state.setNotificationMode);
|
||||
const notifyOnSubtasks = useUIStore(state => state.notifyOnSubtasks);
|
||||
const setNotifyOnSubtasks = useUIStore(state => state.setNotifyOnSubtasks);
|
||||
|
||||
const [notificationPermission, setNotificationPermission] = React.useState<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = React.useState(false);
|
||||
@@ -19,6 +22,12 @@ export const NotificationSettings: React.FC = () => {
|
||||
const [pushBusy, setPushBusy] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isWeb) {
|
||||
setPushSupported(false);
|
||||
setPushSubscribed(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof Notification !== 'undefined') {
|
||||
setNotificationPermission(Notification.permission);
|
||||
}
|
||||
@@ -49,9 +58,12 @@ export const NotificationSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
void refresh();
|
||||
}, []);
|
||||
}, [isWeb]);
|
||||
|
||||
const handleToggleChange = async (checked: boolean) => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') {
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
@@ -74,7 +86,7 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const canShowNotifications = typeof Notification !== 'undefined' && Notification.permission === 'granted';
|
||||
const canShowNotifications = isWeb && typeof Notification !== 'undefined' && Notification.permission === 'granted';
|
||||
|
||||
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
|
||||
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
|
||||
@@ -351,113 +363,141 @@ export const NotificationSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWebRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* General Notification Settings */}
|
||||
<div className="space-y-1">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Foreground Notifications
|
||||
Notification Preferences
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Uses the browser Notification API while OpenChamber is open.
|
||||
Configure how and when you receive notifications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable foreground notifications
|
||||
</span>
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Notify for subtasks
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
When off, no notifications for child sessions created during multi-run.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||
onCheckedChange={handleToggleChange}
|
||||
checked={notifyOnSubtasks}
|
||||
onCheckedChange={(checked) => setNotifyOnSubtasks(checked)}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Notify even when visible
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
When off, only notifies when the tab is hidden or the window is not focused.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationMode === 'always'}
|
||||
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="typography-micro text-destructive">
|
||||
Notification permission denied. Enable notifications in your browser settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Permission granted, but foreground notifications are disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 pt-2">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Background Notifications (Push)
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Uses push notifications; works when OpenChamber is closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!pushSupported ? (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Push not supported in this browser.
|
||||
</p>
|
||||
) : (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pushSupported && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable background notifications
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Opens chat with /?session=<id> deep link.
|
||||
{isWeb && (
|
||||
<>
|
||||
{/* Foreground Notifications */}
|
||||
<div className="space-y-1 pt-4">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Foreground Notifications
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Uses the browser Notification API while OpenChamber is open.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{pushBusy && (
|
||||
<div className="text-muted-foreground">
|
||||
<GridLoader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable foreground notifications
|
||||
</span>
|
||||
<Switch
|
||||
checked={pushSubscribed}
|
||||
disabled={pushBusy}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
checked={nativeNotificationsEnabled && canShowNotifications}
|
||||
onCheckedChange={handleToggleChange}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nativeNotificationsEnabled && canShowNotifications && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Notify even when visible
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
When off, only notifies when the tab is hidden or the window is not focused.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationMode === 'always'}
|
||||
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notificationPermission === 'denied' && (
|
||||
<p className="typography-micro text-destructive">
|
||||
Notification permission denied. Enable notifications in your browser settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Permission granted, but foreground notifications are disabled.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Background Notifications */}
|
||||
<div className="space-y-1 pt-4">
|
||||
<h3 className="typography-ui-header font-semibold text-foreground">
|
||||
Background Notifications (Push)
|
||||
</h3>
|
||||
<p className="typography-ui text-muted-foreground">
|
||||
Uses push notifications; works when OpenChamber is closed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!pushSupported ? (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Push not supported in this browser.
|
||||
</p>
|
||||
) : (
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pushSupported && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<span className="typography-ui text-foreground">
|
||||
Enable background notifications
|
||||
</span>
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
Opens chat with /?session=<id> deep link.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{pushBusy && (
|
||||
<div className="text-muted-foreground">
|
||||
<GridLoader size="sm" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Switch
|
||||
checked={pushSubscribed}
|
||||
disabled={pushBusy}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
void handleEnableBackgroundNotifications();
|
||||
} else {
|
||||
void handleDisableBackgroundNotifications();
|
||||
}
|
||||
}}
|
||||
className="data-[state=checked]:bg-status-info"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -52,7 +52,6 @@ const OPENCHAMBER_SECTION_GROUPS: SectionGroup[] = [
|
||||
id: 'notifications',
|
||||
label: 'Notifications',
|
||||
items: ['Native'],
|
||||
webOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -150,6 +150,7 @@ export const useEventStream = () => {
|
||||
const { checkConnection } = useConfigStore();
|
||||
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
|
||||
const notificationMode = useUIStore((state) => state.notificationMode);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
const activeSessionDirectory = React.useMemo(() => {
|
||||
@@ -1310,6 +1311,17 @@ export const useEventStream = () => {
|
||||
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
|
||||
|
||||
if (shouldNotify) {
|
||||
// Check if this is a subtask and if we should notify for subtasks
|
||||
if (!notifyOnSubtasks) {
|
||||
const sessions = useSessionStore.getState().sessions;
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID);
|
||||
if (isSubtask) {
|
||||
// Skip notification for subtasks
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const notifiedMessages = notifiedMessagesRef.current;
|
||||
|
||||
if (!notifiedMessages.has(messageId)) {
|
||||
@@ -1610,6 +1622,7 @@ export const useEventStream = () => {
|
||||
currentSessionId,
|
||||
nativeNotificationsEnabled,
|
||||
notificationMode,
|
||||
notifyOnSubtasks,
|
||||
addStreamingPart,
|
||||
completeStreamingMessage,
|
||||
updateMessageInfo,
|
||||
@@ -2095,6 +2108,7 @@ export const useEventStream = () => {
|
||||
loadSessions,
|
||||
maybeBootstrapIfStale,
|
||||
resyncMessages,
|
||||
scheduleSoftResync
|
||||
scheduleSoftResync,
|
||||
notifyOnSubtasks,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ type AppearanceSlice = {
|
||||
showTextJustificationActivity: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
autoDeleteEnabled: boolean;
|
||||
autoDeleteAfterDays: number;
|
||||
toolCallExpansion: 'collapsed' | 'activity' | 'detailed';
|
||||
@@ -32,6 +33,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
showTextJustificationActivity: useUIStore.getState().showTextJustificationActivity,
|
||||
nativeNotificationsEnabled: useUIStore.getState().nativeNotificationsEnabled,
|
||||
notificationMode: useUIStore.getState().notificationMode,
|
||||
notifyOnSubtasks: useUIStore.getState().notifyOnSubtasks,
|
||||
autoDeleteEnabled: useUIStore.getState().autoDeleteEnabled,
|
||||
autoDeleteAfterDays: useUIStore.getState().autoDeleteAfterDays,
|
||||
toolCallExpansion: useUIStore.getState().toolCallExpansion,
|
||||
@@ -69,6 +71,7 @@ export const startAppearanceAutoSave = (): void => {
|
||||
showTextJustificationActivity: state.showTextJustificationActivity,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
autoDeleteEnabled: state.autoDeleteEnabled,
|
||||
autoDeleteAfterDays: state.autoDeleteAfterDays,
|
||||
toolCallExpansion: state.toolCallExpansion,
|
||||
@@ -94,6 +97,9 @@ export const startAppearanceAutoSave = (): void => {
|
||||
if (current.notificationMode !== previous.notificationMode) {
|
||||
diff.notificationMode = current.notificationMode;
|
||||
}
|
||||
if (current.notifyOnSubtasks !== previous.notifyOnSubtasks) {
|
||||
diff.notifyOnSubtasks = current.notifyOnSubtasks;
|
||||
}
|
||||
if (current.autoDeleteEnabled !== previous.autoDeleteEnabled) {
|
||||
diff.autoDeleteEnabled = current.autoDeleteEnabled;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export type DesktopSettings = {
|
||||
showTextJustificationActivity?: boolean;
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks?: boolean;
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
|
||||
@@ -216,6 +216,9 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
|
||||
store.setNotificationMode(settings.notificationMode);
|
||||
}
|
||||
}
|
||||
if (typeof settings.notifyOnSubtasks === 'boolean' && settings.notifyOnSubtasks !== store.notifyOnSubtasks) {
|
||||
store.setNotifyOnSubtasks(settings.notifyOnSubtasks);
|
||||
}
|
||||
if (typeof settings.toolCallExpansion === 'string'
|
||||
&& (settings.toolCallExpansion === 'collapsed' || settings.toolCallExpansion === 'activity' || settings.toolCallExpansion === 'detailed')) {
|
||||
if (settings.toolCallExpansion !== store.toolCallExpansion) {
|
||||
@@ -345,6 +348,9 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.notificationMode === 'string' && (candidate.notificationMode === 'always' || candidate.notificationMode === 'hidden-only')) {
|
||||
result.notificationMode = candidate.notificationMode;
|
||||
}
|
||||
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
||||
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
||||
}
|
||||
if (
|
||||
typeof candidate.toolCallExpansion === 'string'
|
||||
&& (candidate.toolCallExpansion === 'collapsed'
|
||||
|
||||
@@ -67,6 +67,7 @@ interface UIStore {
|
||||
isImagePreviewOpen: boolean;
|
||||
nativeNotificationsEnabled: boolean;
|
||||
notificationMode: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks: boolean;
|
||||
|
||||
setTheme: (theme: 'light' | 'dark' | 'system') => void;
|
||||
toggleSidebar: () => void;
|
||||
@@ -121,6 +122,7 @@ interface UIStore {
|
||||
setImagePreviewOpen: (open: boolean) => void;
|
||||
setNativeNotificationsEnabled: (value: boolean) => void;
|
||||
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
||||
setNotifyOnSubtasks: (value: boolean) => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
openMultiRunLauncherWithPrompt: (prompt: string) => void;
|
||||
}
|
||||
@@ -176,6 +178,7 @@ export const useUIStore = create<UIStore>()(
|
||||
isImagePreviewOpen: false,
|
||||
nativeNotificationsEnabled: false,
|
||||
notificationMode: 'hidden-only',
|
||||
notifyOnSubtasks: true,
|
||||
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
@@ -593,6 +596,10 @@ export const useUIStore = create<UIStore>()(
|
||||
setNotificationMode: (mode) => {
|
||||
set({ notificationMode: mode });
|
||||
},
|
||||
|
||||
setNotifyOnSubtasks: (value) => {
|
||||
set({ notifyOnSubtasks: value });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ui-store',
|
||||
@@ -627,6 +634,7 @@ export const useUIStore = create<UIStore>()(
|
||||
diffViewMode: state.diffViewMode,
|
||||
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
|
||||
notificationMode: state.notificationMode,
|
||||
notifyOnSubtasks: state.notifyOnSubtasks,
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user