From 592351df9fd3db9d80af3774701a9d94ac30ef0d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 15 Jan 2026 22:56:20 +0200 Subject: [PATCH] feat: add notifications for agent questions --- .../src-tauri/src/assistant_notifications.rs | 67 +++++++++++++++++-- packages/ui/src/hooks/useEventStream.ts | 26 +++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/packages/desktop/src-tauri/src/assistant_notifications.rs b/packages/desktop/src-tauri/src/assistant_notifications.rs index cf03f448..8ccd6cdf 100644 --- a/packages/desktop/src-tauri/src/assistant_notifications.rs +++ b/packages/desktop/src-tauri/src/assistant_notifications.rs @@ -44,6 +44,7 @@ pub fn spawn_assistant_notifications( let mut shutdown_rx = runtime.subscribe_shutdown(); let notified_messages = Mutex::new(HashSet::::new()); + let notified_questions = Mutex::new(HashSet::::new()); loop { tokio::select! { @@ -52,7 +53,7 @@ pub fn spawn_assistant_notifications( break; } _ = async { - if let Err(err) = run_once(&app, &runtime, &client, ¬ified_messages).await { + if let Err(err) = run_once(&app, &runtime, &client, ¬ified_messages, ¬ified_questions).await { warn!("[desktop:notify] SSE loop error: {err:?}"); } tokio::time::sleep(Duration::from_secs(2)).await; @@ -67,6 +68,7 @@ async fn run_once( runtime: &DesktopRuntime, client: &Client, notified_messages: &Mutex>, + notified_questions: &Mutex>, ) -> Result<()> { let opencode = runtime.opencode_manager(); @@ -119,7 +121,7 @@ async fn run_once( data_lines.clear(); match parse_event_envelope(&raw) { - Ok(event) => handle_event(app, event, notified_messages).await, + Ok(event) => handle_event(app, event, notified_messages, notified_questions).await, Err(err) => { warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}"); } @@ -244,12 +246,67 @@ async fn handle_event( app: &AppHandle, event: EventEnvelope, notified_messages: &Mutex>, + notified_questions: &Mutex>, ) { - if event.event_type.as_str() != "message.updated" { - return; + match event.event_type.as_str() { + "message.updated" => { + handle_message_updated(app, &event.properties, notified_messages).await; + } + "question.asked" => { + handle_question_asked(app, &event.properties, notified_questions).await; + } + _ => {} + } +} + +async fn handle_question_asked( + app: &AppHandle, + properties: &Value, + notified_questions: &Mutex>, +) { + let session_id = properties.get("sessionID").and_then(Value::as_str); + let question_id = properties.get("id").and_then(Value::as_str); + + let (session_id, question_id) = match (session_id, question_id) { + (Some(s), Some(q)) => (s, q), + _ => return, + }; + + let key = format!("{}:{}", session_id, question_id); + { + let mut notified = notified_questions.lock().await; + if notified.contains(&key) { + return; + } + notified.insert(key); } - let Some(info) = event.properties.get("info") else { + let should_notify = app + .get_webview_window("main") + .map(|window| { + let focused = window.is_focused().unwrap_or(false); + let minimized = window.is_minimized().unwrap_or(false); + !focused || minimized + }) + .unwrap_or(true); + + if should_notify { + let _ = app + .notification() + .builder() + .title("Input needed") + .body("Agent is waiting for your response") + .sound("Glass") + .show(); + } +} + +async fn handle_message_updated( + app: &AppHandle, + properties: &Value, + notified_messages: &Mutex>, +) { + let Some(info) = properties.get("info") else { return; }; diff --git a/packages/ui/src/hooks/useEventStream.ts b/packages/ui/src/hooks/useEventStream.ts index 3d22c6c5..60b37f0f 100644 --- a/packages/ui/src/hooks/useEventStream.ts +++ b/packages/ui/src/hooks/useEventStream.ts @@ -342,6 +342,7 @@ export const useEventStream = () => { const permissionToastShownRef = React.useRef>(new Set()); const questionToastShownRef = React.useRef>(new Set()); const notifiedMessagesRef = React.useRef>(new Set()); + const notifiedQuestionsRef = React.useRef>(new Set()); const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => { if (typeof document === 'undefined') return 'visible'; @@ -1230,6 +1231,30 @@ export const useEventStream = () => { addQuestion(request); const toastKey = `${request.sessionID}:${request.id}`; + + // Native notification for web runtime (same conditions as completion notifications) + if (isWebRuntime() && nativeNotificationsEnabled) { + const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden'; + + if (shouldNotify) { + const notifiedQuestions = notifiedQuestionsRef.current; + + if (!notifiedQuestions.has(toastKey)) { + notifiedQuestions.add(toastKey); + + const runtimeAPIs = getRegisteredRuntimeAPIs(); + + if (runtimeAPIs?.notifications) { + void runtimeAPIs.notifications.notifyAgentCompletion({ + title: 'Input needed', + body: 'Agent is waiting for your response', + tag: toastKey, + }); + } + } + } + } + if (!questionToastShownRef.current.has(toastKey)) { setTimeout(() => { const current = currentSessionIdRef.current; @@ -1721,6 +1746,7 @@ export const useEventStream = () => { messageCache.clear(); // eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time notifiedMessagesRef.current.clear(); + notifiedQuestionsRef.current.clear(); pendingResumeRef.current = false; visibilityStateRef.current = resolveVisibilityState();