Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,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));
Expand Down
22 changes: 17 additions & 5 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -77,11 +79,21 @@ export function BrowserPanel({
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="flex items-center gap-2 font-semibold text-sm">
{t.heading}
{live && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 font-medium text-danger text-xs">
<span aria-hidden className="live-dot" />
{t.live}
{resting ? (
<span
title={t.restingWhy(bot.name)}
className="inline-flex items-center gap-1.5 rounded-full bg-sunken px-2 py-0.5 font-medium text-muted text-xs"
>
<Moon aria-hidden size={11} />
{t.resting}
</span>
) : (
live && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 font-medium text-danger text-xs">
<span aria-hidden className="live-dot" />
{t.live}
</span>
)
)}
</h2>
<div className="flex items-center gap-0.5">
Expand Down
31 changes: 31 additions & 0 deletions app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/en/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/es/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/pt-BR/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions app/src/lib/fakeBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export class FakeBrowser {
tabs: [],
error: null,
control: "bot",
resting: false,
ask: null,
updatedAt: this.fake.now,
}
Expand Down Expand Up @@ -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. */
Expand All @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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;
},
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,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.
*/
Expand Down
4 changes: 4 additions & 0 deletions crates/botloft-core/src/protocol/browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ pub struct BrowserState {
pub error: Option<String>,
/// 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<String>,
/// Unix time in milliseconds.
Expand All @@ -67,6 +70,7 @@ impl BrowserState {
tabs: Vec::new(),
error: None,
control: BrowserControl::Bot,
resting: false,
ask: None,
updated_at: now,
}
Expand Down
25 changes: 20 additions & 5 deletions crates/botloftd/src/browser/hands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<Viewport, InputError> {
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)
}

Expand Down
19 changes: 6 additions & 13 deletions crates/botloftd/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod launch;
mod moves;
mod page;
mod read;
mod rest;
mod session;
mod settle;
pub mod sites;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Option<Arc<BrowserFrame>>>;
/// Which hold of the owner's has the browser, if any (spec 21.10).
type Held = tokio::sync::watch::Sender<Option<u64>>;
Expand Down Expand Up @@ -174,22 +165,24 @@ 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);
Arc::clone(&self.slot(&mut slots, bot).calls)
};
let guard = calls.lock_owned().await;
self.touch(bot);
self.wake(bot).await;
Call {
browsers: self,
bot: bot.clone(),
_guard: guard,
}
}

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();
}
Expand Down
11 changes: 10 additions & 1 deletion crates/botloftd/src/browser/moves.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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}");
}
Expand Down
Loading
Loading