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 <noreply@anthropic.com>
This commit is contained in:
9 files changed
+417
-13
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 @@
|
|||||||
LLeMbas origin/dev (f7f2ff21d303d82e8c46fabed100311f300052c0) tests/fixtures/api_v2
|
LLeMbas origin/dev (e0c2332d688f69e5a7efe157f8263eac02ac28d6) tests/fixtures/api_v2
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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] || ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user