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
@@ -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, ¬ified_messages, ¬ified_questions).await {
|
||||
if let Err(err) = run_once(
|
||||
&app,
|
||||
&runtime,
|
||||
&client,
|
||||
¬ified_messages,
|
||||
¬ified_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=<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,
|
||||
})
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user