diff --git a/app/src/browser-stage.css b/app/src/browser-stage.css
new file mode 100644
index 00000000..00bf788f
--- /dev/null
+++ b/app/src/browser-stage.css
@@ -0,0 +1,44 @@
+/* The bot's browser sits on a desk of the bot's color (spec 21.8), so the
+ panel reads as that bot's computer. The color is mixed with the panel,
+ so it is soft in the light theme and deep in the dark one. */
+
+.bot-stage {
+ --stage: var(--bot, var(--accent));
+ border-radius: 18px;
+ background:
+ radial-gradient(
+ 120% 90% at 15% 0%,
+ color-mix(in oklab, var(--stage) 55%, white 18%) 0%,
+ transparent 65%
+ ),
+ linear-gradient(
+ 160deg,
+ color-mix(in oklab, var(--stage) 78%, var(--panel)) 0%,
+ color-mix(in oklab, var(--stage) 52%, var(--panel)) 100%
+ );
+ box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--stage) 40%, transparent);
+ transition: filter 500ms ease;
+}
+
+/* The browser closed: the desk fades with the last picture. */
+.bot-stage[data-dim] {
+ filter: saturate(0.25) brightness(0.92);
+}
+
+/* The page is a window on the desk. A filter, so the ring of the owner's
+ hands (a box shadow) stays. */
+.bot-stage figure {
+ filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.18)) drop-shadow(0 10px 24px rgb(0 0 0 / 0.28));
+}
+
+/* The pill drops onto the desk's edge when the control changes hands. */
+.control-pill {
+ animation: control-pill-in 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
+}
+
+@keyframes control-pill-in {
+ from {
+ opacity: 0;
+ transform: translateY(-6px) scale(0.96);
+ }
+}
diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx
index ef041089..6513c94a 100644
--- a/app/src/features/browser/BrowserPanel.tsx
+++ b/app/src/features/browser/BrowserPanel.tsx
@@ -5,7 +5,7 @@
// 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";
+import { type CSSProperties, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
@@ -17,7 +17,7 @@ import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { AddressBar } from "./AddressBar";
-import { AskCallout, HeldBar, TakeBar } from "./HandsBars";
+import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars";
import { HandsLayer } from "./HandsLayer";
import { LiveView, useCaption } from "./LiveView";
import { TabStrip } from "./TabStrip";
@@ -28,8 +28,14 @@ import { WindowBar } from "./WindowBar";
/** Around the page in the panel, in px. */
const PAD = 12;
-/** Kept under the page, for what the bot just did and the button to take it. */
-const UNDER = 80;
+/**
+ * The bot's color around the page, in px: on the sides and top, and below,
+ * where the pill that says who is in control hangs over its edge.
+ */
+const STAGE = 14;
+const STAGE_BOTTOM = 30;
+/** Kept under the page, for the pill and what the bot just did. */
+const UNDER = 56;
export function BrowserPanel({
bot,
@@ -58,8 +64,8 @@ export function BrowserPanel({
// Notices that come and go are not counted: they never resize the page.
const [inside, body] = useRoom();
const room = inside && {
- width: inside.width - 2 * PAD,
- height: inside.height - 2 * PAD - UNDER,
+ width: inside.width - 2 * PAD - 2 * STAGE,
+ height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER,
};
useFitPage(bot, room, watched);
// A new tab is blank: the owner says where it goes.
@@ -118,7 +124,7 @@ export function BrowserPanel({
)}
+ {(hands.held || (live && !ask)) && (
+
+ )}
{status === "open" && !hands.held && (
// Its place is kept while empty, so the page never moves for it.
-
diff --git a/app/src/features/browser/HandsBars.tsx b/app/src/features/browser/HandsBars.tsx
index 9da03be9..130f93de 100644
--- a/app/src/features/browser/HandsBars.tsx
+++ b/app/src/features/browser/HandsBars.tsx
@@ -1,13 +1,16 @@
-// 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. Each offers a window of its
-// own too, for sites that refuse to sign in here (spec 21.11).
+// The owner's controls in the browser panel (spec 21.10): the bot's request
+// for a hand, and the pill under the live screen that says who is in
+// control, with the button that takes the browser or gives it back. Under
+// it, what the owner should know, and a window of its own for sites that
+// refuse to sign in here (spec 21.11).
-import { Check, Hand, Keyboard, MousePointerClick } from "lucide-react";
+import { Check, ChevronDown, Hand, Keyboard, MousePointerClick } from "lucide-react";
+import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
+import { BotAvatar, moodOf } from "../bots/BotAvatar";
import type { Hands } from "./useHands";
import { WindowButton } from "./WindowBar";
@@ -36,8 +39,84 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands
);
}
-/** The owner has the browser. */
-export function HeldBar({
+/**
+ * Who is in control, hanging from the bottom of the live screen: the bot,
+ * with the button to take it, or the owner, with the one to give it back.
+ */
+export function ControlPill({ bot, hands }: { bot: Bot; hands: Hands }) {
+ const t = useT().browser.hands;
+ const held = hands.held;
+ return (
+
+ );
+}
+
+/**
+ * The window button, and why to take the browser or use a window, folded:
+ * an owner who knows it does not read it every time.
+ */
+function WindowAndWhy({ hands, why }: { hands: Hands; why: string }) {
+ const t = useT().browser.hands;
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+
+
+
+
+ {open &&
{why}
}
+ >
+ );
+}
+
+/** Under the pill, while the bot has the browser: how to take it. */
+export function TakeNotes({ bot, hands }: { bot: Bot; hands: Hands }) {
+ const t = useT().browser;
+ return (
+
+
+
+ );
+}
+
+/** Under the pill, while the owner has the browser. */
+export function HeldNotes({
bot,
task,
tabs,
@@ -54,59 +133,18 @@ export function HeldBar({
const t = useT().browser;
const Hint = focused ? Keyboard : MousePointerClick;
return (
-
- );
-}
-
-/** 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;
- return (
-
);
}
diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx
index 065ce76c..988a0426 100644
--- a/app/src/features/browser/browser.test.tsx
+++ b/app/src/features/browser/browser.test.tsx
@@ -74,7 +74,7 @@ describe("browser panel", () => {
test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => {
const { fake, scout } = crew();
fake.browser.open(scout.id, "https://example.com/", "Example");
- // The panel under its address row, as the app measures it: 536 by 700
+ // The panel under its address row, as the app measures it: 508 by 680
// for the page, once what goes around and under it is set aside.
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560);
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804);
@@ -82,9 +82,9 @@ describe("browser panel", () => {
fireEvent.click(toggle());
const resized = () =>
fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params);
- await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 536, height: 700 }]));
+ await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 508, height: 680 }]));
// As wide as ever, and as tall as the room's shape asks.
- expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1671 });
+ expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1713 });
act(() => {
fake.browser.frame(scout.id, "AAAA");
@@ -92,7 +92,7 @@ describe("browser panel", () => {
// The bot's cursor lands where it acted on the taller page.
const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" });
act(() => {
- fake.browser.act(scout.id, "click", { x: 640, y: 1671, label: "More" });
+ fake.browser.act(scout.id, "click", { x: 640, y: 1713, label: "More" });
});
const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
expect(cursor.style.top).toBe("100%");
diff --git a/app/src/features/browser/hands.test.tsx b/app/src/features/browser/hands.test.tsx
index 2d976515..d774c73c 100644
--- a/app/src/features/browser/hands.test.tsx
+++ b/app/src/features/browser/hands.test.tsx
@@ -39,8 +39,15 @@ describe("the owner's hands in the browser", () => {
const { fake, scout } = crew();
await openScout(fake);
await openPanel(fake, scout.id);
+ // The pill under the screen says who has it.
+ const pill = () =>
+ within(panel())
+ .getAllByRole("status")
+ .find((s) => s.closest(".control-pill"));
+ expect(pill()?.textContent).toContain("Scout is in control");
fireEvent.click(within(panel()).getByRole("button", { name: "Take control" }));
expect(await within(panel()).findByText("You are in control")).toBeDefined();
+ expect(pill()?.textContent).toContain("Done, give it back to Scout");
expect(called(fake, "browser.take")).toEqual([{ botId: scout.id }]);
const page = within(panel()).getByRole("application", {
diff --git a/app/src/features/browser/window.test.tsx b/app/src/features/browser/window.test.tsx
index 19488414..296c7468 100644
--- a/app/src/features/browser/window.test.tsx
+++ b/app/src/features/browser/window.test.tsx
@@ -29,6 +29,9 @@ 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();
+ // Why is folded until the owner asks.
+ expect(within(panel()).queryByText(/like Google/)).toBeNull();
+ fireEvent.click(within(panel()).getByRole("button", { name: "How it works" }));
expect(within(panel()).getByText(/like Google/)).toBeDefined();
fireEvent.click(within(panel()).getByRole("button", { name: "Sign in in a window" }));
@@ -49,6 +52,7 @@ describe("the browser in a window of its own", () => {
await openScout();
fireEvent.click(within(panel()).getByRole("button", { name: "Take control" }));
expect(await within(panel()).findByText("You are in control")).toBeDefined();
+ fireEvent.click(within(panel()).getByRole("button", { name: "How it works" }));
expect(within(panel()).getByText(/won't let you sign in here\?/)).toBeDefined();
expect(within(panel()).getByRole("button", { name: "Sign in in a window" })).toBeDefined();
});
diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts
index 1a21372b..1bdc5d42 100644
--- a/app/src/i18n/en/browser.ts
+++ b/app/src/i18n/en/browser.ts
@@ -67,6 +67,10 @@ export const browser = {
},
hands: {
take: "Take control",
+ /** In the pill under the live screen while the bot has the browser. */
+ botHas: (bot: string) => `${bot} is in control`,
+ /** Opens why to take the browser, folded under the pill. */
+ howItWorks: "How it works",
takeWhy: (bot: string) =>
`To sign in to an account or get past a captcha. ${bot} waits meanwhile.`,
takeFailed: "Could not take the browser",
diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts
index e519b773..22fd0671 100644
--- a/app/src/i18n/es/browser.ts
+++ b/app/src/i18n/es/browser.ts
@@ -68,6 +68,8 @@ export const browser: Messages["browser"] = {
},
hands: {
take: "Tomar el control",
+ botHas: (bot) => `${bot} tiene el control`,
+ howItWorks: "Cómo funciona",
takeWhy: (bot) => `Para entrar en una cuenta o pasar un captcha. ${bot} espera mientras tanto.`,
takeFailed: "No se pudo tomar el navegador",
holding: "Tienes el control",
diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts
index 18bfb4d6..38ecba57 100644
--- a/app/src/i18n/pt-BR/browser.ts
+++ b/app/src/i18n/pt-BR/browser.ts
@@ -69,6 +69,8 @@ export const browser: Messages["browser"] = {
},
hands: {
take: "Assumir o controle",
+ botHas: (bot) => `${bot} está no controle`,
+ howItWorks: "Como funciona",
takeWhy: (bot) =>
`Para entrar numa conta ou passar de um captcha. ${bot} espera enquanto isso.`,
takeFailed: "Não foi possível assumir o navegador",
diff --git a/app/src/index.css b/app/src/index.css
index a3ec5e07..c46d631b 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -18,6 +18,7 @@
@import "./files.css";
@import "./search.css";
@import "./activity.css";
+@import "./browser-stage.css";
@import "./motion-less.css";
@import "./paused.css";
@import "./offscreen.css";
diff --git a/docs/spec.md b/docs/spec.md
index 5963eb0e..76dd4ffc 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1361,7 +1361,8 @@ Esqueceu a senha? [e5 link "Recuperar acesso"]
- No cabeçalho do bot, o botão **Navegador** (globo), ao lado de Arquivos. Com quadros chegando, o título do painel ganha a etiqueta "Ao vivo"; com o navegador descansando (21.2), "Em descanso", e a dica diz que ele não gasta o computador e volta na hora em que o bot ou o dono precisar. A tela mostra o quadro tirado ao descansar. Com o navegador aberto e em uso e o painel fechado, o botão ganha um ponto que pulsa; descansando, não. O painel abre sozinho quando o navegador do bot sobe, quando acorda do descanso para o bot e quando ele pede uma mão (21.10); um navegador que já estava aberto quando o dono abriu o bot fica atrás do botão (15.1). Quando o navegador entra em descanso, o painel fecha na hora, deslizando para a direita, e o chat volta a ocupar o espaço; aberto de novo pelo dono, mostra "Em descanso". Nas mãos do dono, ou com um pedido de mão aberto, ele fica. As duas coisas seguem a escolha "Abrir o navegador e as telas quando um bot começar a usar" (15.1): desligada, o painel só abre e fecha pelo botão.
- O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.browser`, 560 px de início), com um botão para ocupar todo o espaço que o chat pode ceder.
- 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 o lugar da legenda e do botão de assumir (80 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 (a legenda, o pedido de ajuda, a faixa do controle, 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").
+- 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 (14 px dos lados e em cima, 30 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. 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".
- 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".
@@ -1417,9 +1418,9 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro
**No app** (21.8):
-- Embaixo da tela ao vivo, com o navegador aberto: "Assumir o controle" e a explicação ("Para entrar numa conta ou passar de um captcha. espera enquanto isso.").
+- Embaixo da tela ao vivo, com o navegador aberto, uma pílula pendurada na borda da mesa (21.8) diz quem está no controle: o mascote e " está no controle", com "Assumir o controle". Embaixo dela, "Entrar numa janela" (21.11) e "Como funciona", que abre a explicação ("Para entrar numa conta ou passar de um captcha. espera enquanto isso.", e o porquê da janela). A explicação começa dobrada: quem já sabe não precisa tê-la na tela toda vez. Com um pedido de ajuda aberto, quem oferece assumir é o aviso do pedido, e a pílula não aparece. Num painel estreito, a frase da pílula sai da tela (fica para o leitor de tela) e o botão diz o resto. Ao trocar de mãos, a pílula cai de novo na borda.
- Com um pedido aberto, em cima da tela: " precisa de você" com a tarefa e "Assumir o controle". O botão do navegador no cabeçalho ganha o ponto de atenção.
-- No controle: a tela ganha a borda de destaque, e o cursor do bot some. Uma faixa diz "Você está no controle" e, se o bot espera, que ele espera, com "Pronto, devolver para ". Com mais de uma aba, diz também que o bot continua na aba que ficar aberta. As abas e o endereço passam a responder (21.8). O teclado vai para a página enquanto a tela tem o foco; sem foco, a faixa diz "Clique na tela para digitar". Esc vai para a página, não devolve.
+- No controle: a tela ganha a borda de destaque, e o cursor do bot some. A pílula, com a borda de destaque, diz "Você está no controle", com "Pronto, devolver para ". Embaixo dela: se o bot espera, que ele espera (ou o que ele pediu); com mais de uma aba, que o bot continua na aba que ficar aberta. Depois, "Entrar numa janela" e o "Como funciona" dobrado com a dica da janela. As abas e o endereço passam a responder (21.8). O teclado vai para a página enquanto a tela tem o foco; sem foco, a linha embaixo da pílula diz "Clique na tela para digitar". Esc vai para a página, não devolve.
### 21.11 Entrar numa janela de verdade (N3.4)
@@ -1431,7 +1432,7 @@ O login do Google, e o de outros sites que olham mais que o user agent e `naviga
- **Enquanto a janela está aberta:** `BrowserState` fica `closed`, com `window` e `control: "owner"`. As tools do bot esperam como com o dono no controle (21.10). `browser_close` e o fechamento por falta de uso não a tocam, e quem respondia a um pedido de ajuda não devolve o navegador ao bot.
- **Ao fechar a janela:** o navegador volta ao bot, fechado; a próxima tool abre o sem janela no mesmo perfil, com os cookies do login (visto com o Edge 154: um cookie gravado na janela aparece no sem janela, 19). Um pedido de ajuda aberto (`browser_ask_owner`) recebe **Pronto**, e a tool diz ao bot que o navegador fechou e que os logins ficam; ele abre a página de novo com `browser_open`.
-**No app** (21.8): "Entrar numa janela" fica ao lado de "Assumir o controle" embaixo da tela ao vivo, no aviso de um pedido de ajuda e na faixa do dono no controle. Embaixo da tela, a explicação ganha: "Para sites que recusam o login aqui, como o Google: o navegador de abre numa janela própria, e o login fica com ." Com a janela aberta, uma faixa no painel diz "Aberto numa janela": entre na conta na janela que abriu e feche-a depois; o bot espera enquanto isso e fica com o login. A janela não fecha pelo app: fechá-la à força pode perder o login ainda não gravado. Onde o dono faz o login pelo painel (o aviso de um pedido de ajuda e a faixa do dono no controle), uma linha lembra: "O site não deixa entrar aqui? Use Entrar numa janela e feche a janela quando terminar." O cartão de ajuda no chat diz o mesmo.
+**No app** (21.8): "Entrar numa janela" fica embaixo da pílula do controle (com o bot ou com o dono) e no aviso de um pedido de ajuda. Embaixo da tela, a explicação ganha: "Para sites que recusam o login aqui, como o Google: o navegador de abre numa janela própria, e o login fica com ." Com a janela aberta, uma faixa no painel diz "Aberto numa janela": entre na conta na janela que abriu e feche-a depois; o bot espera enquanto isso e fica com o login. A janela não fecha pelo app: fechá-la à força pode perder o login ainda não gravado. Onde o dono faz o login pelo painel (o aviso de um pedido de ajuda e a faixa do dono no controle), uma linha lembra: "O site não deixa entrar aqui? Use Entrar numa janela e feche a janela quando terminar." O cartão de ajuda no chat diz o mesmo.
**Para o bot:** as regras (5.1) dizem que, quando um site recusa o login no navegador dele ("este navegador pode não ser seguro", um login que falha ou volta ao começo), ele pede ajuda com `browser_ask_owner` e diz ao dono, na língua dele, para usar o botão de entrar numa janela; depois abre a página de novo. A descrição de `browser_ask_owner` diz o mesmo.