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
59 changes: 51 additions & 8 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -138,7 +138,12 @@ export function BrowserPanel({
)}
</Callout>
) : status === "closed" && !frame ? (
!windowed && <Empty bot={bot} />
!windowed && (
<>
<Empty bot={bot} />
<OpenForMe bot={bot} hands={hands} />
</>
)
) : (
<>
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
Expand Down Expand Up @@ -178,12 +183,20 @@ export function BrowserPanel({
</LiveView>
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
busy={status === "starting" || status === "open" || hands.opening}
title={
windowed ? t.window.title : status === "closed" ? t.closed : t.starting
windowed
? t.window.title
: status === "closed" && !hands.opening
? t.closed
: t.starting
}
body={status === "closed" && !windowed ? t.closedBody : null}
/>
>
{status === "closed" && !windowed && !hands.opening && (
<OpenForMe bot={bot} hands={hands} />
)}
</Overlay>
)}
</div>
</div>
Expand Down Expand Up @@ -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 (
<div className="absolute inset-0 grid place-items-center rounded-xl">
<div className="flex animate-fade flex-col items-center gap-1.5 rounded-xl bg-panel/90 px-4 py-3 text-center shadow-lift">
Expand All @@ -232,11 +255,31 @@ function Overlay({ busy, title, body }: { busy: boolean; title: string; body: st
{title}
</p>
{body && <p className="max-w-64 text-muted text-xs">{body}</p>}
{children}
</div>
</div>
);
}

/** The owner opens the closed browser for themselves (spec 21.10). */
function OpenForMe({ bot, hands }: { bot: Bot; hands: Hands }) {
const t = useT().browser;
return (
<div className="mt-1.5 flex flex-col items-center gap-1.5">
<Button
variant="primary"
size="sm"
icon={Hand}
disabled={hands.opening}
onClick={() => void hands.start()}
>
{t.openForMe}
</Button>
<p className="max-w-72 text-center text-muted text-xs">{t.openForMeWhy(bot.name)}</p>
</div>
);
}

function Empty({ bot }: { bot: Bot }) {
const t = useT().browser;
return (
Expand Down
42 changes: 42 additions & 0 deletions app/src/features/browser/openForMe.test.tsx
Original file line number Diff line number Diff line change
@@ -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"));
});
});
29 changes: 28 additions & 1 deletion app/src/features/browser/useHands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,6 +24,10 @@ export interface Hands {
switchTab(tabId: string): Promise<boolean>;
/** Takes the active tab to the address the owner typed. */
open(url: string): Promise<boolean>;
/** Opens the closed browser for the owner, and takes it once open. */
start(): Promise<void>;
/** Waiting for the browser opened for the owner. */
opening: boolean;
}

export function useHands(bot: Pick<Bot, "id">, state: BrowserState | null): Hands {
Expand Down Expand Up @@ -79,6 +83,27 @@ export function useHands(bot: Pick<Bot, "id">, 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,
Expand All @@ -89,5 +114,7 @@ export function useHands(bot: Pick<Bot, "id">, state: BrowserState | null): Hand
newTab,
switchTab,
open,
start,
opening,
};
}
4 changes: 4 additions & 0 deletions app/src/i18n/en/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/es/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions app/src/i18n/pt-BR/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
14 changes: 14 additions & 0 deletions app/src/lib/fakeBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export class FakeBrowser {
/** Pages that answer the owner's hands, for the preview. */
private readonly pages = new Map<BotId, (input: BrowserInput) => void>();

/** The page each browser showed when it closed, to open it again. */
private readonly lastUrls = new Map<BotId, string>();

constructor(private readonly fake: FakeBotloft) {}

state(botId: BotId): BrowserState {
Expand Down Expand Up @@ -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 });
}
Expand Down Expand Up @@ -224,6 +228,7 @@ export class FakeBrowser {
| "browser.switchTab"
| "browser.open"
| "browser.window"
| "browser.start"
> {
return {
"browser.list": () =>
Expand Down Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LessonStep> };
"browser.input": { params: BrowserInputParams; result: null };
"browser.reload": { params: BrowserControlParams; result: null };
Expand Down
1 change: 1 addition & 0 deletions crates/botloft-core/src/protocol/export_browser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<BrowserTeachParams>(),
Expand Down
2 changes: 2 additions & 0 deletions crates/botloft-core/src/protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 2 additions & 0 deletions crates/botloftd/src/browser/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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;
Expand Down
6 changes: 5 additions & 1 deletion crates/botloftd/src/browser/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ mod keys;
mod launch;
mod lesson;
mod moves;
mod owner_open;
mod page;
mod program;
mod read;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -119,6 +120,8 @@ struct Slot {
asking: Option<ApprovalId>,
/// The owner has the profile open in a window of its own.
window: Option<window::Window>,
/// The page it showed last, to open again for the owner.
last_url: Option<String>,
}

type Slots = Arc<Mutex<HashMap<BotId, Slot>>>;
Expand Down Expand Up @@ -170,6 +173,7 @@ impl Browsers {
held: tokio::sync::watch::channel(None).0,
asking: None,
window: None,
last_url: None,
})
}

Expand Down
Loading
Loading