Skip to content

Commit a473595

Browse files
authored
Merge pull request #139 from httpsphl/feat/browser-window
feat(browser): sign in in a real window
2 parents f66101d + cc388a3 commit a473595

24 files changed

Lines changed: 581 additions & 59 deletions

File tree

‎app/src/features/browser/BrowserPanel.tsx‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// The bot's own browser beside its chat (spec 21.8): its tabs, the address
22
// and the page it is on, live, with its cursor and a line about what it
33
// just did. Watching starts when the panel opens and stops when it closes.
4-
// The owner can take it into their own hands and give it back (spec 21.10).
4+
// The owner can take it into their own hands and give it back (spec 21.10),
5+
// or open it in a window of its own to sign in (spec 21.11).
56

67
import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
78
import { useEffect, useRef, useState } from "react";
@@ -23,6 +24,7 @@ import { TabStrip } from "./TabStrip";
2324
import { useBrowserView } from "./useBrowserView";
2425
import { useHands } from "./useHands";
2526
import { useFitPage, useRoom } from "./useRoom";
27+
import { WindowBar } from "./WindowBar";
2628

2729
/** Around the page in the panel, in px. */
2830
const PAD = 12;
@@ -51,6 +53,7 @@ export function BrowserPanel({
5153
const hands = useHands(bot, state);
5254
const [focused, setFocused] = useState(false);
5355
const ask = state?.ask ?? null;
56+
const windowed = state?.window === true;
5457
// The page takes the shape of the room the panel has for it (spec 21.3).
5558
// Notices that come and go are not counted: they never resize the page.
5659
const [inside, body] = useRoom();
@@ -118,6 +121,7 @@ export function BrowserPanel({
118121
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
119122
style={{ padding: PAD }}
120123
>
124+
{windowed && <WindowBar bot={bot} />}
121125
{status === "failed" ? (
122126
<Callout tone="danger" title={t.failedTitle}>
123127
{t.failedBody(SYSTEM)}
@@ -131,7 +135,7 @@ export function BrowserPanel({
131135
)}
132136
</Callout>
133137
) : status === "closed" && !frame ? (
134-
<Empty bot={bot} />
138+
!windowed && <Empty bot={bot} />
135139
) : (
136140
<>
137141
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
@@ -167,8 +171,8 @@ export function BrowserPanel({
167171
{(status !== "open" || !frame) && (
168172
<Overlay
169173
busy={status === "starting" || status === "open"}
170-
title={status === "closed" ? t.closed : t.starting}
171-
body={status === "closed" ? t.closedBody : null}
174+
title={windowed ? t.window.title : status === "closed" ? t.closed : t.starting}
175+
body={status === "closed" && !windowed ? t.closedBody : null}
172176
/>
173177
)}
174178
</div>

‎app/src/features/browser/HandsBars.tsx‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// The owner's controls in the browser panel (spec 21.10): taking the
22
// browser, the bot's request for a hand, and the bar that says the owner
3-
// has it, with the button that gives it back.
3+
// has it, with the button that gives it back. Each offers a window of its
4+
// own too, for sites that refuse to sign in here (spec 21.11).
45

56
import { Check, Hand, Keyboard, MousePointerClick } from "lucide-react";
67
import { useT } from "../../i18n";
78
import type { Bot } from "../../lib/protocol.gen";
89
import { Button } from "../../ui/Button";
910
import { Callout } from "../../ui/Callout";
1011
import type { Hands } from "./useHands";
12+
import { WindowButton } from "./WindowBar";
1113

1214
/** The bot asked for a hand and the owner has not taken the browser yet. */
1315
export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands: Hands }) {
@@ -16,16 +18,18 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands
1618
<div className="mb-3">
1719
<Callout tone="warn" title={t.help.needs(bot.name)}>
1820
<p data-selectable>{task}</p>
19-
<Button
20-
variant="primary"
21-
size="sm"
22-
icon={Hand}
23-
className="mt-2.5"
24-
disabled={hands.busy}
25-
onClick={() => void hands.take()}
26-
>
27-
{t.hands.take}
28-
</Button>
21+
<div className="mt-2.5 flex flex-wrap gap-2">
22+
<Button
23+
variant="primary"
24+
size="sm"
25+
icon={Hand}
26+
disabled={hands.busy}
27+
onClick={() => void hands.take()}
28+
>
29+
{t.hands.take}
30+
</Button>
31+
<WindowButton hands={hands} />
32+
</div>
2933
</Callout>
3034
</div>
3135
);
@@ -73,6 +77,7 @@ export function HeldBar({
7377
>
7478
{t.hands.giveBack(bot.name)}
7579
</Button>
80+
<WindowButton hands={hands} />
7681
<span className="flex items-center gap-1.5 text-muted text-xs">
7782
<Hint aria-hidden size={12} className="shrink-0" />
7883
{focused ? t.hands.typing : t.hands.clickToType}
@@ -84,19 +89,22 @@ export function HeldBar({
8489

8590
/** Under the live screen: the owner may take the browser at any time. */
8691
export function TakeBar({ bot, hands }: { bot: Bot; hands: Hands }) {
87-
const t = useT().browser.hands;
92+
const t = useT().browser;
8893
return (
89-
<div className="mt-3 flex items-center gap-3">
94+
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
9095
<Button
9196
variant="secondary"
9297
size="sm"
9398
icon={Hand}
9499
disabled={hands.busy}
95100
onClick={() => void hands.take()}
96101
>
97-
{t.take}
102+
{t.hands.take}
98103
</Button>
99-
<p className="min-w-0 text-muted text-xs">{t.takeWhy(bot.name)}</p>
104+
<WindowButton hands={hands} />
105+
<p className="min-w-0 basis-full text-muted text-xs">
106+
{t.hands.takeWhy(bot.name)} {t.window.why(bot.name)}
107+
</p>
100108
</div>
101109
);
102110
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// The bot's browser in a window of its own (spec 21.11): the button that
2+
// opens it, for sites that refuse to sign in to a browser a program drives,
3+
// and the bar that says it is open while the bot waits.
4+
5+
import { AppWindow } from "lucide-react";
6+
import { useT } from "../../i18n";
7+
import type { Bot } from "../../lib/protocol.gen";
8+
import { Button } from "../../ui/Button";
9+
import type { Hands } from "./useHands";
10+
11+
/** Opens the bot's browser in a window of its own. */
12+
export function WindowButton({ hands }: { hands: Hands }) {
13+
const t = useT().browser.window;
14+
return (
15+
<Button
16+
variant="secondary"
17+
size="sm"
18+
icon={AppWindow}
19+
disabled={hands.busy}
20+
onClick={() => void hands.window()}
21+
>
22+
{t.open}
23+
</Button>
24+
);
25+
}
26+
27+
/** The browser is open in a window of its own. */
28+
export function WindowBar({ bot }: { bot: Bot }) {
29+
const t = useT().browser.window;
30+
return (
31+
<div
32+
role="status"
33+
className="mb-3 animate-rise rounded-xl border border-accent/45 bg-accent/8 px-3.5 py-3"
34+
>
35+
<div className="flex items-start gap-2.5">
36+
<AppWindow aria-hidden size={16} className="mt-0.5 shrink-0 text-accent" />
37+
<div className="min-w-0 flex-1 text-sm">
38+
<p className="font-semibold">{t.title}</p>
39+
<p className="text-ink-soft text-xs">{t.body(bot.name)}</p>
40+
</div>
41+
</div>
42+
</div>
43+
);
44+
}

‎app/src/features/browser/useHands.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// The owner's hands in a bot's browser (spec 21.10): taking it, giving it
2-
// back, sending what they do on the page, and moving between its tabs.
2+
// back, sending what they do on the page, moving between its tabs, and
3+
// opening it in a window of its own (spec 21.11).
34
// Events go out in order and without waiting for each other: the daemon
45
// queues them for the page.
56

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

52+
const openWindow = useCallback(async () => {
53+
setBusy(true);
54+
await attempt(t.window.openFailed, async () => {
55+
putBrowser(await api.call("browser.window", { botId }));
56+
});
57+
setBusy(false);
58+
}, [api, botId, putBrowser, t.window.openFailed]);
59+
4960
const send = useCallback(
5061
(input: BrowserInput) => {
5162
// One lost event is not worth a message; the page shows what arrived.
@@ -73,6 +84,7 @@ export function useHands(bot: Pick<Bot, "id">, state: BrowserState | null): Hand
7384
busy,
7485
take,
7586
release,
87+
window: openWindow,
7688
send,
7789
newTab,
7890
switchTab,
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
2+
import { afterEach, describe, expect, test } from "vitest";
3+
import { FakeBotloft } from "../../lib/fake";
4+
import { crewOpened, openBot, renderApp } from "../../test/app";
5+
6+
afterEach(cleanup);
7+
8+
/** A crew "Ops" with @scout, its browser open on a sign-in page, watched. */
9+
async function openScout() {
10+
const fake = new FakeBotloft();
11+
const ops = fake.addCrew("Ops");
12+
const scout = fake.addBot(ops.id, "Scout", "Finds sources");
13+
fake.setBotState(scout.id, "busy");
14+
fake.browser.open(scout.id, "https://accounts.google.com/", "Sign in");
15+
renderApp(fake);
16+
await crewOpened("Ops");
17+
openBot("Scout");
18+
await screen.findByRole("list", { name: "Messages" });
19+
fireEvent.click(screen.getByRole("button", { name: /^Show browser/ }));
20+
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
21+
act(() => {
22+
fake.browser.frame(scout.id, "AAAA");
23+
});
24+
return { fake, scout };
25+
}
26+
27+
const panel = () => screen.getByRole("complementary", { name: "Scout's browser" });
28+
29+
describe("the browser in a window of its own", () => {
30+
test("opens for signing in, says the bot waits, and goes back when the window closes", async () => {
31+
const { fake, scout } = await openScout();
32+
expect(within(panel()).getByText(/like Google/)).toBeDefined();
33+
fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" }));
34+
35+
expect(await within(panel()).findAllByText("Open in a window")).not.toHaveLength(0);
36+
expect(within(panel()).getByText(/Scout waits meanwhile and keeps the login/)).toBeDefined();
37+
expect(
38+
fake.calls.filter((call) => call.method === "browser.window").map((call) => call.params),
39+
).toEqual([{ botId: scout.id }]);
40+
expect(within(panel()).queryByRole("button", { name: "Take control" })).toBeNull();
41+
42+
act(() => {
43+
fake.browser.closeWindow(scout.id);
44+
});
45+
await waitFor(() => expect(within(panel()).queryByText(/keeps the login/)).toBeNull());
46+
});
47+
48+
test("is offered when the bot asks for a hand", async () => {
49+
const { fake, scout } = await openScout();
50+
act(() => {
51+
fake.browser.ask(scout.id, "Sign in to your Google account");
52+
});
53+
const asking = await within(panel()).findByText("Sign in to your Google account");
54+
expect(asking).toBeDefined();
55+
fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" }));
56+
expect(await within(panel()).findByText(/keeps the login/)).toBeDefined();
57+
58+
// Closing the window is the owner being done.
59+
act(() => {
60+
fake.browser.closeWindow(scout.id);
61+
});
62+
expect(await screen.findByText("You did it: Sign in to your Google account")).toBeDefined();
63+
});
64+
});

‎app/src/i18n/en/browser.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export const browser = {
7979
typing: "What you type goes to the page",
8080
screen: (bot: string) => `${bot}'s browser, in your hands`,
8181
},
82+
window: {
83+
open: "Sign in in a window",
84+
why: (bot: string) =>
85+
`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}.`,
86+
openFailed: "Could not open the window",
87+
title: "Open in a window",
88+
body: (bot: string) =>
89+
`Sign in in the window that opened, then close it. ${bot} waits meanwhile and keeps the login.`,
90+
},
8291
help: {
8392
needs: (bot: string) => `${bot} needs you in the browser`,
8493
asks: (bot: string, task: string) => `${bot} asks: ${task}`,

‎app/src/i18n/es/browser.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ export const browser: Messages["browser"] = {
7979
typing: "Lo que escribes va a la página",
8080
screen: (bot) => `Navegador de ${bot}, en tus manos`,
8181
},
82+
window: {
83+
open: "Iniciar sesión en una ventana",
84+
why: (bot) =>
85+
`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}.`,
86+
openFailed: "No se pudo abrir la ventana",
87+
title: "Abierto en una ventana",
88+
body: (bot) =>
89+
`Inicia sesión en la ventana que se abrió y luego ciérrala. ${bot} espera mientras tanto y conserva la sesión.`,
90+
},
8291
help: {
8392
needs: (bot) => `${bot} te necesita en el navegador`,
8493
asks: (bot, task) => `${bot} pide: ${task}`,

‎app/src/i18n/pt-BR/browser.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ export const browser: Messages["browser"] = {
8181
typing: "O que você digita vai para a página",
8282
screen: (bot) => `Navegador de ${bot}, nas suas mãos`,
8383
},
84+
window: {
85+
open: "Entrar numa janela",
86+
why: (bot) =>
87+
`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}.`,
88+
openFailed: "Não foi possível abrir a janela",
89+
title: "Aberto numa janela",
90+
body: (bot) =>
91+
`Entre na conta na janela que abriu e depois feche-a. ${bot} espera enquanto isso e fica com o login.`,
92+
},
8493
help: {
8594
needs: (bot) => `${bot} precisa de você no navegador`,
8695
asks: (bot, task) => `${bot} pede: ${task}`,

0 commit comments

Comments
 (0)