diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx
index 70617023..57e7b8ef 100644
--- a/app/src/features/browser/BrowserPanel.tsx
+++ b/app/src/features/browser/BrowserPanel.tsx
@@ -4,8 +4,8 @@
// The owner can take it into their own hands and give it back (spec 21.10),
// or open it in a window of its own to sign in (spec 21.11).
-import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
-import { type CSSProperties, useEffect, useRef, useState } from "react";
+import { Globe, Hand, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
+import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
@@ -24,7 +24,7 @@ import { LessonArea, LessonDialog, useLesson } from "./Lesson";
import { LiveView, useCaption } from "./LiveView";
import { TabStrip } from "./TabStrip";
import { useBrowserView } from "./useBrowserView";
-import { useHands } from "./useHands";
+import { type Hands, useHands } from "./useHands";
import { useFitPage, useRoom } from "./useRoom";
import { WindowBar } from "./WindowBar";
@@ -138,7 +138,12 @@ export function BrowserPanel({
)}
) : status === "closed" && !frame ? (
- !windowed &&
+ !windowed && (
+ <>
+
+
+ >
+ )
) : (
<>
{live && ask && !hands.held && }
@@ -178,12 +183,20 @@ export function BrowserPanel({
{(status !== "open" || !frame) && (
+ >
+ {status === "closed" && !windowed && !hands.opening && (
+
+ )}
+
)}
@@ -223,7 +236,17 @@ export function BrowserPanel({
);
}
-function Overlay({ busy, title, body }: { busy: boolean; title: string; body: string | null }) {
+function Overlay({
+ busy,
+ title,
+ body,
+ children,
+}: {
+ busy: boolean;
+ title: string;
+ body: string | null;
+ children?: ReactNode;
+}) {
return (
@@ -232,11 +255,31 @@ function Overlay({ busy, title, body }: { busy: boolean; title: string; body: st
{title}
{body &&
{body}
}
+ {children}
);
}
+/** The owner opens the closed browser for themselves (spec 21.10). */
+function OpenForMe({ bot, hands }: { bot: Bot; hands: Hands }) {
+ const t = useT().browser;
+ return (
+
+
+
{t.openForMeWhy(bot.name)}
+
+ );
+}
+
function Empty({ bot }: { bot: Bot }) {
const t = useT().browser;
return (
diff --git a/app/src/features/browser/openForMe.test.tsx b/app/src/features/browser/openForMe.test.tsx
new file mode 100644
index 00000000..dbee1f43
--- /dev/null
+++ b/app/src/features/browser/openForMe.test.tsx
@@ -0,0 +1,42 @@
+import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+const panel = () => screen.getByRole("complementary", { name: "Scout's browser" });
+
+describe("the owner opens the bot's closed browser", () => {
+ test("on its last page, in their hands, without telling the bot", async () => {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "idle");
+ fake.browser.open(scout.id, "https://example.com/", "Example");
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ fireEvent.click(screen.getByRole("button", { name: /Show browser|Hide browser/ }));
+ await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
+ act(() => {
+ fake.browser.frame(scout.id, "BBBB");
+ });
+ act(() => {
+ fake.browser.close(scout.id);
+ });
+ expect(await within(panel()).findByText("Browser closed")).toBeDefined();
+ expect(within(panel()).getByText(/Scout is not woken or told/)).toBeDefined();
+
+ fireEvent.click(within(panel()).getByRole("button", { name: "Open it for me" }));
+ await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("owner"));
+ expect(fake.browser.state(scout.id).url).toBe("https://example.com/");
+ expect(fake.calls.some((call) => call.method === "messages.send")).toBe(false);
+
+ fireEvent.click(
+ await within(panel()).findByRole("button", { name: "Done, give it back to Scout" }),
+ );
+ await waitFor(() => expect(fake.browser.state(scout.id).control).toBe("bot"));
+ });
+});
diff --git a/app/src/features/browser/useHands.ts b/app/src/features/browser/useHands.ts
index 295c52ed..cf70c5ae 100644
--- a/app/src/features/browser/useHands.ts
+++ b/app/src/features/browser/useHands.ts
@@ -4,7 +4,7 @@
// Events go out in order and without waiting for each other: the daemon
// queues them for the page.
-import { useCallback, useState } from "react";
+import { useCallback, useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, BrowserInput, BrowserState } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
@@ -24,6 +24,10 @@ export interface Hands {
switchTab(tabId: string): Promise;
/** Takes the active tab to the address the owner typed. */
open(url: string): Promise;
+ /** Opens the closed browser for the owner, and takes it once open. */
+ start(): Promise;
+ /** Waiting for the browser opened for the owner. */
+ opening: boolean;
}
export function useHands(bot: Pick, state: BrowserState | null): Hands {
@@ -79,6 +83,27 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand
[api, botId, t.goFailed],
);
+ // Opened for the owner (spec 21.10), it is theirs as soon as it is up.
+ const [opening, setOpening] = useState(false);
+ const start = useCallback(async () => {
+ const asked = await attempt(t.openForMeFailed, async () => {
+ putBrowser(await api.call("browser.start", { botId }));
+ });
+ setOpening(asked);
+ }, [api, botId, putBrowser, t.openForMeFailed]);
+ const status = state?.status;
+ useEffect(() => {
+ if (!opening) {
+ return;
+ }
+ if (status === "open") {
+ setOpening(false);
+ void take();
+ } else if (status === "failed") {
+ setOpening(false);
+ }
+ }, [opening, status, take]);
+
return {
held: state?.status === "open" && state.control === "owner",
busy,
@@ -89,5 +114,7 @@ export function useHands(bot: Pick, state: BrowserState | null): Hand
newTab,
switchTab,
open,
+ start,
+ opening,
};
}
diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts
index 82c5cfed..3aa5a6f1 100644
--- a/app/src/i18n/en/browser.ts
+++ b/app/src/i18n/en/browser.ts
@@ -32,6 +32,10 @@ export const browser = {
starting: "Opening the browser…",
closed: "Browser closed",
closedBody: "It opens again when the bot needs it. Logins stay.",
+ openForMe: "Open it for me",
+ openForMeWhy: (bot: string) =>
+ `To see what ${bot} left open and use it yourself. ${bot} is not woken or told; give it back when you are done and it rests again.`,
+ openForMeFailed: "Could not open the browser",
failedTitle: "The browser couldn't open",
failedBody: (system: string): string =>
system === "Windows"
diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts
index 264f29eb..74a70059 100644
--- a/app/src/i18n/es/browser.ts
+++ b/app/src/i18n/es/browser.ts
@@ -33,6 +33,10 @@ export const browser: Messages["browser"] = {
starting: "Abriendo el navegador…",
closed: "Navegador cerrado",
closedBody: "Se abre de nuevo cuando el bot lo necesite. Las sesiones iniciadas siguen.",
+ openForMe: "Abrirlo para mí",
+ openForMeWhy: (bot) =>
+ `Para ver lo que ${bot} dejó abierto y usarlo tú mismo. ${bot} no se despierta ni se entera; devuélvelo cuando termines y el navegador vuelve a descansar.`,
+ openForMeFailed: "No se pudo abrir el navegador",
failedTitle: "El navegador no se abrió",
failedBody: (system: string) =>
system === "Windows"
diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts
index 42f66965..26925edb 100644
--- a/app/src/i18n/pt-BR/browser.ts
+++ b/app/src/i18n/pt-BR/browser.ts
@@ -34,6 +34,10 @@ export const browser: Messages["browser"] = {
starting: "Abrindo o navegador…",
closed: "Navegador fechado",
closedBody: "Ele abre de novo quando o bot precisar. Os logins continuam.",
+ openForMe: "Abrir para mim",
+ openForMeWhy: (bot) =>
+ `Para ver o que ${bot} deixou aberto e mexer você mesmo. ${bot} não é acordado nem avisado; devolva quando terminar e o navegador volta a descansar.`,
+ openForMeFailed: "Não foi possível abrir o navegador",
failedTitle: "O navegador não abriu",
failedBody: (system: string) =>
system === "Windows"
diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts
index 85145890..a9309db4 100644
--- a/app/src/lib/fakeBrowser.ts
+++ b/app/src/lib/fakeBrowser.ts
@@ -37,6 +37,9 @@ export class FakeBrowser {
/** Pages that answer the owner's hands, for the preview. */
private readonly pages = new Map void>();
+ /** The page each browser showed when it closed, to open it again. */
+ private readonly lastUrls = new Map();
+
constructor(private readonly fake: FakeBotloft) {}
state(botId: BotId): BrowserState {
@@ -101,6 +104,7 @@ export class FakeBrowser {
close(botId: BotId): BrowserState {
this.frames.delete(botId);
+ this.lastUrls.set(botId, this.state(botId).url ?? "about:blank");
const closed = { status: "closed", loading: false, control: "bot", resting: false } as const;
return this.setTabs(botId, [], { ...closed, ask: null });
}
@@ -224,6 +228,7 @@ export class FakeBrowser {
| "browser.switchTab"
| "browser.open"
| "browser.window"
+ | "browser.start"
> {
return {
"browser.list": () =>
@@ -311,6 +316,15 @@ export class FakeBrowser {
this.repaint(botId);
return null;
},
+ // Up a moment later, on its last page, as the daemon does it.
+ "browser.start": ({ botId }) => {
+ if (this.watching !== botId) {
+ throw conflict("watch this browser before opening it");
+ }
+ const url = this.lastUrls.get(botId) ?? "about:blank";
+ setTimeout(() => this.open(botId, url, new URL(url).hostname));
+ return this.set(botId, { status: "starting" });
+ },
"browser.window": ({ botId }) => {
if (this.watching !== botId) {
throw conflict("watch this browser before opening it in a window");
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index ad8ccfab..b3779e02 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -1696,6 +1696,7 @@ export interface RpcMethods {
"browser.take": { params: BrowserControlParams; result: BrowserState };
"browser.release": { params: BrowserControlParams; result: BrowserState };
"browser.window": { params: BrowserControlParams; result: BrowserState };
+ "browser.start": { params: BrowserControlParams; result: BrowserState };
"browser.teach": { params: BrowserTeachParams; result: Array };
"browser.input": { params: BrowserInputParams; result: null };
"browser.reload": { params: BrowserControlParams; result: null };
diff --git a/crates/botloft-core/src/protocol/export_browser.rs b/crates/botloft-core/src/protocol/export_browser.rs
index e27d9910..40b995f3 100644
--- a/crates/botloft-core/src/protocol/export_browser.rs
+++ b/crates/botloft-core/src/protocol/export_browser.rs
@@ -54,6 +54,7 @@ pub(super) fn methods(out: &mut Out) {
out.method(method::BROWSER_TAKE, &control, &state);
out.method(method::BROWSER_RELEASE, &control, &state);
out.method(method::BROWSER_WINDOW, &control, &state);
+ out.method(method::BROWSER_START, &control, &state);
out.method(
method::BROWSER_TEACH,
&out.name::(),
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index 24a89126..4e5fd62a 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -161,6 +161,8 @@ pub mod method {
pub const BROWSER_OPEN: &str = "browser.open";
pub const BROWSER_WINDOW: &str = "browser.window";
pub const BROWSER_TEACH: &str = "browser.teach";
+ /// The owner opens the bot's closed browser for themselves.
+ pub const BROWSER_START: &str = "browser.start";
pub const SCREENS_LIST: &str = "screens.list";
pub const QUESTIONS_LIST: &str = "questions.list";
pub const QUESTIONS_ANSWER: &str = "questions.answer";
diff --git a/crates/botloftd/src/browser/call.rs b/crates/botloftd/src/browser/call.rs
index 96a8b58d..b9bd2b68 100644
--- a/crates/botloftd/src/browser/call.rs
+++ b/crates/botloftd/src/browser/call.rs
@@ -10,6 +10,7 @@ use botloft_core::protocol::{BrowserState, BrowserStatus};
use tokio::sync::OwnedMutexGuard;
use tracing::debug;
+use super::owner_open::remember;
use super::session::{Hooks, PageInfo, Session};
use super::{BrowserError, Browsers, lock, program, update, watch};
@@ -92,6 +93,7 @@ impl Call<'_> {
bot.clone(),
);
move |info: PageInfo| {
+ remember(&slots, &bot, info.url.as_deref());
update(&slots, &events, clock.as_ref(), &bot, |state| {
state.url = info.url;
state.title = info.title;
diff --git a/crates/botloftd/src/browser/mod.rs b/crates/botloftd/src/browser/mod.rs
index c1a68fa0..896e0954 100644
--- a/crates/botloftd/src/browser/mod.rs
+++ b/crates/botloftd/src/browser/mod.rs
@@ -13,6 +13,7 @@ mod keys;
mod launch;
mod lesson;
mod moves;
+mod owner_open;
mod page;
mod program;
mod read;
@@ -46,7 +47,7 @@ pub use self::page::{Aim, Done, Scroll};
pub use self::program::find as find_program;
pub use self::read::{READ_MAX, Reading};
pub use self::session::Session;
-pub use self::sweep::{Want, run};
+pub use self::sweep::{Want, run, want};
pub use self::viewport::Viewport;
pub use self::watch::Watching;
pub use self::window::WindowError;
@@ -119,6 +120,8 @@ struct Slot {
asking: Option,
/// The owner has the profile open in a window of its own.
window: Option,
+ /// The page it showed last, to open again for the owner.
+ last_url: Option,
}
type Slots = Arc>>;
@@ -170,6 +173,7 @@ impl Browsers {
held: tokio::sync::watch::channel(None).0,
asking: None,
window: None,
+ last_url: None,
})
}
diff --git a/crates/botloftd/src/browser/owner_open.rs b/crates/botloftd/src/browser/owner_open.rs
new file mode 100644
index 00000000..db86f22a
--- /dev/null
+++ b/crates/botloftd/src/browser/owner_open.rs
@@ -0,0 +1,42 @@
+//! The owner opens a bot's closed browser for themselves (spec 21.10): on
+//! the page it showed last, without the bot, which is neither woken nor
+//! told. The app then takes it into the owner's hands.
+
+use std::path::Path;
+
+use botloft_core::ids::BotId;
+use tracing::debug;
+
+use super::{BrowserError, Browsers, lock};
+
+impl Browsers {
+ /// Starts the bot's browser if it is closed and opens the last page it
+ /// showed again; a running one stays as it is.
+ pub async fn open_for_owner(&self, bot: &BotId, downloads: &Path) -> Result<(), BrowserError> {
+ let call = self.begin(bot).await;
+ if call.running().is_some() {
+ return Ok(());
+ }
+ let last = lock(&self.slots)
+ .get(bot)
+ .and_then(|slot| slot.last_url.clone());
+ let session = call.session(downloads).await?;
+ if let Some(url) = last
+ && let Err(err) = session.open(&url).await
+ {
+ debug!(bot = %bot, "browser: the last page did not open again: {err}");
+ }
+ debug!(bot = %bot, "browser: opened for the owner");
+ Ok(())
+ }
+}
+
+/// Keeps `url` as the last page of the bot's browser, unless it is blank.
+pub(super) fn remember(slots: &super::Slots, bot: &BotId, url: Option<&str>) {
+ let Some(url) = url.filter(|url| !url.is_empty() && *url != "about:blank") else {
+ return;
+ };
+ if let Some(slot) = lock(slots).get_mut(bot) {
+ slot.last_url = Some(url.to_owned());
+ }
+}
diff --git a/crates/botloftd/src/browser/sweep.rs b/crates/botloftd/src/browser/sweep.rs
index 7b923bab..25f30175 100644
--- a/crates/botloftd/src/browser/sweep.rs
+++ b/crates/botloftd/src/browser/sweep.rs
@@ -56,7 +56,7 @@ fn working(state: BotState) -> bool {
matches!(state, BotState::Busy | BotState::NeedsApproval)
}
-fn want(daemon: &Daemon, bot: &BotId) -> Want {
+pub fn want(daemon: &Daemon, bot: &BotId) -> Want {
let store = daemon.store();
let Ok(record) = bots::find(&store, bot) else {
return Want::Forget;
diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs
index 0c0531ae..76ef1d88 100644
--- a/crates/botloftd/src/rpc/mod.rs
+++ b/crates/botloftd/src/rpc/mod.rs
@@ -246,6 +246,8 @@ fn route(
}
// A window of its own outlives the request (spec 21.11).
method::BROWSER_WINDOW => watch.window(daemon, request.params),
+ // The browser starting outlives the request too (spec 21.10).
+ method::BROWSER_START => watch.start(daemon, request.params),
name if ASIDE.contains(&name) => return Routed::Aside(request),
_ => return Routed::InOrder(request),
};
diff --git a/crates/botloftd/src/rpc/watching.rs b/crates/botloftd/src/rpc/watching.rs
index 3472af32..848eee33 100644
--- a/crates/botloftd/src/rpc/watching.rs
+++ b/crates/botloftd/src/rpc/watching.rs
@@ -14,11 +14,12 @@ use botloft_core::protocol::{
};
use serde::Serialize;
use serde_json::Value;
+use tracing::debug;
use super::dispatch::parse;
use super::jsonrpc::{self, RpcError};
use crate::approvals;
-use crate::browser::{Hands, InputError, Watching};
+use crate::browser::{Hands, InputError, Want, Watching, want};
use crate::service::{ApiError, bots};
use crate::state::Daemon;
@@ -155,6 +156,44 @@ impl Watch {
to_value(&state)
}
+ /// Opens the bot's closed browser for the owner, on its last page,
+ /// without the bot (spec 21.10); answers at once, and the state says
+ /// when it is open, for the app to take it.
+ pub(super) fn start(
+ &mut self,
+ daemon: &Arc,
+ params: Option,
+ ) -> Result {
+ let params: BrowserControlParams = parse(params)?;
+ let bot = params.bot_id;
+ if !self.watches(&bot) {
+ return Err(
+ ApiError::Conflict("watch this browser before opening it".to_owned()).into(),
+ );
+ }
+ if !matches!(want(daemon, &bot), Want::Keep | Want::Rest) {
+ return Err(ApiError::Conflict(
+ "the bot or its crew is paused: its browser stays closed".to_owned(),
+ )
+ .into());
+ }
+ let workspace = {
+ let store = daemon.store();
+ let record = bots::find(&store, &bot)?;
+ let crew = crate::service::crews::find(&store, &record.crew_id)?;
+ daemon.paths.bot_workspace(&crew.slug, &record.slug)
+ };
+ let state = daemon.browsers.view(&bot).state;
+ let daemon = Arc::clone(daemon);
+ tokio::spawn(async move {
+ let downloads = workspace.join("downloads");
+ if let Err(err) = daemon.browsers.open_for_owner(&bot, &downloads).await {
+ debug!(bot = %bot, "browser: could not open for the owner: {err}");
+ }
+ });
+ to_value(&state)
+ }
+
/// The owner's hold on the bot's browser, if this connection has it.
fn hands(&self, bot: &BotId) -> Result<&Hands, RpcError> {
self.hands
diff --git a/crates/botloftd/tests/browser_owner_open.rs b/crates/botloftd/tests/browser_owner_open.rs
new file mode 100644
index 00000000..576f165c
--- /dev/null
+++ b/crates/botloftd/tests/browser_owner_open.rs
@@ -0,0 +1,82 @@
+//! The owner opens a bot's closed browser for themselves with the real
+//! Microsoft Edge (spec 21.10): on the page it showed last, without the
+//! bot, then takes it and gives it back. Skipped where Edge is not
+//! installed.
+
+mod common;
+
+use std::time::Duration;
+
+use common::Client;
+use common::browsing::{answer_site, call, setup};
+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()
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn the_owner_opens_the_closed_browser_on_its_last_page_and_takes_it() {
+ let Some(mut b) = setup().await else { return };
+ let url = format!("{}/login", b.site);
+ let opening = call(&b.mcp, "browser_open", json!({ "url": url }));
+ answer_site(&mut b.app, true, None).await;
+ opening.await.expect("task").expect("page");
+ b.mcp
+ .tool_text("browser_close", json!({}))
+ .await
+ .expect("closed");
+
+ // Only the connection watching it opens it.
+ let id = json!({ "botId": b.bot["id"] });
+ assert!(b.app.call("browser.start", id.clone()).await.is_err());
+ b.app
+ .call("browser.watch", id.clone())
+ .await
+ .expect("watch");
+ b.app
+ .call("browser.start", id.clone())
+ .await
+ .expect("start");
+
+ // It opens on the page it showed last.
+ let mut state = Value::Null;
+ for _ in 0..150 {
+ state = browser(&mut b.app).await;
+ if state["status"] == "open"
+ && state["url"]
+ .as_str()
+ .is_some_and(|url| url.contains("/login"))
+ {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ assert_eq!(state["status"], "open", "{state}");
+ assert!(
+ state["url"]
+ .as_str()
+ .is_some_and(|url| url.contains("/login")),
+ "{state}"
+ );
+
+ let taken = b.app.call("browser.take", id.clone()).await.expect("take");
+ assert_eq!(taken["control"], "owner");
+ let given = b
+ .app
+ .call("browser.release", id.clone())
+ .await
+ .expect("release");
+ assert_eq!(given["control"], "bot");
+
+ // A paused bot's browser stays closed.
+ b.app
+ .call(
+ "bots.setPaused",
+ json!({ "botId": b.bot["id"], "paused": true }),
+ )
+ .await
+ .expect("pause");
+ assert!(b.app.call("browser.start", id).await.is_err());
+}
diff --git a/docs/spec.md b/docs/spec.md
index c79cd1b7..13f8fd2a 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1300,6 +1300,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg
- **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 ou excluir o bot fecha o navegador e apaga o perfil.
- **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.
+- **Abrir para o dono:** fechado, o navegador só sobe por uma tool do bot ou quando o dono o abre para ele mesmo (`browser.start`, 21.10). O daemon guarda, na memória, a última página que ele mostrou (o endereço da aba ativa, sem `about:blank`) e a abre de novo; um daemon que reinicia a esquece, e o navegador abre em branco. O bot não é acordado nem avisado.
- **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.
@@ -1384,6 +1385,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
| `browser.newTab` | `botId` | `null`: abre uma aba em branco, que vira a ativa. Só da conexão que controla |
| `browser.switchTab` | `botId`, `tabId` | `null`: essa aba vira a ativa. Só da conexão que controla; aba que não existe mais é `not_found` |
| `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` |
+| `browser.start` | `botId` | `BrowserState`: abre o navegador fechado do bot para o dono, na última página (21.2), e responde na hora; o estado diz quando ele está aberto, e o app o toma (`browser.take`). Aberto, não muda nada. Da conexão que assiste esse bot; com o bot ou a crew pausados é `conflict` |
| `browser.window` | `botId` | `BrowserState`: abre o navegador numa janela própria, para o dono entrar numa conta (21.11). Da conexão que assiste esse bot; tira o controle dela, se o tinha. Já aberto numa janela, ou sem navegador no computador, é `conflict` |
| `browser.teach` | `botId`, `on` | `LessonStep[]`: começa uma lição (21.13), com o primeiro passo, ou termina e devolve os passos. Só da conexão que controla |
@@ -1400,7 +1402,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
- 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 a mesa em volta (10 px dos lados e em cima, 26 px embaixo) e o lugar da pílula e da legenda (56 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 (o pedido de ajuda e as linhas embaixo da pílula, 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").
- A tela fica sobre uma mesa na cor do bot, para o painel ler como o computador dele: um degradê da cor misturada com o fundo do painel (suave no tema claro, fundo no escuro), com a página como uma janela com sombra, que ocupa a mesa toda menos uma borda fina (10 px; embaixo, 26 px, onde a pílula do controle se pendura sem nunca cobrir a página). Com o navegador fechado, a mesa perde a cor junto com o último quadro.
-- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado". Em `failed`, o motivo em "Details".
+- Sem navegador aberto: o mascote e " ainda não abriu o navegador", com a explicação de que tudo que ele fizer num site aparece ali ao vivo. Depois de fechado, o último quadro fica apagado com "Navegador fechado" e o botão **Abrir para mim** (21.10); sem quadro, o botão fica embaixo do mascote. Em `failed`, o motivo em "Details".
- No chat, a linha de uma tool `browser_*` ganha o ícone do globo e o botão "Ver no navegador", que abre o painel.
- O pedido de site (21.5) é um cartão próprio: " quer usar o navegador em **wikipedia.org**", o endereço embaixo, Permitir e Negar. Respondido: "Você permitiu wikipedia.org".
- O dono no controle e o pedido de ajuda têm a própria parte da tela (21.10).
@@ -1423,6 +1425,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, rolar e digitar na tela ao vivo. Serve para o que o bot não deve ou não consegue fazer sozinho: entrar numa conta com a senha do dono, passar de um captcha, confirmar um código que chegou no celular dele.
+- **Abrir para mim:** com o navegador fechado, o dono o abre sem passar pelo bot, para ver o que ele deixou aberto ou mexer ele mesmo: `browser.start` e, assim que abre, `browser.take`. A explicação embaixo do botão diz que o bot não é acordado nem avisado e que, devolvido, o navegador volta a descansar (21.2). Devolver não avisa o bot, a menos que ele tenha um pedido de ajuda aberto.
- **Assumir:** "Assumir o controle", no painel (21.8), com o navegador aberto. Quem controla é a conexão que assiste o bot (21.7); parar de assistir (fechar o painel, trocar de bot, fechar o app, cair a conexão) devolve o controle na hora, para o bot nunca ficar preso. Cada navegador tem um só controlador: `browser.take` de outra conexão enquanto um controla é `conflict`.
- **O bot espera:** enquanto o dono controla, cada tool `browser_*` do bot espera ele devolver, até 10 minutos. Passou disso, a tool volta com erro: o dono está usando o navegador. `browser_ask_owner` não espera, porque é ela que chama o dono. O bot continua `busy` nessa espera, e o painel diz que ele espera.
- **Devolver:** "Pronto, devolver para " no painel. Fechar o navegador (bot pausado, `browser_close`, processo que caiu) também devolve.