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:
Jovines
2026-01-30 14:15:00 +02:00
committed by GitHub
co-authored by Jovines
parent 344f369093
commit de468361ec
10 changed files with 354 additions and 93 deletions
@@ -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=&lt;id&gt; 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=&lt;id&gt; 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,
},
];
+15 -1
View File
@@ -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;
}
+1
View File
@@ -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"
+6
View File
@@ -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'
+8
View File
@@ -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,
})
}
),