From e45d14b6b5a6329a1a0440e219ac8917edad9d5a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:08:47 -0300 Subject: [PATCH 1/2] feat(browser): a browser nobody is using rests A bot's Edge kept working after the bot was done: a page with a timer and an animation took 84% of a core with nobody watching, and it only closed after 10 minutes with the panel shut, never with it open. Now the browser rests the moment the bot's turn ends (its state leaves `busy` and `needs_approval`): the daemon takes a picture of the active tab, for the panel, and minimizes Edge's window. The pages count as hidden, so timers, animations and frames stop, as in any minimized window; the same page then takes under 1% of a core. Nothing in the pages is lost. It wakes at once for the bot's next browser tool, for the owner's hands and for a reload, and rests again by itself 20 s after the owner used it while the bot is out of a turn. `BrowserState.resting` tells the app. Closing for lack of use no longer waits for nobody to be watching: the panel opens by itself and kept the browser open for good. Use is a tool of the bot or something the owner did with the browser in their hands, and a browser in their hands does not close. `Page.setWebLifecycleState` was tried first and does not work for this: back to `active`, the page stayed hidden and slow. Spec 21.2, 21.7, 21.8, 21.9 and 21.11 (N3.3), and section 19 has the measurements. --- app/src/lib/fakeBrowser.ts | 15 +- app/src/lib/protocol.gen.ts | 5 + crates/botloft-core/src/protocol/browser.rs | 4 + crates/botloftd/src/browser/hands.rs | 25 +++- crates/botloftd/src/browser/mod.rs | 19 +-- crates/botloftd/src/browser/moves.rs | 11 +- crates/botloftd/src/browser/rest.rs | 143 ++++++++++++++++++++ crates/botloftd/src/browser/session.rs | 2 + crates/botloftd/src/browser/sweep.rs | 140 ++++++++++++++++--- crates/botloftd/tests/browser_rest.rs | 104 ++++++++++++++ crates/botloftd/tests/common/site.rs | 10 +- docs/spec.md | 15 +- 12 files changed, 446 insertions(+), 47 deletions(-) create mode 100644 crates/botloftd/src/browser/rest.rs create mode 100644 crates/botloftd/tests/browser_rest.rs diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts index 256c8ad1..6e23e376 100644 --- a/app/src/lib/fakeBrowser.ts +++ b/app/src/lib/fakeBrowser.ts @@ -50,6 +50,7 @@ export class FakeBrowser { tabs: [], error: null, control: "bot", + resting: false, ask: null, updatedAt: this.fake.now, } @@ -79,7 +80,13 @@ export class FakeBrowser { tabs.length === 0 ? [{ id: this.fake.id("tab"), url, title, active: true }] : tabs.map((tab) => (tab.active ? { ...tab, url, title } : tab)); - return this.setTabs(botId, next, { status: "open", loading: false, error: null }); + const awake = { status: "open", loading: false, error: null, resting: false } as const; + return this.setTabs(botId, next, awake); + } + + /** The bot's turn ended: its browser rests until someone uses it. */ + rest(botId: BotId): BrowserState { + return this.set(botId, { resting: true }); } /** Another tab opened, and became the active one. */ @@ -92,7 +99,8 @@ export class FakeBrowser { close(botId: BotId): BrowserState { this.frames.delete(botId); - return this.setTabs(botId, [], { status: "closed", loading: false, control: "bot", ask: null }); + const closed = { status: "closed", loading: false, control: "bot", resting: false } as const; + return this.setTabs(botId, [], { ...closed, ask: null }); } /** The bot asks the owner for a hand: a request in the chat and in the panel. */ @@ -232,7 +240,7 @@ export class FakeBrowser { if (this.state(botId).status !== "open") { throw conflict("the browser is not open"); } - return this.set(botId, { control: "owner" }); + return this.set(botId, { control: "owner", resting: false }); }, "browser.release": ({ botId }) => { const approvalId = this.asks.get(botId); @@ -255,6 +263,7 @@ export class FakeBrowser { throw conflict("the browser is not open"); } this.reloads.push(botId); + this.set(botId, { resting: false }); this.repaint(botId); return null; }, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index fd866774..423806a8 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -860,6 +860,11 @@ error: string | null, * Who uses it now (spec 21.10). */ control: BrowserControl, +/** + * Nobody uses it, so it rests: its pages stand still until the bot or + * the owner needs them (spec 21.2). + */ +resting: boolean, /** * What the bot asked the owner to do in it, while it waits. */ diff --git a/crates/botloft-core/src/protocol/browser.rs b/crates/botloft-core/src/protocol/browser.rs index 9b2ab766..90be0617 100644 --- a/crates/botloft-core/src/protocol/browser.rs +++ b/crates/botloft-core/src/protocol/browser.rs @@ -50,6 +50,9 @@ pub struct BrowserState { pub error: Option, /// Who uses it now (spec 21.10). pub control: BrowserControl, + /// Nobody uses it, so it rests: its pages stand still until the bot or + /// the owner needs them (spec 21.2). + pub resting: bool, /// What the bot asked the owner to do in it, while it waits. pub ask: Option, /// Unix time in milliseconds. @@ -67,6 +70,7 @@ impl BrowserState { tabs: Vec::new(), error: None, control: BrowserControl::Bot, + resting: false, ask: None, updated_at: now, } diff --git a/crates/botloftd/src/browser/hands.rs b/crates/botloftd/src/browser/hands.rs index e861347a..e32c5b77 100644 --- a/crates/botloftd/src/browser/hands.rs +++ b/crates/botloftd/src/browser/hands.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use std::time::Duration; +use std::time::{Duration, Instant}; use botloft_core::ids::{ApprovalId, BotId}; use botloft_core::protocol::{BrowserControl, BrowserInput}; @@ -13,6 +13,7 @@ use tracing::debug; use super::input::fits; use super::moves::{Move, feed}; +use super::rest::wake; use super::session::Session; use super::{Browsers, Slots, Viewport, lock, sites, update}; use crate::clock::Clock; @@ -74,7 +75,17 @@ impl Browsers { self.set_state(bot, |state| state.control = BrowserControl::Owner); debug!(bot = %bot, "browser: the owner took it"); let (moves, queue) = mpsc::unbounded_channel(); - tokio::spawn(feed(Arc::clone(&session), queue)); + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let (page, owner) = (Arc::clone(&session), bot.clone()); + tokio::spawn(async move { + // A browser at rest wakes for the owner's hands. + wake(&page, &slots, &events, clock.as_ref(), &owner).await; + feed(page, queue).await; + }); Ok(Hands { slots: Arc::clone(&self.slots), events: self.events.clone(), @@ -156,12 +167,16 @@ impl Drop for Asking<'_> { } impl Hands { - /// The page's size, while the owner still has the browser. + /// The page's size, while the owner still has the browser. What they + /// do counts as using it. fn page(&self) -> Result { lock(&self.slots) - .get(&self.bot) + .get_mut(&self.bot) .filter(|slot| *slot.held.borrow() == Some(self.hold)) - .map(|slot| slot.viewport) + .map(|slot| { + slot.used = Instant::now(); + slot.viewport + }) .ok_or(InputError::NotHeld) } diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs index 4db10c74..04f0902a 100644 --- a/crates/botloftd/src/browser/mod.rs +++ b/crates/botloftd/src/browser/mod.rs @@ -13,6 +13,7 @@ mod launch; mod moves; mod page; mod read; +mod rest; mod session; mod settle; pub mod sites; @@ -41,7 +42,7 @@ pub use self::launch::find as find_program; pub use self::page::{Done, Scroll}; pub use self::read::{READ_MAX, Reading}; pub use self::session::Session; -pub use self::sweep::run; +pub use self::sweep::{Want, run}; pub use self::viewport::Viewport; pub use self::watch::Watching; use crate::clock::Clock; @@ -93,16 +94,6 @@ impl Default for BrowserSettings { } } -/// What should happen to a bot's browser. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Want { - Keep, - /// The bot or its crew is paused. - Close, - /// The bot or its crew is archived: the profile goes too. - Forget, -} - type Frames = tokio::sync::watch::Sender>>; /// Which hold of the owner's has the browser, if any (spec 21.10). type Held = tokio::sync::watch::Sender>; @@ -174,7 +165,8 @@ impl Browsers { }) } - /// Waits for the bot's browser to be free and holds it for one call. + /// Waits for the bot's browser to be free and holds it for one call, + /// awake. pub async fn begin(&self, bot: &BotId) -> Call<'_> { let calls = { let mut slots = lock(&self.slots); @@ -182,6 +174,7 @@ impl Browsers { }; let guard = calls.lock_owned().await; self.touch(bot); + self.wake(bot).await; Call { browsers: self, bot: bot.clone(), @@ -189,7 +182,7 @@ impl Browsers { } } - fn touch(&self, bot: &BotId) { + pub(super) fn touch(&self, bot: &BotId) { if let Some(slot) = lock(&self.slots).get_mut(bot) { slot.used = Instant::now(); } diff --git a/crates/botloftd/src/browser/moves.rs b/crates/botloftd/src/browser/moves.rs index 2be731f7..0c9036f1 100644 --- a/crates/botloftd/src/browser/moves.rs +++ b/crates/botloftd/src/browser/moves.rs @@ -12,6 +12,7 @@ use serde_json::json; use tokio::sync::mpsc; use tracing::debug; +use super::rest::wake; use super::session::{Session, Tab}; use super::{BrowserError, Browsers}; @@ -32,12 +33,20 @@ pub(super) enum Move { impl Browsers { /// Reloads the active tab of the bot's browser, in the owner's hands or - /// not. `false` when the browser is not open. + /// not, waking it if it rests. `false` when the browser is not open. pub fn reload(&self, bot: &BotId) -> bool { let Some(session) = self.running(bot) else { return false; }; + self.touch(bot); + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let bot = bot.clone(); tokio::spawn(async move { + wake(&session, &slots, &events, clock.as_ref(), &bot).await; if let Err(err) = session.reload().await { debug!("browser: the page did not reload: {err}"); } diff --git a/crates/botloftd/src/browser/rest.rs b/crates/botloftd/src/browser/rest.rs new file mode 100644 index 00000000..e9d3c5d8 --- /dev/null +++ b/crates/botloftd/src/browser/rest.rs @@ -0,0 +1,143 @@ +//! A browser nobody is using rests (spec 21.2): its window is minimized, so +//! its pages stop working as in any window out of sight, and it comes back +//! the moment the bot or the owner needs it. Nothing in the pages is lost. + +use std::sync::Arc; + +use botloft_core::ids::BotId; +use botloft_core::protocol::BrowserFrame; +use serde_json::{Value, json}; +use tokio::sync::broadcast; +use tracing::debug; + +use super::session::Session; +use super::{Browsers, Slots, lock, update}; +use crate::clock::Clock; +use crate::state::Event; + +impl Session { + pub fn is_resting(&self) -> bool { + self.lock().resting + } + + /// Minimizes every window of the browser, or brings them back. + async fn show(&self, shown: bool) { + let tabs: Vec = { + let tabs = self.lock(); + tabs.list.iter().map(|tab| tab.target.clone()).collect() + }; + let mut windows: Vec = Vec::new(); + for tab in tabs { + let params = json!({ "targetId": tab }); + // A tab that closed meanwhile has no window to look for. + if let Ok(found) = self + .cdp + .call(None, "Browser.getWindowForTarget", params) + .await + && !windows.contains(&found["windowId"]) + { + windows.push(found["windowId"].clone()); + } + } + let state = if shown { "normal" } else { "minimized" }; + for window in windows { + let params = json!({ "windowId": window, "bounds": { "windowState": state } }); + if let Err(err) = self.cdp.call(None, "Browser.setWindowBounds", params).await { + debug!("browser: changing a window to {state}: {err}"); + } + } + } +} + +/// Brings a resting browser back before something uses it, with frames for +/// whoever watches. +pub(super) async fn wake( + session: &Session, + slots: &Slots, + events: &broadcast::Sender, + clock: &dyn Clock, + bot: &BotId, +) { + let _turn = session.syncing.lock().await; + if !session.is_resting() { + return; + } + session.show(true).await; + let watched = { + let mut tabs = session.lock(); + tabs.resting = false; + tabs.active() + .filter(|_| tabs.watching) + .map(|tab| tab.session.clone()) + }; + if let Some(page) = watched { + session.cast(&page, false).await; + session.cast(&page, true).await; + } + update(slots, events, clock, bot, |state| state.resting = false); + debug!(bot = %bot, "browser: awake"); +} + +impl Browsers { + /// Wakes the bot's browser if it rests. + pub(super) async fn wake(&self, bot: &BotId) { + if let Some(session) = self.running(bot) { + wake( + &session, + &self.slots, + &self.events, + self.clock.as_ref(), + bot, + ) + .await; + } + } + + /// Puts the bot's browser to rest, unless a tool or the owner is using + /// it. The picture of its page stays for whoever looks meanwhile. + pub fn rest(&self, bot: &BotId) { + let found = lock(&self.slots).get(bot).and_then(|slot| { + let (_, session) = slot.session.as_ref()?; + if slot.held.borrow().is_some() || session.is_resting() || session.is_closed() { + return None; + } + let turn = Arc::clone(&slot.calls).try_lock_owned().ok()?; + Some((Arc::clone(session), turn, slot.frames.clone())) + }); + let Some((session, turn, frames)) = found else { + return; + }; + let (slots, events, clock) = ( + Arc::clone(&self.slots), + self.events.clone(), + Arc::clone(&self.clock), + ); + let bot = bot.clone(); + tokio::spawn(async move { + // No tool starts while the browser goes to rest. + let _turn = turn; + let _one = session.syncing.lock().await; + let held = lock(&slots) + .get(&bot) + .is_none_or(|slot| slot.held.borrow().is_some()); + if held || session.is_resting() { + return; + } + if let Ok(data) = session.screenshot().await { + let page = session.viewport(); + frames.send_replace(Some(Arc::new(BrowserFrame { + bot_id: bot.clone(), + data, + width: page.width, + height: page.height, + }))); + } + session.show(false).await; + session.lock().resting = true; + update(&slots, &events, clock.as_ref(), &bot, |state| { + state.resting = true; + }); + debug!(bot = %bot, "browser: resting"); + }); + } +} diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index 2d59f88c..5f0366ff 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -72,6 +72,8 @@ pub struct Tabs { pub notes: Vec, pub(super) downloads: HashMap, pub watching: bool, + /// Its window is minimized while nobody uses it (`rest.rs`). + pub(super) resting: bool, pub closed: bool, pub viewport: Viewport, /// The tab the bot was on when the owner took the browser, until the diff --git a/crates/botloftd/src/browser/sweep.rs b/crates/botloftd/src/browser/sweep.rs index 6b035ca8..7b923bab 100644 --- a/crates/botloftd/src/browser/sweep.rs +++ b/crates/botloftd/src/browser/sweep.rs @@ -1,26 +1,61 @@ -//! Closing the browsers nobody needs (spec 21.2): idle ones, and those of -//! bots that are paused or archived. +//! Looking after the browsers nobody needs (spec 21.2): one whose bot ended +//! its turn rests at once, idle ones close, and so do those of bots that +//! are paused or archived. use std::sync::Arc; use std::time::Duration; use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tokio::sync::broadcast::error::RecvError; -use super::{Browsers, Want, lock}; +use super::{Browsers, lock}; use crate::service::{bots, crews}; -use crate::state::Daemon; +use crate::state::{Daemon, Event}; const SWEEP_EVERY: Duration = Duration::from_secs(30); +/// How long after its last use an awake browser goes back to rest, when +/// its bot is not in a turn. +const REST_AFTER: Duration = Duration::from_secs(20); + +/// What should happen to a bot's browser. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Want { + /// The bot is in a turn: the browser is its to use. + Keep, + /// The bot is not working: the browser may rest. + Rest, + /// The bot or its crew is paused. + Close, + /// The bot or its crew is archived: the profile goes too. + Forget, +} pub async fn run(daemon: Arc) { let mut tick = tokio::time::interval(SWEEP_EVERY); tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut events = daemon.subscribe(); loop { - tick.tick().await; - daemon.browsers.sweep(|bot| want(&daemon, bot)); + tokio::select! { + _ = tick.tick() => daemon.browsers.sweep(|bot| want(&daemon, bot)), + event = events.recv() => match event { + // The turn ended: the browser rests right away. + Ok(Event::BotState(change)) if !working(change.state) => { + daemon.browsers.rest(&change.bot_id); + } + // What was missed, the next sweep sees. + Ok(_) | Err(RecvError::Lagged(_)) => {} + Err(RecvError::Closed) => return, + }, + } } } +/// Whether a bot in `state` is in a turn, its browser in use or about to be. +fn working(state: BotState) -> bool { + matches!(state, BotState::Busy | BotState::NeedsApproval) +} + fn want(daemon: &Daemon, bot: &BotId) -> Want { let store = daemon.store(); let Ok(record) = bots::find(&store, bot) else { @@ -33,29 +68,59 @@ fn want(daemon: &Daemon, bot: &BotId) -> Want { Want::Forget } else if record.paused || crew.paused { Want::Close - } else { + } else if daemon + .supervisor + .status(bot) + .is_some_and(|(state, _)| working(state)) + { Want::Keep + } else { + Want::Rest + } +} + +/// What the sweep does with an open browser that is in no tool call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Step { + Stay, + Rest, + Close, + Forget, +} + +/// `held` by the owner, and unused by the bot and by them for `quiet`. +/// Watching is not using: the panel opens by itself and would keep the +/// browser open for good. +fn step(want: Want, held: bool, quiet: Duration, idle: Duration) -> Step { + match want { + Want::Forget => Step::Forget, + Want::Close => Step::Close, + _ if held => Step::Stay, + _ if quiet >= idle => Step::Close, + Want::Rest if quiet >= REST_AFTER => Step::Rest, + Want::Keep | Want::Rest => Step::Stay, } } impl Browsers { - /// Closes the browsers nobody used or watched for a while, and those - /// of bots that should not run (spec 21.2). Browsers in a call stay. + /// Closes the browsers nobody used for a while and those of bots that + /// should not run, and rests the ones whose bot is not working (spec + /// 21.2). Browsers in a call stay as they are. pub fn sweep(&self, want: impl Fn(&BotId) -> Want) { - let open: Vec<(BotId, bool)> = lock(&self.slots) + let open: Vec<(BotId, bool, Duration)> = lock(&self.slots) .iter() .filter(|(_, slot)| slot.session.is_some() && slot.calls.try_lock().is_ok()) .map(|(bot, slot)| { - let unused = slot.watchers == 0 && slot.used.elapsed() >= self.settings.idle; - (bot.clone(), unused) + let held = slot.held.borrow().is_some(); + (bot.clone(), held, slot.used.elapsed()) }) .collect(); - for (bot, unused) in open { - match want(&bot) { - Want::Forget => self.forget(&bot), - Want::Close => self.close(&bot), - Want::Keep if unused => self.close(&bot), - Want::Keep => {} + for (bot, held, quiet) in open { + match step(want(&bot), held, quiet, self.settings.idle) { + Step::Forget => self.forget(&bot), + Step::Close => self.close(&bot), + Step::Rest => self.rest(&bot), + Step::Stay => {} } } } @@ -85,3 +150,42 @@ impl Browsers { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const IDLE: Duration = Duration::from_secs(600); + + #[test] + fn a_browser_rests_when_its_bot_is_not_working_and_closes_when_unused() { + let secs = Duration::from_secs; + // In a turn, it stays; out of one, it rests once it is quiet. + assert_eq!(step(Want::Keep, false, secs(120), IDLE), Step::Stay); + assert_eq!(step(Want::Rest, false, secs(5), IDLE), Step::Stay); + assert_eq!(step(Want::Rest, false, secs(20), IDLE), Step::Rest); + // Unused for long enough, it closes, in a turn or not. + assert_eq!(step(Want::Rest, false, IDLE, IDLE), Step::Close); + assert_eq!(step(Want::Keep, false, IDLE, IDLE), Step::Close); + // In the owner's hands it neither rests nor closes by itself. + assert_eq!(step(Want::Rest, true, IDLE, IDLE), Step::Stay); + // A paused or archived bot's browser goes, whoever holds it. + assert_eq!(step(Want::Close, true, secs(0), IDLE), Step::Close); + assert_eq!(step(Want::Forget, true, secs(0), IDLE), Step::Forget); + } + + #[test] + fn only_a_turn_keeps_a_browser_awake() { + assert!(working(BotState::Busy)); + assert!(working(BotState::NeedsApproval)); + for state in [ + BotState::Idle, + BotState::RateLimited, + BotState::AuthError, + BotState::Backoff, + BotState::Offline, + ] { + assert!(!working(state), "{state:?}"); + } + } +} diff --git a/crates/botloftd/tests/browser_rest.rs b/crates/botloftd/tests/browser_rest.rs new file mode 100644 index 00000000..a1ee75d7 --- /dev/null +++ b/crates/botloftd/tests/browser_rest.rs @@ -0,0 +1,104 @@ +//! A bot's browser resting when nobody uses it, with the real Microsoft Edge +//! (spec 21.2): it rests the moment the bot's turn ends, keeps a picture +//! for whoever looks, and wakes for the bot's next tool, the owner's hands +//! and a reload. Skipped where Edge is not installed. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use common::browsing::{Browsing, answer_site, call, setup}; +use common::{Client, stream}; +use serde_json::{Value, json}; + +async fn browser(app: &mut Client) -> Value { + let list = app.call("browser.list", Value::Null).await.expect("list"); + list[0].clone() +} + +/// Waits until the bot's browser rests, or stops resting. +async fn until_resting(app: &mut Client, resting: bool) { + for _ in 0..100 { + if browser(app).await["resting"] == resting { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("the browser never got to resting = {resting}"); +} + +async fn look(b: &mut Browsing) -> String { + b.mcp + .tool_text("browser_look", json!({})) + .await + .expect("look") +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_browser_rests_when_the_turn_ends_and_wakes_when_it_is_used() { + let Some(mut b) = setup().await else { return }; + tokio::spawn(botloftd::browser::run(Arc::clone(&b.t.daemon))); + let id = json!({ "botId": b.bot["id"] }); + let bot: BotId = b.bot["id"].as_str().expect("id").parse().expect("bot id"); + let process = b.t.process_of(&b.bot).await; + + // A turn: the owner asks, and the bot opens a page. + let ask = json!({ "botId": b.bot["id"], "body": "Open the page" }); + b.app.call("messages.send", ask).await.expect("send"); + let line = process.wait_lines(1).await.pop().expect("line"); + b.t.until_state(&b.bot, BotState::Busy).await; + let url = format!("{}/sight", b.site); + let opening = call(&b.mcp, "browser_open", json!({ "url": url })); + answer_site(&mut b.app, true, None).await; + let page = opening.await.expect("task").expect("page"); + assert!(page.contains("Hidden 0 times"), "{page}"); + assert_eq!(browser(&mut b.app).await["resting"], false); + + // The turn ends: the browser rests at once, with nobody asking. + stream::answer(&process, &line, "It is open.").await; + b.t.until_state(&b.bot, BotState::Idle).await; + until_resting(&mut b.app, true).await; + + // Whoever looks now sees the page as it was, though nobody watched. + let view = b + .app + .call("browser.watch", id.clone()) + .await + .expect("watch"); + assert_eq!(view["state"]["resting"], true); + assert_eq!(view["state"]["status"], "open"); + assert!(view["frame"]["data"].as_str().expect("picture").len() > 100); + + // The bot's next tool wakes it: the page was out of sight once, and + // nothing in it was lost. Frames come again. + b.app.forget("browser.frame"); + let page = look(&mut b).await; + assert!(page.contains("Hidden 1 times"), "{page}"); + assert_eq!(browser(&mut b.app).await["resting"], false); + b.app.notification("browser.frame").await; + + // So do the owner's hands, and a browser in them does not rest. + b.t.daemon.browsers.rest(&bot); + until_resting(&mut b.app, true).await; + b.app.call("browser.take", id.clone()).await.expect("take"); + until_resting(&mut b.app, false).await; + b.t.daemon.browsers.rest(&bot); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(browser(&mut b.app).await["resting"], false); + b.app + .call("browser.release", id.clone()) + .await + .expect("release"); + + // And a reload, without taking the browser. + b.t.daemon.browsers.rest(&bot); + until_resting(&mut b.app, true).await; + b.app.call("browser.reload", id).await.expect("reload"); + until_resting(&mut b.app, false).await; + let page = look(&mut b).await; + assert!(page.contains("The owner reloaded the page."), "{page}"); + assert!(page.contains("Hidden 0 times"), "{page}"); +} diff --git a/crates/botloftd/tests/common/site.rs b/crates/botloftd/tests/common/site.rs index 483f5f58..bca71719 100644 --- a/crates/botloftd/tests/common/site.rs +++ b/crates/botloftd/tests/common/site.rs @@ -1,7 +1,8 @@ //! A small web site on 127.0.0.1 for the browser tests: a form, the page //! it leads to, a link that opens a new tab, a button that shows a dialog, //! a sign-in page with its fields at fixed points, for the owner's hands, -//! a page that says how big its window is, and one that counts its loads. +//! a page that says how big its window is, one that counts its loads and +//! one that counts the times it went out of sight. use std::collections::HashMap; use std::net::SocketAddr; @@ -37,6 +38,12 @@ const SIZE: &str = "Size

"; + async fn account(Form(form): Form>) -> Html { let user = form.get("user").cloned().unwrap_or_default(); let length = form @@ -78,6 +85,7 @@ pub async fn serve() -> SocketAddr { .route("/links", get(|| async { Html(POPUP) })) .route("/login", get(|| async { Html(LOGIN) })) .route("/size", get(|| async { Html(SIZE) })) + .route("/sight", get(|| async { Html(SIGHT) })) .route("/account", post(account)); let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); diff --git a/docs/spec.md b/docs/spec.md index 2cc4e10a..b9f2422f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -856,7 +856,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles. **Testado com 2.1.284**: o header chegou com o valor da variável de ambiente | feito (M3) | | Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | | Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283** (interativo) e **2.1.284** (`-p --setting-sources project,local`): `Read(//c/.../**)` em `deny` bloqueou a leitura com "File is in a directory that is denied by your permission settings", sem perguntar, e o `result` listou a negação em `permission_denials` | feito (M4.1) | -| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | +| Edge sem janela controlado por CDP | 21.2, 21.3 | **Visto com o Edge 154** (Windows 11): `--headless=new --remote-debugging-port=0` escreve `DevToolsActivePort` em ~400 ms; `Target.setAutoAttach` no alvo do navegador prende a aba que já existe e cada popup (`openerId`, esperando o depurador); `Emulation.setDeviceMetricsOverride` deixa a tela em 1280 × 800, e chamado de novo com a página aberta muda o `innerHeight` dela na hora; `Page.startScreencast` manda quadros JPEG com o tamanho da tela, também depois dessa mudança (com `maxHeight` no limite de 21.3), mas o quadro do tamanho novo se perde quando há quadros anteriores ainda sem `screencastFrameAck`, e uma página parada não manda outro: o daemon espera esses serem confirmados e recomeça o screencast (`stopScreencast`, `startScreencast`), que então manda um quadro; clique por `Input.dispatchMouseEvent` segue links; `alert` chega em `Page.javascriptDialogOpening`. `Target.targetInfoChanged` chega com o endereço novo da aba, mas com o endereço no lugar do título, e não vem de novo quando a página ganha o título. Ler o título com `Target.getTargetInfo` depois do `Page.loadEventFired` falhou de vez em quando nos testes (ficava o endereço); o `document.title` lido na página, num mundo isolado, não falhou. `Target.createTarget` com `about:blank` abre uma aba que chega por `attachedToTarget` como as outras; `Target.activateTarget` numa aba que ficou atrás a traz de volta, e o screencast dela volta a mandar quadros. Um Edge parado numa página que se mexe (um timer ocupado e uma animação) gastou 84% de um núcleo sem ninguém assistindo e 126% com o screencast, em ~450 MB e 7 a 10 processos. `Browser.setWindowBounds` com `windowState: "minimized"` na janela do `Browser.getWindowForTarget` deixou a página `hidden`, com o timer a menos de um por segundo e o processador abaixo de 1%; `normal` a trouxe de volta inteira, e `Page.captureScreenshot` funcionou em seguida. `Page.setWebLifecycleState` com `frozen` também parou a página, mas `active` a deixou `hidden` e lenta, e um `Page.bringToFront` depois disso travou o `Runtime.evaluate`: não serve. `Emulation.setCPUThrottlingRate` gastou mais, não menos. O user agent traz `HeadlessChrome`. Num popup esperando o depurador, `Page.enable` não responde até `Runtime.runIfWaitingForDebugger`; um mundo isolado com o mesmo nome (`Page.createIsolatedWorld`) volta o mesmo contexto até a página navegar, não enxerga os globais da página e lê `iframe` do mesmo site | feito | | Imagem no resultado de uma tool MCP | 21.4 | Resultado com `{"type": "image", "data", "mimeType"}` em `content` (especificação MCP). **Testado com 2.1.284** (Haiku): pedido só pelas cores do logo no screenshot, o bot respondeu certo, e as cores não estavam no texto da página: o modelo recebe a imagem | feito | | Tools do navegador com o Claude Code real | 21 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): o bot carregou `browser_open`, `browser_click`, `browser_look` e `browser_screenshot` pelo `ToolSearch`; o pedido de example.com apareceu no chat e, permitido, ele abriu a página e clicou no link, que levou ao iana.org; o `browser_screenshot` lá pediu o iana.org, e o segundo screenshot não pediu de novo. O painel mostrou a página e o cursor ao vivo. Pausar o bot fechou o navegador (os processos do Edge sumiram). O log de `debug` só teve nomes de tools, métodos e ids | feito | | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | @@ -1004,7 +1004,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho ## 21. Navegador -Status: **N1, N2, N3, N3.1 e N3.2 implementados** (21.11); N4 é a seção 22. +Status: **N1, N2, N3, N3.1, N3.2 e N3.3 implementados** (21.11); N4 é a seção 22. ### 21.1 O que é @@ -1021,7 +1021,9 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). - Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. - **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar o bot fecha o navegador e apaga o perfil. -- **Fechar:** depois de `[browser] idle_minutes` sem tool do bot e sem ninguém assistindo; quando o bot ou a crew pausa; com `browser_close`. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. +- **Descansar:** um navegador aberto que ninguém usa não deve gastar o computador. Assim que o turno do bot termina (o estado dele deixa de ser `busy` ou `needs_approval`, 7.1), o daemon põe o navegador para descansar: tira um quadro da aba ativa, para o painel ter o que mostrar, e minimiza a janela do Edge (`Browser.setWindowBounds` com `windowState: "minimized"`; a janela já não aparecia, mas para o Edge ela existe). As páginas passam a valer como as de uma janela minimizada: `visibilityState` vira `hidden`, os timers caem para um por segundo ou menos, animações e quadros param. Medido com o Edge 154, uma página que gastava 90% de um núcleo fica abaixo de 1% (19). Nada se perde: abas, formulários e sessões continuam como estavam. Não descansa no meio de uma tool nem nas mãos do dono (21.10). +- **Acordar:** na hora, com a janela de volta a `normal`, na próxima tool `browser_*` do bot, quando o dono assume o controle e quando ele recarrega a página. Depois de algo que o dono fez com o bot fora de um turno, o navegador volta a descansar sozinho: 20 s sem uso, conferido a cada 30 s. +- **Fechar:** depois de `[browser] idle_minutes` sem uso, que é uma tool do bot ou algo que o dono fez com o navegador nas mãos; quando o bot ou a crew pausa; com `browser_close`. Assistir não é uso: o painel abre sozinho (21.8) e seguraria o navegador aberto para sempre. Nas mãos do dono ele não fecha. Se já há `[browser] max_open` navegadores abertos, abrir outro fecha o que está parado há mais tempo, mas nunca um que esteja no meio de uma tool. - Se o processo cai ou o WebSocket fecha, o estado vira `closed` e a próxima tool sobe de novo. ### 21.3 Página @@ -1106,14 +1108,14 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.open` | `botId`, `url` | `null`: leva a aba ativa a um endereço `http` ou `https`; sem esquema, vale `https://`. Só da conexão que controla; o que não é endereço da web é `validation` | - O que muda o navegador responde assim que o pedido é aceito; o resultado chega em `browser.changed` e nos quadros. -- `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), `ask` (o que o bot pediu ao dono com `browser_ask_owner`, enquanto o pedido está aberto) e `updatedAt`. +- `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`. - 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. - 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. - 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). ### 21.8 App (N2) -- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo". Com o navegador aberto e o painel fechado, o botão ganha um ponto que pulsa. O painel abre sozinho quando o navegador do bot sobe e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). +- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo"; com o navegador descansando (21.2), "Em descanso", e a dica diz que ele não gasta o computador e volta na hora em que o bot ou o dono precisar. A tela mostra o quadro tirado ao descansar. Com o navegador aberto e em uso e o painel fechado, o botão ganha um ponto que pulsa; descansando, não. O painel abre sozinho quando o navegador do bot sobe e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). - O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.browser`, 560 px de início), com um botão para ocupar todo o espaço que o chat pode ceder. - Em cima, as abas: uma por página aberta, na ordem em que abriram, com o título e, embaixo dele, o site (ou o nome do arquivo); a ativa em destaque, "Nova aba" numa aba em branco, e **+** no fim. Não cabendo, a fila rola para o lado. Sem o controle elas só mostram o que o bot abriu, e a dica diz para assumir o controle; no controle, um clique troca de aba e **+** abre uma nova, já com o cursor na barra do endereço (21.10). - Depois, a barra do endereço: **Recarregar** (a seta circular), que vale a qualquer momento (21.10); o endereço, com o cadeado ou a roda de carregando; e "Abrir no meu navegador" (`open_url`, 15.2). O endereço é só leitura e selecionável; no controle vira um campo: Enter leva a aba até o que o dono digitou (`browser.open`), Esc desfaz. Embaixo, a tela ao vivo ocupa o espaço que sobra no painel: o app mede o painel abaixo da barra do endereço, tira o lugar da legenda e do botão de assumir (80 px) e manda o resto ao daemon (`browser.resize`, 300 ms depois da última mudança, e de novo ao voltar a assistir); cada quadro aparece no maior tamanho que cabe, na proporção da página. Arrastar a borda do painel, alargá-lo ou mudar o tamanho da janela muda a página junto. O que entra e sai do painel (a legenda, o pedido de ajuda, a faixa do controle, 21.10) não entra na conta: a página nunca muda de tamanho por causa disso, só a imagem encolhe um pouco para caber. Sobre a tela, o cursor do bot (a seta, na cor dele) indo até cada ponto de `browser.action`, um anel no clique e uma legenda curta do que ele fez ("Clicou em Entrar", "Escreveu em E-mail", "Abriu exemplo.com"). @@ -1130,7 +1132,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] ```toml [browser] path = "" # vazio = Microsoft Edge do Windows - idle_minutes = 10 # sem uso e sem ninguém assistindo, fecha + idle_minutes = 10 # sem uso, fecha (21.2) max_open = 4 # navegadores abertos ao mesmo tempo ``` @@ -1185,6 +1187,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro | **N3** Dono no controle | `browser.take`, `browser.release`, `browser.input`, `browser_ask_owner` e o pedido de ajuda, a espera das tools, o controle no painel e o cartão no chat, textos nos três idiomas, testes com o Edge real | com o Claude Code real, um bot numa página de login pede ajuda, o dono assume pelo cartão, digita, devolve, e o bot continua na página já dentro da conta | | **N3.1** Tela do tamanho do painel | `browser.resize`, a página com a altura que o painel pede, a tela ao vivo ocupando o espaço do painel, testes com o Edge real | num painel alto, a página do bot ocupa a altura toda em vez de deixar um vazio; arrastar a borda do painel muda a página junto | | **N3.2** Abas e recarregar | a lista de abas em `BrowserState`, `browser.reload`, `browser.newTab`, `browser.switchTab`, `browser.open`, o aviso ao bot quando o dono troca de aba, as abas e o botão de recarregar no painel, o endereço que o dono digita, textos nos três idiomas, testes com o Edge real | pelo app, o dono vê as abas que o bot abriu, recarrega a página, assume, abre uma aba nova num endereço, troca de aba e devolve; o bot lê que a aba mudou antes de agir | +| **N3.3** Descanso | o navegador descansa quando o turno do bot termina e acorda quando é usado, `resting` em `BrowserState`, fechar por falta de uso mesmo com o painel aberto, a etiqueta "Em descanso", textos nos três idiomas, testes com o Edge real | com um bot real parado numa página que se mexe, o Edge dele não gasta processador; o bot volta a usar a página sem perder nada | | **N4** Telas | o bot desenhando telas (HTML) que aparecem lado a lado numa área de design, atualizadas enquanto ele escreve | seção 22 | ## 22. Telas From c12b4585795665cc61a67bbfbfd78419c32dfa3c Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:08:47 -0300 Subject: [PATCH 2/2] feat(app): the browser panel says when the browser rests In place of "Live" the panel shows "Resting", with a hint that it costs the computer nothing and wakes when the bot or the owner needs it. The browser button in the bot's header no longer says the bot is using a browser that rests. The preview's Analyst has a resting browser. --- app/src/dev/preview.ts | 5 ++++ app/src/features/bots/BotView.tsx | 5 ++-- app/src/features/browser/BrowserPanel.tsx | 22 ++++++++++++---- app/src/features/browser/browser.test.tsx | 31 +++++++++++++++++++++++ app/src/i18n/en/browser.ts | 3 +++ app/src/i18n/es/browser.ts | 3 +++ app/src/i18n/pt-BR/browser.ts | 3 +++ 7 files changed, 65 insertions(+), 7 deletions(-) diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 78c9a345..ff7f1d5d 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -14,6 +14,7 @@ import { seedBrowser } from "./seedBrowser"; import { seedChats } from "./seedChats"; import { seedChief } from "./seedChief"; import { seedHands } from "./seedHands"; +import { activeTab, drawPage } from "./seedPage"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -115,6 +116,10 @@ function seed(fake: FakeBotloft): void { }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); seedBrowser(fake, scout.id); + // Analyst hit the limit in the middle of a search: its browser rests. + fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats"); + fake.browser.paint(analyst.id, (size) => drawPage(activeTab(fake, analyst.id), size)); + fake.browser.rest(analyst.id); seedScreens(fake, research.id); seedHands(fake, research.id); const ops = fake.addCrew("Ops"); diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index acc7791c..4b58529a 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -86,9 +86,10 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { }; const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); + // A browser at rest is open, but the bot is not using it (spec 21.2). const browsing = useApp((state) => { - const status = state.browsers[bot.id]?.status; - return status === "open" || status === "starting"; + const browser = state.browsers[bot.id]; + return browser?.status === "starting" || (browser?.status === "open" && !browser.resting); }); const browserOpen = side === "browser"; const asking = useApp((state) => Boolean(state.browsers[bot.id]?.ask)); diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index a23ce0ae..05f432da 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -3,7 +3,7 @@ // just did. Watching starts when the panel opens and stops when it closes. // The owner can take it into their own hands and give it back (spec 21.10). -import { Globe, LoaderCircle, Maximize2, Minimize2, X } from "lucide-react"; +import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; @@ -43,6 +43,8 @@ export function BrowserPanel({ const [expanded, setExpanded] = useState(false); const caption = useCaption(bot, action); const live = status === "open" && frame !== null; + // Nobody uses it: its pages stand still until someone does (spec 21.2). + const resting = status === "open" && state?.resting === true; const hands = useHands(bot, state); const [focused, setFocused] = useState(false); const ask = state?.ask ?? null; @@ -77,11 +79,21 @@ export function BrowserPanel({

