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
@@ -1,4 +1,4 @@
use std::{collections::HashSet, path::PathBuf, time::Duration};
use std::{collections::{HashMap, HashSet}, path::PathBuf, time::Duration};
use anyhow::Result;
use futures_util::TryStreamExt;
@@ -45,6 +45,7 @@ pub fn spawn_assistant_notifications(
let mut shutdown_rx = runtime.subscribe_shutdown();
let notified_messages = Mutex::new(HashSet::<String>::new());
let notified_questions = Mutex::new(HashSet::<String>::new());
let session_parent_cache = Mutex::new(HashMap::<String, Option<String>>::new());
loop {
tokio::select! {
@@ -53,7 +54,14 @@ pub fn spawn_assistant_notifications(
break;
}
_ = async {
if let Err(err) = run_once(&app, &runtime, &client, &notified_messages, &notified_questions).await {
if let Err(err) = run_once(
&app,
&runtime,
&client,
&notified_messages,
&notified_questions,
&session_parent_cache,
).await {
warn!("[desktop:notify] SSE loop error: {err:?}");
}
tokio::time::sleep(Duration::from_secs(2)).await;
@@ -69,6 +77,7 @@ async fn run_once(
client: &Client,
notified_messages: &Mutex<HashSet<String>>,
notified_questions: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) -> Result<()> {
let opencode = runtime.opencode_manager();
@@ -121,7 +130,19 @@ async fn run_once(
data_lines.clear();
match parse_event_envelope(&raw) {
Ok(event) => handle_event(app, event, notified_messages, notified_questions).await,
Ok(event) => {
handle_event(
app,
runtime,
client,
&base,
event,
notified_messages,
notified_questions,
session_parent_cache,
)
.await
}
Err(err) => {
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
}
@@ -244,13 +265,26 @@ async fn try_connect_sse(
async fn handle_event(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
base: &str,
event: EventEnvelope,
notified_messages: &Mutex<HashSet<String>>,
notified_questions: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) {
match event.event_type.as_str() {
"message.updated" => {
handle_message_updated(app, &event.properties, notified_messages).await;
handle_message_updated(
app,
runtime,
client,
base,
&event.properties,
notified_messages,
session_parent_cache,
)
.await;
}
"question.asked" => {
handle_question_asked(app, &event.properties, notified_questions).await;
@@ -262,6 +296,61 @@ async fn handle_event(
}
}
async fn resolve_session_parent_id(
client: &Client,
base: &str,
session_id: &str,
cache: &Mutex<HashMap<String, Option<String>>>,
) -> Option<Option<String>> {
{
let locked = cache.lock().await;
if let Some(existing) = locked.get(session_id) {
return Some(existing.clone());
}
}
// Fail open: on any error, return None (unknown)
let sessions_url = format!("{base}/session");
let response = match tokio::time::timeout(
Duration::from_secs(2),
client.get(&sessions_url).header("accept", "application/json").send(),
)
.await
{
Ok(Ok(resp)) => resp,
_ => return None,
};
if !response.status().is_success() {
return None;
}
let data: Value = match response.json().await {
Ok(v) => v,
Err(_) => return None,
};
let parent = data
.as_array()
.and_then(|arr| {
arr.iter().find_map(|entry| {
let id = entry.get("id").and_then(Value::as_str)?;
if id != session_id {
return None;
}
let parent = entry.get("parentID").and_then(Value::as_str);
Some(parent.filter(|s| !s.is_empty()).map(|s| s.to_string()))
})
})
.flatten();
{
let mut locked = cache.lock().await;
locked.insert(session_id.to_string(), parent.clone());
}
Some(parent)
}
async fn handle_question_asked(
app: &AppHandle,
properties: &Value,
@@ -396,8 +485,12 @@ async fn handle_permission_asked(
async fn handle_message_updated(
app: &AppHandle,
runtime: &DesktopRuntime,
client: &Client,
base: &str,
properties: &Value,
notified_messages: &Mutex<HashSet<String>>,
session_parent_cache: &Mutex<HashMap<String, Option<String>>>,
) {
let Some(info) = properties.get("info") else {
return;
@@ -418,6 +511,31 @@ async fn handle_message_updated(
None => return,
};
// Subtask filtering (fail open)
let notify_on_subtasks = runtime
.settings()
.load()
.await
.ok()
.and_then(|settings| settings.get("notifyOnSubtasks").and_then(Value::as_bool))
.unwrap_or(true);
if !notify_on_subtasks {
let session_id = info
.get("sessionID")
.and_then(Value::as_str)
.or_else(|| properties.get("sessionID").and_then(Value::as_str))
.or_else(|| properties.get("sessionId").and_then(Value::as_str));
if let Some(session_id) = session_id {
if let Some(parent) = resolve_session_parent_id(client, base, session_id, session_parent_cache).await {
if parent.is_some() {
return;
}
}
}
}
{
let mut notified = notified_messages.lock().await;
if notified.contains(&message_id) {
@@ -295,6 +295,9 @@ fn sanitize_settings_update(payload: &Value) -> Value {
if let Some(Value::Bool(b)) = obj.get("nativeNotificationsEnabled") {
result_obj.insert("nativeNotificationsEnabled".to_string(), json!(b));
}
if let Some(Value::Bool(b)) = obj.get("notifyOnSubtasks") {
result_obj.insert("notifyOnSubtasks".to_string(), json!(b));
}
if let Some(Value::String(s)) = obj.get("notificationMode") {
let trimmed = s.trim();
if trimmed == "always" || trimmed == "hidden-only" {
@@ -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,
})
}
),
+66
View File
@@ -783,6 +783,9 @@ const sanitizeSettingsUpdate = (payload) => {
result.notificationMode = mode;
}
}
if (typeof candidate.notifyOnSubtasks === 'boolean') {
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
}
if (typeof candidate.autoDeleteEnabled === 'boolean') {
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
}
@@ -1861,6 +1864,53 @@ const pushPermissionDebounceTimers = new Map();
const notifiedPermissionRequests = new Set();
const lastReadyNotificationAt = new Map();
// Cache: sessionId -> parentID (string) or null (no parent). Undefined = unknown.
const sessionParentIdCache = new Map();
const SESSION_PARENT_CACHE_TTL_MS = 60 * 1000;
const getCachedSessionParentId = (sessionId) => {
const entry = sessionParentIdCache.get(sessionId);
if (!entry) return undefined;
if (Date.now() - entry.at > SESSION_PARENT_CACHE_TTL_MS) {
sessionParentIdCache.delete(sessionId);
return undefined;
}
return entry.parentID;
};
const setCachedSessionParentId = (sessionId, parentID) => {
sessionParentIdCache.set(sessionId, { parentID: parentID ?? null, at: Date.now() });
};
const fetchSessionParentId = async (sessionId) => {
if (!sessionId) return undefined;
const cached = getCachedSessionParentId(sessionId);
if (cached !== undefined) return cached;
try {
const response = await fetch(buildOpenCodeUrl('/session', ''), {
method: 'GET',
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(2000),
});
if (!response.ok) {
return undefined;
}
const data = await response.json().catch(() => null);
if (!Array.isArray(data)) {
return undefined;
}
const match = data.find((s) => s && typeof s === 'object' && s.id === sessionId);
const parentID = match && typeof match.parentID === 'string' && match.parentID.length > 0 ? match.parentID : null;
setCachedSessionParentId(sessionId, parentID);
return parentID;
} catch {
return undefined;
}
};
const extractSessionIdFromPayload = (payload) => {
if (!payload || typeof payload !== 'object') return null;
const props = payload.properties;
@@ -1919,6 +1969,22 @@ const maybeSendPushForTrigger = async (payload) => {
if (payload.type === 'message.updated') {
const info = payload.properties?.info;
if (info?.role === 'assistant' && info?.finish === 'stop' && sessionId) {
// Check if this is a subtask and if we should notify for subtasks
const settings = await readSettingsFromDisk();
if (settings.notifyOnSubtasks === false) {
// Prefer parentID on payload (if present), else fetch from sessions list.
const sessionInfo = payload.properties?.session;
const parentIDFromPayload = sessionInfo?.parentID ?? payload.properties?.parentID;
const parentID = parentIDFromPayload
? parentIDFromPayload
: await fetchSessionParentId(sessionId);
// Fail open: if parentID cannot be resolved, send notification.
if (parentID) {
return;
}
}
const now = Date.now();
const lastAt = lastReadyNotificationAt.get(sessionId) ?? 0;
if (now - lastAt < PUSH_READY_COOLDOWN_MS) {