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.
- **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
+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
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 <server-ref>" >&2; exit 2; }
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,
};
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::<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
/// (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 {
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,
};
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()
.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::<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
});
@@ -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 || {
+172 -4
View File
@@ -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<Option<HashMap<String, (String, bool)>>>,
/// Set to end the event stream that is followed now; `reset` sets it and puts a new one here.
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.
@@ -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<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.
@@ -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::<Vec<_>>()})
}
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)))]))
+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": [
{
"id": "sched1",
@@ -52,6 +52,7 @@
"fired_count": 3,
"failure_streak": 0,
"watch": null,
"runs_on": null,
"last_error": null,
"last_run": {
"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, [])
})
// 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] || ""
+10
View File
@@ -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)