{t.heading} - {live && ( - - - {t.live} + {resting ? ( + + + {t.resting} + ) : ( + live && ( + + + {t.live} + + ) )}

diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 41d96e48..c9429ebf 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -102,6 +102,37 @@ describe("browser panel", () => { await waitFor(() => expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 800 })); }); + test("says when the browser rests, and it is live again once someone uses it", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + fireEvent.click(toggle()); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + expect(within(panel()).getByText("Live")).toBeDefined(); + + // The bot's turn ended: the page stays, and the panel says it rests. + act(() => { + fake.browser.rest(scout.id); + }); + const resting = within(panel()).getByText("Resting"); + expect(resting.title).toContain("Scout isn't using the browser"); + expect(within(panel()).queryByText("Live")).toBeNull(); + expect(within(panel()).getByRole("figure").querySelector("img")).not.toBeNull(); + // Closed, its button does not say the bot is using it. + fireEvent.click(toggle()); + expect(toggle().getAttribute("aria-label")).toBe("Show browser"); + + // The owner's hands wake it. + fireEvent.click(toggle()); + fireEvent.click(await within(panel()).findByRole("button", { name: "Take control" })); + expect(await within(panel()).findByText("You are in control")).toBeDefined(); + expect(within(panel()).queryByText("Resting")).toBeNull(); + expect(fake.browser.state(scout.id).resting).toBe(false); + }); + test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => { const { fake, scout } = crew(); await openScout(fake); diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts index 6aaabaad..2f7685c9 100644 --- a/app/src/i18n/en/browser.ts +++ b/app/src/i18n/en/browser.ts @@ -16,6 +16,9 @@ export const browser = { openOutside: "Open in my browser", openFailed: "Could not open the page", live: "Live", + resting: "Resting", + restingWhy: (bot: string) => + `${bot} isn't using the browser, so it rests and costs your computer nothing. It wakes the moment ${bot} or you need it.`, loading: "Loading…", address: "Address", addressHint: "Type an address and press Enter", diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts index 2fcf8b84..7e835875 100644 --- a/app/src/i18n/es/browser.ts +++ b/app/src/i18n/es/browser.ts @@ -17,6 +17,9 @@ export const browser: Messages["browser"] = { openOutside: "Abrir en mi navegador", openFailed: "No se pudo abrir la página", live: "En vivo", + resting: "En reposo", + restingWhy: (bot) => + `${bot} no está usando el navegador, así que descansa y no gasta tu ordenador. Vuelve en cuanto ${bot} o tú lo necesiten.`, loading: "Cargando…", address: "Dirección", addressHint: "Escribe una dirección y pulsa Enter", diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts index e26e9b54..95c6372e 100644 --- a/app/src/i18n/pt-BR/browser.ts +++ b/app/src/i18n/pt-BR/browser.ts @@ -18,6 +18,9 @@ export const browser: Messages["browser"] = { openOutside: "Abrir no meu navegador", openFailed: "Não foi possível abrir a página", live: "Ao vivo", + resting: "Em descanso", + restingWhy: (bot) => + `${bot} não está usando o navegador, então ele descansa e não gasta o seu computador. Volta na hora em que ${bot} ou você precisar.`, loading: "Carregando…", address: "Endereço", addressHint: "Digite um endereço e aperte Enter",