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
+420
-16
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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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}")" \
|
||||
|
||||
+98
-9
@@ -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 {
|
||||
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::<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
@@ -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 @@
|
||||
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": [
|
||||
{
|
||||
"id": "sched1",
|
||||
@@ -52,6 +52,7 @@
|
||||
"fired_count": 3,
|
||||
"failure_streak": 0,
|
||||
"watch": null,
|
||||
"runs_on": null,
|
||||
"last_error": null,
|
||||
"last_run": {
|
||||
"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, [])
|
||||
})
|
||||
|
||||
// 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] || ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user