Skip to content

Commit 513890b

Browse files
committed
feat(browser): the owner's cursor gets there before the click
Click, type and select used to act first and tell the app afterwards, so the panel showed the page change before the bot's cursor moved. They now aim first: the element is found and scrolled into view, browser.action goes out with its point and, while someone watches, the click waits the cursor's glide (560 ms) before it happens. Nobody watching, no wait. The ripple in the panel starts as the cursor arrives.
1 parent 7272fee commit 513890b

8 files changed

Lines changed: 141 additions & 31 deletions

File tree

‎app/src/motion.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@
214214
border: 3px solid;
215215
border-radius: 9999px;
216216
pointer-events: none;
217-
animation: ripple 700ms var(--ease-out) 420ms both;
217+
animation: ripple 700ms var(--ease-out) 520ms both;
218218
}
219219

220220
.bot-typing {

‎crates/botloftd/src/browser/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub use self::call::Call;
3939
pub use self::hands::{Asking, Hands, InputError, OWNER_WAIT, TakeError};
4040
pub use self::keys::{Key, find as find_key, names as key_names};
4141
pub use self::launch::find as find_program;
42-
pub use self::page::{Done, Scroll};
42+
pub use self::page::{Aim, Done, Scroll};
4343
pub use self::read::{READ_MAX, Reading};
4444
pub use self::session::Session;
4545
pub use self::sweep::{Want, run};

‎crates/botloftd/src/browser/page.rs‎

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
//! What a bot does in its active tab (spec 21.4): open, read, click, type,
22
//! choose, press, scroll, go back and look. Each action waits for the page
3-
//! to settle (spec 21.3).
3+
//! to settle (spec 21.3). Acting on an element takes two steps: `aim` finds
4+
//! it and scrolls it into view, so the owner's cursor can get there first
5+
//! (spec 21.7), and then the action itself.
46
57
use serde_json::{Value, json};
68

@@ -21,6 +23,14 @@ pub struct Done {
2123
pub chosen: Option<String>,
2224
}
2325

26+
/// An element found and in view, before anything is done to it.
27+
#[derive(Debug, Clone)]
28+
pub struct Aim {
29+
reference: String,
30+
point: Value,
31+
pub done: Done,
32+
}
33+
2434
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2535
pub enum Scroll {
2636
Down,
@@ -52,7 +62,7 @@ impl Session {
5262
}
5363

5464
/// Scrolls the element into view and finds where to click it.
55-
async fn locate(&self, reference: &str) -> Result<(Value, Done), BrowserError> {
65+
pub async fn aim(&self, reference: &str) -> Result<Aim, BrowserError> {
5666
let point = self.reader(&format!("point({})", json!(reference))).await?;
5767
if point["missing"] == true {
5868
return Err(BrowserError::Stale(reference.to_owned()));
@@ -73,7 +83,24 @@ impl Session {
7383
cover: point["cover"].as_str().map(str::to_owned),
7484
chosen: None,
7585
};
76-
Ok((point, done))
86+
Ok(Aim {
87+
reference: reference.to_owned(),
88+
point,
89+
done,
90+
})
91+
}
92+
93+
/// `aim` for a text field: anything else fails before the cursor goes
94+
/// there.
95+
pub async fn aim_field(&self, reference: &str) -> Result<Aim, BrowserError> {
96+
let aim = self.aim(reference).await?;
97+
if aim.point["text"] != true {
98+
let role = aim.point["role"].as_str().unwrap_or("element");
99+
return Err(BrowserError::Page(format!(
100+
"{reference} is a {role}, not a text field; browser_type only types into text fields"
101+
)));
102+
}
103+
Ok(aim)
77104
}
78105

79106
async fn mouse_click(&self, (x, y): (f64, f64)) -> Result<(), BrowserError> {
@@ -87,27 +114,23 @@ impl Session {
87114
Ok(())
88115
}
89116

90-
pub async fn click(&self, reference: &str) -> Result<Done, BrowserError> {
91-
let (_, done) = self.locate(reference).await?;
117+
pub async fn click(&self, Aim { done, .. }: Aim) -> Result<Done, BrowserError> {
92118
let marks = self.marks();
93119
self.mouse_click(done.point.unwrap_or_default()).await?;
94120
self.settle(marks).await;
95121
Ok(done)
96122
}
97123

124+
/// Types into a field from `aim_field`.
98125
pub async fn type_text(
99126
&self,
100-
reference: &str,
127+
aim: Aim,
101128
text: &str,
102129
submit: bool,
103130
) -> Result<Done, BrowserError> {
104-
let (point, done) = self.locate(reference).await?;
105-
if point["text"] != true {
106-
let role = point["role"].as_str().unwrap_or("element");
107-
return Err(BrowserError::Page(format!(
108-
"{reference} is a {role}, not a text field; browser_type only types into text fields"
109-
)));
110-
}
131+
let Aim {
132+
reference, done, ..
133+
} = aim;
111134
let marks = self.marks();
112135
self.mouse_click(done.point.unwrap_or_default()).await?;
113136
let ready = self
@@ -135,8 +158,12 @@ impl Session {
135158
Ok(done)
136159
}
137160

138-
pub async fn choose(&self, reference: &str, option: &str) -> Result<Done, BrowserError> {
139-
let (_, mut done) = self.locate(reference).await?;
161+
pub async fn choose(&self, aim: Aim, option: &str) -> Result<Done, BrowserError> {
162+
let Aim {
163+
reference,
164+
mut done,
165+
..
166+
} = aim;
140167
let marks = self.marks();
141168
let result = self
142169
.reader(&format!("select({}, {})", json!(reference), json!(option)))
@@ -237,7 +264,7 @@ impl Session {
237264
/// Scrolls until the element is in the middle of the screen.
238265
pub async fn scroll_to(&self, reference: &str) -> Result<Done, BrowserError> {
239266
let marks = self.marks();
240-
let (_, done) = self.locate(reference).await?;
267+
let Aim { done, .. } = self.aim(reference).await?;
241268
self.settle(marks).await;
242269
Ok(done)
243270
}

‎crates/botloftd/src/browser/watch.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ impl Browsers {
3434
}
3535
}
3636

37+
/// Someone has the bot's browser open in the app.
38+
pub fn watched(&self, bot: &BotId) -> bool {
39+
lock(&self.slots)
40+
.get(bot)
41+
.is_some_and(|slot| slot.watchers > 0)
42+
}
43+
3744
/// The room a watching app has for the page, `width` by `height`: the
3845
/// page takes its shape, now or when the browser opens (spec 21.3).
3946
pub fn resize(&self, bot: &BotId, width: u32, height: u32) {

‎crates/botloftd/src/tools/browser.rs‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use tracing::debug;
1212

1313
use super::browser_args::Tool;
1414
use super::browser_help::ask_owner;
15-
use super::browser_reply::{answer, describe, report, unavailable};
15+
use super::browser_reply::{answer, describe, point, report, unavailable};
1616
use super::browser_sites::{allowed, page_allowed};
1717
use super::calls::explain;
1818
use crate::browser::{Done, OWNER_WAIT, Scroll, sites};
@@ -168,19 +168,38 @@ async fn run(
168168
}
169169
return Ok(Reply::Picture(data, line));
170170
}
171-
Tool::Click { reference } => (BrowserActionKind::Click, session.click(&reference).await),
171+
// The cursor goes first; these report before they act.
172+
Tool::Click { reference } => {
173+
let kind = BrowserActionKind::Click;
174+
let aim = session
175+
.aim(&reference)
176+
.await
177+
.map_err(|err| describe(&err))?;
178+
point(daemon, id, kind, &aim).await;
179+
(kind, session.click(aim).await)
180+
}
172181
Tool::Type {
173182
reference,
174183
text,
175184
submit,
176-
} => (
177-
BrowserActionKind::Type,
178-
session.type_text(&reference, &text, submit).await,
179-
),
180-
Tool::Select { reference, option } => (
181-
BrowserActionKind::Select,
182-
session.choose(&reference, &option).await,
183-
),
185+
} => {
186+
let kind = BrowserActionKind::Type;
187+
let aim = session
188+
.aim_field(&reference)
189+
.await
190+
.map_err(|err| describe(&err))?;
191+
point(daemon, id, kind, &aim).await;
192+
(kind, session.type_text(aim, &text, submit).await)
193+
}
194+
Tool::Select { reference, option } => {
195+
let kind = BrowserActionKind::Select;
196+
let aim = session
197+
.aim(&reference)
198+
.await
199+
.map_err(|err| describe(&err))?;
200+
point(daemon, id, kind, &aim).await;
201+
(kind, session.choose(aim, &option).await)
202+
}
184203
Tool::Press { key } => {
185204
let label = Some(key.key.trim().to_owned()).filter(|key| !key.is_empty());
186205
let result = session.press(key).await.map(|()| Done {
@@ -206,7 +225,13 @@ async fn run(
206225
Tool::Open { .. } | Tool::Close | Tool::AskOwner { .. } => unreachable!("handled above"),
207226
};
208227
let done = done.map_err(|err| describe(&err))?;
209-
report(daemon, id, kind, &done);
228+
let pointed = matches!(
229+
kind,
230+
BrowserActionKind::Click | BrowserActionKind::Type | BrowserActionKind::Select
231+
);
232+
if !pointed {
233+
report(daemon, id, kind, &done);
234+
}
210235
let mut notes = Vec::new();
211236
if let Some(cover) = &done.cover {
212237
notes.push(format!(

‎crates/botloftd/src/tools/browser_reply.rs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
//! the action for the owner's cursor, and errors in words the bot can act
33
//! on.
44
5+
use std::time::Duration;
6+
57
use botloft_core::ids::BotId;
68
use botloft_core::protocol::{BrowserAction, BrowserActionKind};
79

810
use super::browser::{Bot, Reply};
911
use super::browser_sites::outside_file;
10-
use crate::browser::{BrowserError, Done, Session, sites};
12+
use crate::browser::{Aim, BrowserError, Done, Session, sites};
1113
use crate::state::Daemon;
1214

1315
/// The page as the bot reads it, with what happened on the way.
@@ -54,6 +56,20 @@ pub(super) async fn answer(
5456
Ok(Reply::Text(out))
5557
}
5658

59+
/// How long the owner's cursor takes to glide to a point (`.bot-cursor` in
60+
/// the app's `motion.css`, 520 ms), with a little to spare.
61+
const GLIDE: Duration = Duration::from_millis(560);
62+
63+
/// Shows the owner where the bot is about to act and, while someone
64+
/// watches, waits for the cursor to get there, so the page changes after
65+
/// the cursor arrives and not before (spec 21.7).
66+
pub(super) async fn point(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, aim: &Aim) {
67+
report(daemon, bot, kind, &aim.done);
68+
if daemon.browsers.watched(bot) {
69+
tokio::time::sleep(GLIDE).await;
70+
}
71+
}
72+
5773
pub(super) fn report(daemon: &Daemon, bot: &BotId, kind: BrowserActionKind, done: &Done) {
5874
daemon.browsers.action(BrowserAction {
5975
bot_id: bot.clone(),

‎crates/botloftd/tests/browser.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
mod common;
66

7+
use std::time::{Duration, Instant};
8+
79
use common::browsing::{answer_site, call, ref_of, setup};
810
use serde_json::{Value, json};
911

@@ -197,3 +199,36 @@ async fn new_tabs_and_dialogs_reach_the_bot_and_the_owner_watches() {
197199
.await
198200
.expect("unwatch");
199201
}
202+
203+
#[tokio::test(flavor = "multi_thread")]
204+
async fn the_owner_sees_the_cursor_get_there_before_the_click() {
205+
let Some(mut b) = setup().await else { return };
206+
b.app
207+
.call("browser.watch", json!({ "botId": b.bot["id"] }))
208+
.await
209+
.expect("watch");
210+
let opening = call(
211+
&b.mcp,
212+
"browser_open",
213+
json!({ "url": format!("{}/links", b.site) }),
214+
);
215+
answer_site(&mut b.app, true, None).await;
216+
let page = opening.await.expect("task").expect("page");
217+
218+
let warn = ref_of(&page, "button \"Warn me\"");
219+
let clicking = call(&b.mcp, "browser_click", json!({ "ref": warn }));
220+
let action = loop {
221+
let action = b.app.notification("browser.action").await;
222+
if action["kind"] == "click" {
223+
break action;
224+
}
225+
};
226+
let pointed = Instant::now();
227+
assert_eq!(action["label"], "Warn me");
228+
assert!(action["x"].as_f64().expect("x") > 0.0);
229+
// The click waits for the cursor's glide in the panel.
230+
assert!(!clicking.is_finished());
231+
let warned = clicking.await.expect("task").expect("clicked");
232+
assert!(warned.contains("\"Hi there\""), "{warned}");
233+
assert!(pointed.elapsed() >= Duration::from_millis(500));
234+
}

‎docs/spec.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1264,7 +1264,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
12641264

12651265
- O que muda o navegador responde assim que o pedido é aceito; o resultado chega em `browser.changed` e nos quadros.
12661266
- `BrowserState`: `botId`, `status` (`closed`, `starting`, `open`, `failed`), `url`, `title` e `loading` (da aba ativa), `tabs` (as abas abertas, na ordem em que abriram: `BrowserTab {id, title, url, active}`, com `id` opaco), `error` (por que não abriu, em `failed`), `control` (`bot` ou `owner`, 21.10), `resting` (o navegador descansa, 21.2), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto) e `updatedAt`.
1267-
- Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado.
1267+
- Notificações para todos: `browser.changed` (`BrowserState`) e `browser.action {botId, kind, x, y, label, at}`, com `kind` `open`, `click`, `type`, `select`, `press`, `scroll` ou `back`; `x` e `y` em pixels da página (o tamanho que vem em `browser.frame`) quando a ação tem um ponto; `label` é o nome do elemento, a tecla ou o site, nunca o texto digitado. Clicar, digitar e escolher avisam antes de agir: o daemon acha o elemento e o traz para a tela, manda `browser.action` com o ponto e, se alguém assiste, espera o cursor deslizar até lá no painel (560 ms, o tempo da animação mais uma folga) antes do clique de verdade; sem ninguém assistindo, não espera. As outras ações avisam depois de feitas. Assim a página muda depois que o cursor chega, e não antes.
12681268
- Só para a conexão que assiste: `browser.frame {botId, data, width, height}`, um JPEG em base64 e o tamanho da página que ele mostra. Cada conexão assiste um bot por vez; `browser.watch` de outro troca, e fechar a conexão para.
12691269
- Os quadros vêm do `Page.startScreencast` (JPEG, qualidade 60, do tamanho da página, 21.3), que roda só enquanto alguém assiste, e só quando a tela muda. O daemon confirma cada quadro no máximo ~15 vezes por segundo. Um app lento não acumula quadros: a conexão manda sempre o mais novo quando consegue, e quadros não contam no limite de 1024 notificações (11.1).
12701270

0 commit comments

Comments
 (0)