Scheduled-result notifications: first lines, Open / Run again / Pause; a click opens
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

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 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 14:08:04 +00:00
1 parent 2b87fa4c29
commit 9b07527c3a
9 files changed
+417 -13

No files matched your search

+5
View File
@@ -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. 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 - **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. 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 ### Fixed
+1 -1
View File
@@ -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 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 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 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 <server-ref>" >&2; exit 2; } [ $# -eq 1 ] || { echo "usage: scripts/fetch-fixtures.sh <server-ref>" >&2; exit 2; }
commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \ commit="$(git -C "$SERVER" rev-parse --verify --quiet "$1^{commit}")" \
+95 -6
View File
@@ -43,7 +43,7 @@ use tauri::{
WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry, WebviewWindow, WebviewWindowBuilder, Window, WindowEvent, Wry,
}; };
use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; 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_opener::OpenerExt;
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
@@ -1656,9 +1656,11 @@ fn watch_chats(handle: AppHandle) {
} else { } else {
d.title.clone() d.title.clone()
}; };
let id = app.watch.notes.add(watch::Note::Chat(d.id.clone()));
let _ = handle let _ = handle
.notification() .notification()
.builder() .builder()
.id(id)
.title(title) .title(title)
.body(if d.agent { &words.agent } else { &words.reply }) .body(if d.agent { &words.agent } else { &words.reply })
.show(); .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::<App>().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::<App>().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 /// How long to wait before following the event stream again: after it closed or broke off, and
/// (longer) on a server that has none. /// (longer) on a server that has none.
const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5); const EVENTS_AGAIN: std::time::Duration = std::time::Duration::from_secs(5);
@@ -1703,17 +1755,48 @@ fn watch_events(handle: AppHandle) {
} else { } else {
r.title r.title
}; };
let body = match r.told { let body = r.body.clone().unwrap_or_else(|| {
match r.told {
watch::Told::Result => &words.scheduled, watch::Told::Result => &words.scheduled,
watch::Told::Failed => &words.scheduled_failed, watch::Told::Failed => &words.scheduled_failed,
watch::Told::Pause => &words.scheduled_pause, watch::Told::Pause => &words.scheduled_pause,
}; }
let _ = handle .clone()
});
let id = app.watch.notes.add(watch::Note::Run {
schedule: r.schedule.clone(),
run: r.run.clone(),
});
let mut note = handle
.notification() .notification()
.builder() .builder()
.id(id)
.title(title) .title(title)
.body(body) .body(body);
.show(); 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::<Vec<_>>()
.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 true
}); });
@@ -1900,6 +1983,12 @@ pub fn run() {
watch_updates(handle.clone()); watch_updates(handle.clone());
watch_chats(handle.clone()); watch_chats(handle.clone());
watch_events(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(); let h = handle.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
+172 -4
View File
@@ -15,8 +15,15 @@
//! else the schedule's `notify` policy as the server applies it (`services/schedule/notify.py`: //! 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; //! `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 //! `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 //! whether to pause it. A server that made the run's notification (`run.notification`, server
//! the stream answers 404, and nothing is said. //! 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::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -90,6 +97,8 @@ pub struct Watch {
runs: Mutex<Option<HashMap<String, (String, bool)>>>, runs: Mutex<Option<HashMap<String, (String, bool)>>>,
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here. /// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
stop: Mutex<Arc<AtomicBool>>, stop: Mutex<Arc<AtomicBool>>,
/// The notifications shown, so a click on one knows what it was about.
pub notes: Notes,
} }
/// How a scheduled task's run is told. /// How a scheduled task's run is told.
@@ -108,6 +117,95 @@ pub enum Told {
pub struct Ran { pub struct Ran {
pub title: String, pub title: String,
pub told: Told, 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<String>,
/// 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<String>, 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::<String>()
.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<i32>,
by_id: Mutex<HashMap<i32, Note>>,
}
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<Note> {
self.by_id.lock().unwrap().get(&id).cloned()
}
} }
/// The run statuses that are an end (`db/models/schedule.py`): `running` and `waiting` are not. /// 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), .unwrap_or(0),
); );
if let Some(told) = told { if let Some(told) = told {
let (body, actions) = run_notification(run);
out.push(Ran { out.push(Ran {
title: row title: row
.get("title") .get("title")
@@ -219,6 +318,10 @@ impl Watch {
.unwrap_or("") .unwrap_or("")
.to_string(), .to_string(),
told, told,
schedule: id.to_string(),
run: run_id.to_string(),
body,
actions,
}); });
} }
} }
@@ -259,13 +362,75 @@ mod tests {
})).collect::<Vec<_>>()}) })).collect::<Vec<_>>()})
} }
fn ran(id: &str, told: Told) -> Ran { fn ran(id: &str, run: &str, told: Told) -> Ran {
Ran { Ran {
title: format!("Task {id}"), title: format!("Task {id}"),
told, 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] #[test]
fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() { fn a_finished_run_is_said_once_by_its_policy_and_a_silent_one_only_when_always() {
let w = Watch::default(); let w = Watch::default();
@@ -290,7 +455,10 @@ mod tests {
("c", "matters", 0, Some(("r4", "silent", false))), ("c", "matters", 0, Some(("r4", "silent", false))),
("d", "never", 0, Some(("r5", "ok", 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. // The same list again (the server told a device meanwhile): not said twice.
assert!(w assert!(w
.look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))])) .look_schedules(&schedules(&[("a", "matters", 0, Some(("r2", "ok", true)))]))
+1 -1
View File
@@ -1 +1 @@
LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2 LLeMbas origin/dev (e0c2332d688f69e5a7efe157f8263eac02ac28d6) tests/fixtures/api_v2
+2 -1
View File
@@ -1,5 +1,5 @@
{ {
"version": "d2f307bf1c7b8d6b", "version": "8bee85746004dc19",
"schedules": [ "schedules": [
{ {
"id": "sched1", "id": "sched1",
@@ -52,6 +52,7 @@
"fired_count": 3, "fired_count": 3,
"failure_streak": 0, "failure_streak": 0,
"watch": null, "watch": null,
"runs_on": null,
"last_error": null, "last_error": null,
"last_run": { "last_run": {
"id": "run1", "id": "run1",
+107
View File
@@ -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"
}
}
+24
View File
@@ -1526,6 +1526,30 @@ test("I8: at 390 px the composer with Dictate and the drawing dialog stay inside
assert.deepEqual(p.errors, []) 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 ---------------------------------------------------------------------------------------- // --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || "" const only = process.argv[2] || ""
+10
View File
@@ -410,6 +410,16 @@ async function boot() {
listen("open-settings", () => { listen("open-settings", () => {
if (view === "app") showSettings(true) 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") status = await invoke("get_status")
applyTheme(status.theme) applyTheme(status.theme)
renderBar(status) renderBar(status)