feat: add notifications for agent questions
This commit is contained in:
@@ -44,6 +44,7 @@ pub fn spawn_assistant_notifications(
|
|||||||
|
|
||||||
let mut shutdown_rx = runtime.subscribe_shutdown();
|
let mut shutdown_rx = runtime.subscribe_shutdown();
|
||||||
let notified_messages = Mutex::new(HashSet::<String>::new());
|
let notified_messages = Mutex::new(HashSet::<String>::new());
|
||||||
|
let notified_questions = Mutex::new(HashSet::<String>::new());
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -52,7 +53,7 @@ pub fn spawn_assistant_notifications(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ = async {
|
_ = 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:?}");
|
warn!("[desktop:notify] SSE loop error: {err:?}");
|
||||||
}
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||||
@@ -67,6 +68,7 @@ async fn run_once(
|
|||||||
runtime: &DesktopRuntime,
|
runtime: &DesktopRuntime,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
notified_messages: &Mutex<HashSet<String>>,
|
notified_messages: &Mutex<HashSet<String>>,
|
||||||
|
notified_questions: &Mutex<HashSet<String>>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let opencode = runtime.opencode_manager();
|
let opencode = runtime.opencode_manager();
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ async fn run_once(
|
|||||||
data_lines.clear();
|
data_lines.clear();
|
||||||
|
|
||||||
match parse_event_envelope(&raw) {
|
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) => {
|
Err(err) => {
|
||||||
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
warn!("[desktop:notify] Failed to parse SSE data: {err}; raw={raw}");
|
||||||
}
|
}
|
||||||
@@ -244,12 +246,67 @@ async fn handle_event(
|
|||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
event: EventEnvelope,
|
event: EventEnvelope,
|
||||||
notified_messages: &Mutex<HashSet<String>>,
|
notified_messages: &Mutex<HashSet<String>>,
|
||||||
|
notified_questions: &Mutex<HashSet<String>>,
|
||||||
) {
|
) {
|
||||||
if event.event_type.as_str() != "message.updated" {
|
match event.event_type.as_str() {
|
||||||
return;
|
"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<HashSet<String>>,
|
||||||
|
) {
|
||||||
|
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<HashSet<String>>,
|
||||||
|
) {
|
||||||
|
let Some(info) = properties.get("info") else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export const useEventStream = () => {
|
|||||||
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
const permissionToastShownRef = React.useRef<Set<string>>(new Set());
|
||||||
const questionToastShownRef = React.useRef<Set<string>>(new Set());
|
const questionToastShownRef = React.useRef<Set<string>>(new Set());
|
||||||
const notifiedMessagesRef = React.useRef<Set<string>>(new Set());
|
const notifiedMessagesRef = React.useRef<Set<string>>(new Set());
|
||||||
|
const notifiedQuestionsRef = React.useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
const resolveVisibilityState = React.useCallback((): 'visible' | 'hidden' => {
|
||||||
if (typeof document === 'undefined') return 'visible';
|
if (typeof document === 'undefined') return 'visible';
|
||||||
@@ -1230,6 +1231,30 @@ export const useEventStream = () => {
|
|||||||
addQuestion(request);
|
addQuestion(request);
|
||||||
|
|
||||||
const toastKey = `${request.sessionID}:${request.id}`;
|
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)) {
|
if (!questionToastShownRef.current.has(toastKey)) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const current = currentSessionIdRef.current;
|
const current = currentSessionIdRef.current;
|
||||||
@@ -1721,6 +1746,7 @@ export const useEventStream = () => {
|
|||||||
messageCache.clear();
|
messageCache.clear();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
|
||||||
notifiedMessagesRef.current.clear();
|
notifiedMessagesRef.current.clear();
|
||||||
|
notifiedQuestionsRef.current.clear();
|
||||||
|
|
||||||
pendingResumeRef.current = false;
|
pendingResumeRef.current = false;
|
||||||
visibilityStateRef.current = resolveVisibilityState();
|
visibilityStateRef.current = resolveVisibilityState();
|
||||||
|
|||||||
Reference in New Issue
Block a user