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
12 changes: 8 additions & 4 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// The bot's own browser beside its chat (spec 21.8): its tabs, the address
// and the page it is on, live, with its cursor and a line about what it
// 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).
// 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 { useEffect, useRef, useState } from "react";
Expand All @@ -23,6 +24,7 @@ import { TabStrip } from "./TabStrip";
import { useBrowserView } from "./useBrowserView";
import { useHands } from "./useHands";
import { useFitPage, useRoom } from "./useRoom";
import { WindowBar } from "./WindowBar";

/** Around the page in the panel, in px. */
const PAD = 12;
Expand Down Expand Up @@ -51,6 +53,7 @@ export function BrowserPanel({
const hands = useHands(bot, state);
const [focused, setFocused] = useState(false);
const ask = state?.ask ?? null;
const windowed = state?.window === true;
// The page takes the shape of the room the panel has for it (spec 21.3).
// Notices that come and go are not counted: they never resize the page.
const [inside, body] = useRoom();
Expand Down Expand Up @@ -118,6 +121,7 @@ export function BrowserPanel({
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
style={{ padding: PAD }}
>
{windowed && <WindowBar bot={bot} />}
{status === "failed" ? (
<Callout tone="danger" title={t.failedTitle}>
{t.failedBody(SYSTEM)}
Expand All @@ -131,7 +135,7 @@ export function BrowserPanel({
)}
</Callout>
) : status === "closed" && !frame ? (
<Empty bot={bot} />
!windowed && <Empty bot={bot} />
) : (
<>
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
Expand Down Expand Up @@ -167,8 +171,8 @@ export function BrowserPanel({
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
title={status === "closed" ? t.closed : t.starting}
body={status === "closed" ? t.closedBody : null}
title={windowed ? t.window.title : status === "closed" ? t.closed : t.starting}
body={status === "closed" && !windowed ? t.closedBody : null}
/>
)}
</div>
Expand Down
38 changes: 23 additions & 15 deletions app/src/features/browser/HandsBars.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
// The owner's controls in the browser panel (spec 21.10): taking the
// browser, the bot's request for a hand, and the bar that says the owner
// has it, with the button that gives it back.
// has it, with the button that gives it back. Each offers a window of its
// own too, for sites that refuse to sign in here (spec 21.11).

import { Check, Hand, Keyboard, MousePointerClick } from "lucide-react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import type { Hands } from "./useHands";
import { WindowButton } from "./WindowBar";

/** The bot asked for a hand and the owner has not taken the browser yet. */
export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands: Hands }) {
Expand All @@ -16,16 +18,18 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands
<div className="mb-3">
<Callout tone="warn" title={t.help.needs(bot.name)}>
<p data-selectable>{task}</p>
<Button
variant="primary"
size="sm"
icon={Hand}
className="mt-2.5"
disabled={hands.busy}
onClick={() => void hands.take()}
>
{t.hands.take}
</Button>
<div className="mt-2.5 flex flex-wrap gap-2">
<Button
variant="primary"
size="sm"
icon={Hand}
disabled={hands.busy}
onClick={() => void hands.take()}
>
{t.hands.take}
</Button>
<WindowButton hands={hands} />
</div>
</Callout>
</div>
);
Expand Down Expand Up @@ -73,6 +77,7 @@ export function HeldBar({
>
{t.hands.giveBack(bot.name)}
</Button>
<WindowButton hands={hands} />
<span className="flex items-center gap-1.5 text-muted text-xs">
<Hint aria-hidden size={12} className="shrink-0" />
{focused ? t.hands.typing : t.hands.clickToType}
Expand All @@ -84,19 +89,22 @@ export function HeldBar({

/** Under the live screen: the owner may take the browser at any time. */
export function TakeBar({ bot, hands }: { bot: Bot; hands: Hands }) {
const t = useT().browser.hands;
const t = useT().browser;
return (
<div className="mt-3 flex items-center gap-3">
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<Button
variant="secondary"
size="sm"
icon={Hand}
disabled={hands.busy}
onClick={() => void hands.take()}
>
{t.take}
{t.hands.take}
</Button>
<p className="min-w-0 text-muted text-xs">{t.takeWhy(bot.name)}</p>
<WindowButton hands={hands} />
<p className="min-w-0 basis-full text-muted text-xs">
{t.hands.takeWhy(bot.name)} {t.window.why(bot.name)}
</p>
</div>
);
}
44 changes: 44 additions & 0 deletions app/src/features/browser/WindowBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// The bot's browser in a window of its own (spec 21.11): the button that
// opens it, for sites that refuse to sign in to a browser a program drives,
// and the bar that says it is open while the bot waits.

import { AppWindow } from "lucide-react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import type { Hands } from "./useHands";

/** Opens the bot's browser in a window of its own. */
export function WindowButton({ hands }: { hands: Hands }) {
const t = useT().browser.window;
return (
<Button
variant="secondary"
size="sm"
icon={AppWindow}
disabled={hands.busy}
onClick={() => void hands.window()}
>
{t.open}
</Button>
);
}

/** The browser is open in a window of its own. */
export function WindowBar({ bot }: { bot: Bot }) {
const t = useT().browser.window;
return (
<div
role="status"
className="mb-3 animate-rise rounded-xl border border-accent/45 bg-accent/8 px-3.5 py-3"
>
<div className="flex items-start gap-2.5">
<AppWindow aria-hidden size={16} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0 flex-1 text-sm">
<p className="font-semibold">{t.title}</p>
<p className="text-ink-soft text-xs">{t.body(bot.name)}</p>
</div>
</div>
</div>
);
}
14 changes: 13 additions & 1 deletion app/src/features/browser/useHands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// The owner's hands in a bot's browser (spec 21.10): taking it, giving it
// back, sending what they do on the page, and moving between its tabs.
// back, sending what they do on the page, moving between its tabs, and
// opening it in a window of its own (spec 21.11).
// Events go out in order and without waiting for each other: the daemon
// queues them for the page.

Expand All @@ -15,6 +16,8 @@ export interface Hands {
busy: boolean;
take(): Promise<void>;
release(): Promise<void>;
/** Opens the browser in a window of its own, to sign in. */
window(): Promise<void>;
send(input: BrowserInput): void;
/** Opens a blank tab; `true` once the daemon took the request. */
newTab(): Promise<boolean>;
Expand Down Expand Up @@ -46,6 +49,14 @@ export function useHands(bot: Pick<Bot, "id">, state: BrowserState | null): Hand
setBusy(false);
}, [api, botId, putBrowser, t.hands.giveBackFailed]);

const openWindow = useCallback(async () => {
setBusy(true);
await attempt(t.window.openFailed, async () => {
putBrowser(await api.call("browser.window", { botId }));
});
setBusy(false);
}, [api, botId, putBrowser, t.window.openFailed]);

const send = useCallback(
(input: BrowserInput) => {
// One lost event is not worth a message; the page shows what arrived.
Expand Down Expand Up @@ -73,6 +84,7 @@ export function useHands(bot: Pick<Bot, "id">, state: BrowserState | null): Hand
busy,
take,
release,
window: openWindow,
send,
newTab,
switchTab,
Expand Down
64 changes: 64 additions & 0 deletions app/src/features/browser/window.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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);

/** A crew "Ops" with @scout, its browser open on a sign-in page, watched. */
async function openScout() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
fake.setBotState(scout.id, "busy");
fake.browser.open(scout.id, "https://accounts.google.com/", "Sign in");
renderApp(fake);
await crewOpened("Ops");
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
fireEvent.click(screen.getByRole("button", { name: /^Show browser/ }));
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
act(() => {
fake.browser.frame(scout.id, "AAAA");
});
return { fake, scout };
}

const panel = () => screen.getByRole("complementary", { name: "Scout's browser" });

describe("the browser in a window of its own", () => {
test("opens for signing in, says the bot waits, and goes back when the window closes", async () => {
const { fake, scout } = await openScout();
expect(within(panel()).getByText(/like Google/)).toBeDefined();
fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" }));

expect(await within(panel()).findAllByText("Open in a window")).not.toHaveLength(0);
expect(within(panel()).getByText(/Scout waits meanwhile and keeps the login/)).toBeDefined();
expect(
fake.calls.filter((call) => call.method === "browser.window").map((call) => call.params),
).toEqual([{ botId: scout.id }]);
expect(within(panel()).queryByRole("button", { name: "Take control" })).toBeNull();

act(() => {
fake.browser.closeWindow(scout.id);
});
await waitFor(() => expect(within(panel()).queryByText(/keeps the login/)).toBeNull());
});

test("is offered when the bot asks for a hand", async () => {
const { fake, scout } = await openScout();
act(() => {
fake.browser.ask(scout.id, "Sign in to your Google account");
});
const asking = await within(panel()).findByText("Sign in to your Google account");
expect(asking).toBeDefined();
fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" }));
expect(await within(panel()).findByText(/keeps the login/)).toBeDefined();

// Closing the window is the owner being done.
act(() => {
fake.browser.closeWindow(scout.id);
});
expect(await screen.findByText("You did it: Sign in to your Google account")).toBeDefined();
});
});
9 changes: 9 additions & 0 deletions app/src/i18n/en/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export const browser = {
typing: "What you type goes to the page",
screen: (bot: string) => `${bot}'s browser, in your hands`,
},
window: {
open: "Sign in in a window",
why: (bot: string) =>
`For sites that refuse to sign in here, like Google: ${bot}'s browser opens in a window of its own, and the login stays for ${bot}.`,
openFailed: "Could not open the window",
title: "Open in a window",
body: (bot: string) =>
`Sign in in the window that opened, then close it. ${bot} waits meanwhile and keeps the login.`,
},
help: {
needs: (bot: string) => `${bot} needs you in the browser`,
asks: (bot: string, task: string) => `${bot} asks: ${task}`,
Expand Down
9 changes: 9 additions & 0 deletions app/src/i18n/es/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export const browser: Messages["browser"] = {
typing: "Lo que escribes va a la página",
screen: (bot) => `Navegador de ${bot}, en tus manos`,
},
window: {
open: "Iniciar sesión en una ventana",
why: (bot) =>
`Para sitios que rechazan el inicio de sesión aquí, como Google: el navegador de ${bot} se abre en una ventana propia y la sesión queda para ${bot}.`,
openFailed: "No se pudo abrir la ventana",
title: "Abierto en una ventana",
body: (bot) =>
`Inicia sesión en la ventana que se abrió y luego ciérrala. ${bot} espera mientras tanto y conserva la sesión.`,
},
help: {
needs: (bot) => `${bot} te necesita en el navegador`,
asks: (bot, task) => `${bot} pide: ${task}`,
Expand Down
9 changes: 9 additions & 0 deletions app/src/i18n/pt-BR/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@ export const browser: Messages["browser"] = {
typing: "O que você digita vai para a página",
screen: (bot) => `Navegador de ${bot}, nas suas mãos`,
},
window: {
open: "Entrar numa janela",
why: (bot) =>
`Para sites que recusam o login aqui, como o Google: o navegador de ${bot} abre numa janela própria, e o login fica com ${bot}.`,
openFailed: "Não foi possível abrir a janela",
title: "Aberto numa janela",
body: (bot) =>
`Entre na conta na janela que abriu e depois feche-a. ${bot} espera enquanto isso e fica com o login.`,
},
help: {
needs: (bot) => `${bot} precisa de você no navegador`,
asks: (bot, task) => `${bot} pede: ${task}`,
Expand Down
Loading
Loading