From 9b07527c3aebdb20bd5ec5baa02a3ad8639bcec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 14:08:04 +0000 Subject: [PATCH] Scheduled-result notifications: first lines, Open / Run again / Pause; a click opens A run's own notification (run.notification, server dev) gives the body (the result's first lines) and the actions with labels in the person's language; they become the notification's action type. Every notification is remembered by id (watch::Notes) so a click opens the chat or the task, and Run again / Pause post /schedules/{id}/run|pause without the window. An older server's run is said as before. Fixtures re-pinned at server dev e0c2332, schedule.json added. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + scripts/fetch-fixtures.sh | 2 +- src-tauri/src/lib.rs | 107 +++++++++++++-- src-tauri/src/watch.rs | 176 ++++++++++++++++++++++++- tests/ui/fixtures/SOURCE | 2 +- tests/ui/fixtures/event_schedules.json | 3 +- tests/ui/fixtures/schedule.json | 107 +++++++++++++++ tests/ui/run.mjs | 24 ++++ ui/js/main.js | 10 ++ 9 files changed, 420 insertions(+), 16 deletions(-) create mode 100644 tests/ui/fixtures/schedule.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a17107d..256cc68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,11 @@ All notable changes to LLeMbas Desktop. The format follows server has it on for the account. The microphone is allowed only right after Dictate is pressed. - **Draw a picture.** From Ctrl+K, where the server can draw for the account: a prompt and a shape, and the picture under it. Only the account's own pictures on its own server are shown. +- **A scheduled task's notification says what it found.** Where the server makes the run's + notification, it carries the task's name and the result's first lines (or the failure said), + with Open and Run again (and Pause, when a task has failed three times in a row), labelled in + the account's language. Open, or a click on the notification, opens the task; Run again and + Pause do it without opening the window. A chat's notification, clicked, opens that chat. ### Fixed diff --git a/scripts/fetch-fixtures.sh b/scripts/fetch-fixtures.sh index 75e0e82..077fe1d 100755 --- a/scripts/fetch-fixtures.sh +++ b/scripts/fetch-fixtures.sh @@ -16,7 +16,7 @@ OUT="tests/ui/fixtures" FILES="chat_agent.json event_ask.json event_status.json event_status_idle.json event_status_person.json event_status_stopped.json event_tree.json models_agent.json subagent_findings.json subagent_transcript.json tree.json me.json folders.json folder.json search.json memories.json memory.json scope.json push_schedule.json push_schedule_failed.json -event_schedules.json schedule_run.json chat.json chats_listed.json files_listing.json" +event_schedules.json schedule.json schedule_run.json chat.json chats_listed.json files_listing.json" [ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh " >&2; exit 2; } commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 75fd117..01506b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,7 +43,7 @@ use tauri::{ WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry, }; use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; -use tauri_plugin_notification::NotificationExt; +use tauri_plugin_notification::{Action, ActionPerformed, ActionType, NotificationExt}; use tauri_plugin_opener::OpenerExt; use tauri_plugin_updater::UpdaterExt; @@ -1656,9 +1656,11 @@ fn watch_chats(handle: AppHandle) { } else { d.title.clone() }; + let id = app.watch.notes.add(watch::Note::Chat(d.id.clone())); let _ = handle .notification() .builder() + .id(id) .title(title) .body(if d.agent { &words.agent } else { &words.reply }) .show(); @@ -1666,6 +1668,56 @@ fn watch_chats(handle: AppHandle) { }); } +/// A click on one of the app's notifications, or on one of its actions: a chat's opens that chat; +/// a scheduled run's opens its task (`open`, or the notification itself), runs it again now +/// (`run_again`) or pauses it (`pause`) -- the last two without opening the window. +fn notification_action(handle: &AppHandle, done: &ActionPerformed) { + let Some(id) = done.notification().map(|n| n.id()) else { + return; + }; + let Some(note) = handle.state::().watch.notes.get(id) else { + return; + }; + match (note, done.action_id()) { + (watch::Note::Chat(chat), "tap") => { + show(handle); + let _ = handle.emit("open-chat", chat); + } + (watch::Note::Run { schedule, run }, "tap" | "open") => { + show(handle); + let _ = handle.emit( + "open-schedule", + serde_json::json!({ "schedule": schedule, "run": run }), + ); + } + (watch::Note::Run { schedule, .. }, which @ ("run_again" | "pause")) => { + let path = format!( + "/schedules/{}/{}", + terminal::encode(&schedule), + if which == "pause" { "pause" } else { "run" } + ); + let h = handle.clone(); + std::thread::spawn(move || { + let Some((app_url, token)) = h.state::().door() else { + return; + }; + match server::request( + &app_url, + &token, + "POST", + &path, + Some(&serde_json::json!({})), + ) { + Err(e) if e.status == 401 => token_refused(&h), + Err(e) => log::write(&format!("a notification's action failed: {}", e.detail)), + Ok(_) => {} + } + }); + } + _ => {} + } +} + /// How long to wait before following the event stream again: after it closed or broke off, and /// (longer) on a server that has none. const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5); @@ -1703,17 +1755,48 @@ fn watch_events(handle: AppHandle) { } else { r.title }; - let body = match r.told { - watch::Told::Result => &words.scheduled, - watch::Told::Failed => &words.scheduled_failed, - watch::Told::Pause => &words.scheduled_pause, - }; - let _ = handle + let body = r.body.clone().unwrap_or_else(|| { + match r.told { + watch::Told::Result => &words.scheduled, + watch::Told::Failed => &words.scheduled_failed, + watch::Told::Pause => &words.scheduled_pause, + } + .clone() + }); + let id = app.watch.notes.add(watch::Note::Run { + schedule: r.schedule.clone(), + run: r.run.clone(), + }); + let mut note = handle .notification() .builder() + .id(id) .title(title) - .body(body) - .show(); + .body(body); + if !r.actions.is_empty() { + // One action type per set of actions; registered again, it takes the labels + // this run came with (the person's language may have changed). + let kind = format!( + "schedule-{}", + r.actions + .iter() + .map(|(a, _)| a.as_str()) + .collect::>() + .join("-") + ); + let actions = r + .actions + .iter() + .map(|(a, label)| Action::builder(a, label).build()) + .collect(); + let _ = handle + .notification() + .register_action_types(vec![ActionType::builder(&kind) + .actions(actions) + .build()]); + note = note.action_type_id(kind); + } + let _ = note.show(); } true }); @@ -1900,6 +1983,12 @@ pub fn run() { watch_updates(handle.clone()); watch_chats(handle.clone()); watch_events(handle.clone()); + { + let h = handle.clone(); + let _ = handle + .notification() + .on_action(move |done| notification_action(&h, done)); + } { let h = handle.clone(); std::thread::spawn(move || { diff --git a/src-tauri/src/watch.rs b/src-tauri/src/watch.rs index 1b48977..7d07d6e 100644 --- a/src-tauri/src/watch.rs +++ b/src-tauri/src/watch.rs @@ -15,8 +15,15 @@ //! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`: //! `always` -- a result, even "nothing to report"; `matters` -- a result, never a `[SILENT]` one; //! `never` -- nothing), and a failure the first time in a row and again on the third, which asks -//! whether to pause it. Said by the schedule's title, never the result's words. A server without -//! the stream answers 404, and nothing is said. +//! whether to pause it. A server that made the run's notification (`run.notification`, server +//! 2.0.0-beta.9) gives its words: the task's name and the result's first lines -- shown here as on +//! an unlocked phone, the desktop being the person's own -- and its actions with their labels in +//! the person's language (`open`, `run_again`, and `pause` on a failure that asks). An older +//! server's run is said by the schedule's title and the page's words, without actions. A server +//! without the stream answers 404, and nothing is said. +//! +//! Every notification shown is remembered by its id (`Notes`), so a click on it or on one of its +//! actions knows which chat or which run it was about. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -90,6 +97,8 @@ pub struct Watch { runs: Mutex>>, /// Set to end the event stream that is followed now; `reset` sets it and puts a new one here. stop: Mutex>, + /// The notifications shown, so a click on one knows what it was about. + pub notes: Notes, } /// How a scheduled task's run is told. @@ -108,6 +117,95 @@ pub enum Told { pub struct Ran { pub title: String, pub told: Told, + pub schedule: String, + pub run: String, + /// The server's own words for it (`run.notification.body`): the result's first lines, or the + /// failure said. None from a server that does not make them. + pub body: Option, + /// The actions it offers, `(id, label)`, in the server's order: only those the app can do. + pub actions: Vec<(String, String)>, +} + +/// The actions of a scheduled run's notification the app knows how to do. +pub const RUN_ACTIONS: [&str; 3] = ["open", "run_again", "pause"]; + +/// The most of a result's first lines a notification carries: the server cuts them already. +const BODY_MAX: usize = 400; + +/// The words and actions of a run's own notification (`run.notification`), as far as they are +/// usable: a body without control characters and not too long; actions the app knows, each with +/// its label (the id when the server gave none). +fn run_notification(run: &Value) -> (Option, Vec<(String, String)>) { + let Some(n) = run.get("notification").filter(|n| n.is_object()) else { + return (None, Vec::new()); + }; + let body = n + .get("body") + .and_then(Value::as_str) + .map(|b| { + b.chars() + .map(|c| if c.is_control() && c != '\n' { ' ' } else { c }) + .take(BODY_MAX) + .collect::() + .trim() + .to_string() + }) + .filter(|b| !b.is_empty()); + let labels = n.get("labels"); + let actions = n + .get("actions") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .filter_map(Value::as_str) + .filter(|a| RUN_ACTIONS.contains(a)) + .map(|a| { + let label = labels + .and_then(|l| l.get(a)) + .and_then(Value::as_str) + .filter(|l| !l.trim().is_empty()) + .unwrap_or(a); + (a.to_string(), label.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + (body, actions) +} + +/// What a notification shown was about. +#[derive(Clone, Debug, PartialEq)] +pub enum Note { + /// A chat whose reply or work finished. + Chat(String), + /// A scheduled task's run. + Run { schedule: String, run: String }, +} + +/// The notifications shown, by id: the most recent `NOTES_KEPT`. +#[derive(Default)] +pub struct Notes { + next: Mutex, + by_id: Mutex>, +} + +const NOTES_KEPT: i32 = 200; + +impl Notes { + /// Remember a notification about to be shown; its id. + pub fn add(&self, note: Note) -> i32 { + let mut next = self.next.lock().unwrap(); + *next = next.checked_add(1).unwrap_or(1); + let id = *next; + let mut by_id = self.by_id.lock().unwrap(); + by_id.insert(id, note); + by_id.retain(|k, _| *k > id - NOTES_KEPT); + id + } + + pub fn get(&self, id: i32) -> Option { + self.by_id.lock().unwrap().get(&id).cloned() + } } /// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not. @@ -212,6 +310,7 @@ impl Watch { .unwrap_or(0), ); if let Some(told) = told { + let (body, actions) = run_notification(run); out.push(Ran { title: row .get("title") @@ -219,6 +318,10 @@ impl Watch { .unwrap_or("") .to_string(), told, + schedule: id.to_string(), + run: run_id.to_string(), + body, + actions, }); } } @@ -259,13 +362,75 @@ mod tests { })).collect::>()}) } - fn ran(id: &str, told: Told) -> Ran { + fn ran(id: &str, run: &str, told: Told) -> Ran { Ran { title: format!("Task {id}"), told, + schedule: id.into(), + run: run.into(), + body: None, + actions: vec![], } } + #[test] + fn a_runs_own_notification_gives_its_first_lines_and_the_actions_the_app_can_do() { + let w = Watch::default(); + let row = |run: Value| { + json!({"version": "v", "schedules": [{ + "id": "s1", "title": "Waybread check", "notify": "matters", "failure_streak": 3, + "last_run": run, + }]}) + }; + w.look_schedules(&row(json!({"id": "r0", "status": "running"}))); + let out = w.look_schedules(&row(json!({"id": "r0", "status": "failed", "notified": true, + "notification": {"title": "Waybread check", "body": "Failed 3 times\u{7} in a row. Pause it?", + "actions": ["open", "run_again", "pause", "unknown"], + "labels": {"open": "Otvoriť", "run_again": "Spustiť znova", "pause": ""}}}))); + assert_eq!(out.len(), 1); + assert_eq!(out[0].told, Told::Pause); + assert_eq!( + (out[0].schedule.as_str(), out[0].run.as_str()), + ("s1", "r0") + ); + assert_eq!( + out[0].body.as_deref(), + Some("Failed 3 times in a row. Pause it?") + ); + assert_eq!( + out[0].actions, + vec![ + ("open".to_string(), "Otvoriť".to_string()), + ("run_again".to_string(), "Spustiť znova".to_string()), + ("pause".to_string(), "pause".to_string()), + ] + ); + } + + #[test] + fn a_notification_is_found_again_by_its_id() { + let notes = Notes::default(); + let a = notes.add(Note::Chat("c1".into())); + let b = notes.add(Note::Run { + schedule: "s1".into(), + run: "r1".into(), + }); + assert_ne!(a, b); + assert_eq!(notes.get(a), Some(Note::Chat("c1".into()))); + assert_eq!( + notes.get(b), + Some(Note::Run { + schedule: "s1".into(), + run: "r1".into() + }) + ); + assert_eq!(notes.get(b + 1), None); + for _ in 0..NOTES_KEPT { + notes.add(Note::Chat("x".into())); + } + assert_eq!(notes.get(a), None); + } + #[test] fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() { let w = Watch::default(); @@ -290,7 +455,10 @@ mod tests { ("c", "matters", 0, Some(("r4", "silent", false))), ("d", "never", 0, Some(("r5", "ok", false))), ])); - assert_eq!(out, vec![ran("a", Told::Result), ran("b", Told::Result)]); + assert_eq!( + out, + vec![ran("a", "r2", Told::Result), ran("b", "r3", Told::Result)] + ); // The same list again (the server told a device meanwhile): not said twice. assert!(w .look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))])) diff --git a/tests/ui/fixtures/SOURCE b/tests/ui/fixtures/SOURCE index 0d68d14..2dbb0f8 100644 --- a/tests/ui/fixtures/SOURCE +++ b/tests/ui/fixtures/SOURCE @@ -1 +1 @@ -LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2 +LLeMbas origin/dev (e0c2332d688f69e5a7efe157f8263eac02ac28d6) tests/fixtures/api_v2 diff --git a/tests/ui/fixtures/event_schedules.json b/tests/ui/fixtures/event_schedules.json index 19b480d..662d565 100644 --- a/tests/ui/fixtures/event_schedules.json +++ b/tests/ui/fixtures/event_schedules.json @@ -1,5 +1,5 @@ { - "version": "d2f307bf1c7b8d6b", + "version": "8bee85746004dc19", "schedules": [ { "id": "sched1", @@ -52,6 +52,7 @@ "fired_count": 3, "failure_streak": 0, "watch": null, + "runs_on": null, "last_error": null, "last_run": { "id": "run1", diff --git a/tests/ui/fixtures/schedule.json b/tests/ui/fixtures/schedule.json new file mode 100644 index 0000000..60be838 --- /dev/null +++ b/tests/ui/fixtures/schedule.json @@ -0,0 +1,107 @@ +{ + "schedule": { + "id": "sched1", + "title": "Waybread check", + "instruction": "Check the waybread is wrapped.", + "request": "every second Monday at 7:30", + "rule": { + "at": { + "nth": [ + { + "n": 2, + "weekday": 0 + } + ], + "times": [ + "07:30" + ] + } + }, + "description": "On the second Monday of each month at 07:30", + "timezone": "UTC", + "enabled": true, + "next_run": "2026-01-12T07:30:00+00:00", + "next_runs": [ + "2026-01-12T07:30:00+00:00", + "2026-02-09T07:30:00+00:00", + "2026-03-09T07:30:00+00:00", + "2026-04-13T07:30:00+00:00", + "2026-05-11T07:30:00+00:00" + ], + "target": "chat", + "mode": "fresh", + "notify": "matters", + "catch_up": "one", + "grace_minutes": null, + "model": "qwen", + "effort": "high", + "agents": null, + "tools": [ + "files", + "recall", + "search", + "fetch", + "memory", + "skills", + "plan" + ], + "origin": "user", + "chat_id": "chat1", + "fired_count": 3, + "failure_streak": 0, + "watch": null, + "runs_on": null, + "last_error": null, + "last_run": { + "id": "run1", + "schedule_id": "sched1", + "status": "ok", + "timing": "late", + "missed": 0, + "due_at": "2026-01-02T03:04:05+00:00", + "started_at": "2026-01-02T03:04:05+00:00", + "finished_at": "2026-01-02T03:04:05+00:00", + "waiting_for": null, + "retry_at": null, + "progress": null, + "attempts": 1, + "model": { + "id": "gpt-oss", + "name": "GPT-OSS", + "reason": "switched", + "switched_from": "Qwen" + }, + "tokens": { + "prompt": 1200, + "completion": 340 + }, + "cost": null, + "chat_id": "chat1", + "message_id": "msg2", + "report_path": null, + "preview": "All the waybread is wrapped.", + "error": null, + "notified": true, + "notification": { + "title": "Waybread check", + "body": "All the waybread is wrapped.", + "public": { + "title": "Waybread check", + "body": "Finished" + }, + "actions": [ + "open", + "run_again" + ], + "labels": { + "open": "Open", + "run_again": "Run again" + }, + "ask": "" + }, + "watch": null + }, + "running": false, + "created_at": "2026-01-02T03:04:05+00:00" + } +} diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 91d94fa..88128a1 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -1526,6 +1526,30 @@ test("I8: at 390 px the composer with Dictate and the drawing dialog stay inside assert.deepEqual(p.errors, []) }) +// J8: a notification clicked (Rust: notification_action) opens the chat or the scheduled task. + +test("J8: a scheduled run's notification opens its task, and a chat's opens the chat", async () => { + const p = await page({ + status: status(), + routes: baseRoutes({ + "GET /schedules": { schedules: fixture("event_schedules").schedules }, + "GET /schedules/sched1": fixture("schedule"), + "GET /schedules/sched1/runs": { runs: [fixture("schedule_run").run || fixture("schedule_run")], more: false }, + "GET /schedules/models": { models: [], instance: [], mine: [] }, + "GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?")], more: false, live: null }, + }), + streams: {}, + }) + await p.waitForSelector("#composer") + await p.evaluate(() => window.__mock.emit("open-schedule", { schedule: "sched1", run: "run1" })) + await p.waitForSelector("#page-scheduled:not([hidden])") + await p.waitForFunction(() => window.__mock.api("GET", "/schedules/sched1").length >= 1) + await p.waitForFunction(() => /Waybread check/.test(document.getElementById("scheduled-title").textContent)) + await p.evaluate(() => window.__mock.emit("open-chat", "chat1")) + await p.waitForFunction(() => window.__mock.api("GET", "/chats/chat1").length === 1) + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/js/main.js b/ui/js/main.js index 1b7f278..d8ea96b 100644 --- a/ui/js/main.js +++ b/ui/js/main.js @@ -410,6 +410,16 @@ async function boot() { listen("open-settings", () => { if (view === "app") showSettings(true) }) + // A notification clicked: the chat it told of, or the scheduled task whose run it was. + listen("open-chat", (e) => { + if (view === "app" && typeof e.payload === "string") showChat(e.payload) + }) + listen("open-schedule", (e) => { + const id = e.payload?.schedule + if (view !== "app" || typeof id !== "string") return + showPage("scheduled") + showScheduled(id) + }) status = await invoke("get_status") applyTheme(status.theme) renderBar(status)