diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index f13b93e8..70617023 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -32,12 +32,11 @@ import { WindowBar } from "./WindowBar"; const PAD = 12; /** * 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. + * where the pill that says who is in control hangs over its edge. The pill + * reaches into it less than this, so it never covers the page. */ const STAGE = 10; const STAGE_BOTTOM = 26; -/** Kept under the page, for the pill and what the bot just did. */ -const UNDER = 56; export function BrowserPanel({ bot, @@ -63,13 +62,9 @@ export function BrowserPanel({ 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(); - const room = inside && { - width: inside.width - 2 * PAD - 2 * STAGE, - height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER, - }; + // The page takes the shape of the room it has on the desk (spec 21.3), so + // it fills it at its own size, with no bands of the desk beside it. + const [room, stage] = useRoom(); useFitPage(bot, room, watched); // A new tab is blank: the owner says where it goes. const address = useRef(null); @@ -126,7 +121,6 @@ export function BrowserPanel({ )}
@@ -159,6 +153,7 @@ export function BrowserPanel({ } >
@@ -207,7 +202,7 @@ export function BrowserPanel({ )}

)} - {hands.held && } + {hands.held && } {hands.held && lesson.steps === null && ( void lesson.start()} /> )} {live && !hands.held && !ask && } diff --git a/app/src/features/browser/HandsBars.tsx b/app/src/features/browser/HandsBars.tsx index c685197a..067e3a27 100644 --- a/app/src/features/browser/HandsBars.tsx +++ b/app/src/features/browser/HandsBars.tsx @@ -5,7 +5,7 @@ // refuse to sign in here (spec 21.11). import { Check, ChevronDown, GraduationCap, Hand, Keyboard, MousePointerClick } from "lucide-react"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; @@ -91,14 +91,25 @@ export function ControlPill({ /** * 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. + * an owner who knows it does not read it every time. One row, so the page + * above keeps the room. */ -function WindowAndWhy({ hands, why }: { hands: Hands; why: string }) { +function WindowAndWhy({ + hands, + why, + children, +}: { + hands: Hands; + why: ReactNode; + /** More buttons, before the window's. */ + children?: ReactNode; +}) { const t = useT().browser.hands; const [open, setOpen] = useState(false); return ( <>
+ {children}
- {open &&

{why}

} + {open && ( +
{why}
+ )} ); } @@ -124,18 +137,25 @@ export function TakeNotes({ bot, hands }: { bot: Bot; hands: Hands }) { const t = useT().browser; return (
- + {`${t.hands.takeWhy(bot.name)} ${t.window.why(bot.name)}`}

} + />
); } -/** Under the pill, while the owner has the browser. */ +/** + * Under the pill, while the owner has the browser: what the bot asked, and + * one row of buttons, with the rest folded, so the page keeps the room. + */ export function HeldNotes({ bot, task, tabs, focused, hands, + onTeach, }: { bot: Bot; task: string | null; @@ -143,22 +163,37 @@ export function HeldNotes({ tabs: number; focused: boolean; hands: Hands; + /** Starts a lesson (spec 21.13). */ + onTeach(): void; }) { const t = useT().browser; const Hint = focused ? Keyboard : MousePointerClick; return (
-

- - {focused ? t.hands.typing : t.hands.clickToType} -

-

- {task ? t.help.asks(bot.name, task) : t.hands.waits(bot.name)} -

- {tabs > 1 &&

{t.hands.leaves(bot.name)}

} -
- -
+ {task && ( +

+ {t.help.asks(bot.name, task)} +

+ )} + +

+ + {focused ? t.hands.typing : t.hands.clickToType} +

+ {!task &&

{t.hands.waits(bot.name)}

} + {tabs > 1 &&

{t.hands.leaves(bot.name)}

} +

{t.lesson.teachWhy(bot.name)}

+

{t.window.hint}

+ + } + > + +
); } diff --git a/app/src/features/browser/Lesson.tsx b/app/src/features/browser/Lesson.tsx index abfb9f16..8491bc1b 100644 --- a/app/src/features/browser/Lesson.tsx +++ b/app/src/features/browser/Lesson.tsx @@ -3,7 +3,7 @@ // end they name the task and make it a routine, or send it for the bot to // remember. -import { AlarmClock, GraduationCap, Send, X } from "lucide-react"; +import { AlarmClock, Send, X } from "lucide-react"; import { type FormEvent, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, BrowserState, LessonStep } from "../../lib/protocol.gen"; @@ -57,18 +57,11 @@ export function useLesson(bot: Pick, state: BrowserState | null): Les }; } -/** Under the owner's pill: start a lesson, or the one being given, live. */ -export function LessonArea({ bot, lesson }: { bot: Bot; lesson: Lesson }) { +/** Under the owner's pill: the lesson being given, live. */ +export function LessonArea({ lesson }: { lesson: Lesson }) { const t = useT().browser.lesson; if (lesson.steps === null) { - return ( -
- -

{t.teachWhy(bot.name)}

-
- ); + return null; } return (
{t.recording}

+ {/* A fixed height: steps coming in never resize the page above. */} {lesson.steps.length === 0 ? ( -

{t.empty}

+

{t.empty}

) : ( -
    +
      {lesson.steps.map((step, index) => ( // biome-ignore lint/suspicious/noArrayIndexKey: steps only grow at the end
    1. diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index afa7bd30..8f7ef197 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -71,20 +71,20 @@ describe("browser panel", () => { expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull(); }); - test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => { + test("gives the page the shape of the room on the desk, 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: 516 by 688 - // for the page, once what goes around and under it is set aside. + // The room on the desk, inside its edge, as the app measures it: all + // of it is the page's, so no band of the desk shows beside it. vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560); vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804); await openScout(fake); 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: 516, height: 688 }])); + await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 560, height: 804 }])); // A narrow room keeps the desktop layout's width, in the room's shape. - expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1066 }); + expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1148 }); 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: 400, y: 1066, label: "More" }); + fake.browser.act(scout.id, "click", { x: 400, y: 1148, label: "More" }); }); const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement; expect(cursor.style.top).toBe("100%"); diff --git a/app/src/features/browser/tabs.test.tsx b/app/src/features/browser/tabs.test.tsx index e39be004..349076d1 100644 --- a/app/src/features/browser/tabs.test.tsx +++ b/app/src/features/browser/tabs.test.tsx @@ -87,6 +87,8 @@ describe("the tabs of the bot's browser", () => { await openPanel(fake, scout.id); fireEvent.click(within(panel()).getByRole("button", { name: "Take control" })); expect(await within(panel()).findByText("You are in control")).toBeDefined(); + // Folded with the rest of what the owner should know. + fireEvent.click(within(panel()).getByRole("button", { name: "How it works" })); expect(within(panel()).getByText("Scout goes on in the tab you leave open.")).toBeDefined(); fireEvent.click(tab("Rye · Example, example.com")); diff --git a/docs/spec.md b/docs/spec.md index b22ae8f6..65c83e74 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1301,7 +1301,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg ### 21.3 Página - **Abas:** o daemon se prende a toda página nova do navegador (`Target.setAutoAttach` no alvo do navegador, com `flatten` e `waitForDebuggerOnStart`) e a configura antes de ela rodar. Os comandos de preparo saem juntos com `Runtime.runIfWaitingForDebugger`, sem esperar resposta um a um: numa aba aberta pela página, `Page.enable` só responde depois que ela roda (visto com o Edge 154). O navegador só conta como aberto, e uma aba nova do dono só recebe um endereço, depois desse preparo: uma página aberta antes carregaria sem o daemon ver os eventos dela. Uma aba aberta pela página (`target=_blank`, `window.open`) vira a aba ativa, como para quem usa o navegador; quando ela fecha, a que era ativa antes dela volta (`Target.activateTarget`, para o navegador mostrar a mesma que o daemon). No máximo 6 abas: acima disso, fecha a que foi ativa há mais tempo. As tools agem sempre na ativa, e o bot não tem tool para trocar de aba; o dono, no controle, troca de aba, abre uma nova e vai a um endereço (21.10). O título de cada aba, para a lista que o dono vê (21.7), é o `document.title`, lido no mundo isolado (21.6) a cada carregamento e a cada mudança de endereço dentro da página: o Edge sem janela avisa quando a aba ganha um endereço, mas nunca o título dela (19). Até a página carregar, a aba fica sem título; um título que a página troca depois disso só aparece no carregamento seguinte. -- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que o painel tem para ela (`browser.resize`, 21.7): a largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). +- **Tela:** 1280 × 800 px, escala 1 (`Emulation.setDeviceMetricsOverride`), enquanto ninguém assiste. Com o painel aberto, a página toma o espaço que ela tem na mesa do painel (`browser.resize`, 21.7), medido onde ela aparece, por dentro da borda da mesa, já sem os avisos e os botões em volta: assim ela o ocupa inteiro, sem faixas da mesa dos lados. Um aviso que aparece ou some (o pedido de ajuda, as notas do controle) muda esse espaço, e a página acompanha. A largura do espaço, entre 800 e 1600 px, para a página aparecer quase no tamanho do app e o dono enxergar e mexer nela, e a altura na mesma proporção do espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio. Abaixo de 800 px a maioria dos sites troca a versão de computador pela de celular: um painel mais estreito que isso encolhe a imagem, não a página. Alargar o painel ou ocupar todo o espaço dá mais página. O tamanho vale para todas as abas, e volta a 1280 × 800 quando o último que assistia para. O que a página vê do navegador é o de um Edge comum, porque alguns sites recusam o login num navegador que um programa controla (19): o user agent é o do Edge sem a palavra `Headless`, com as marcas e a plataforma de `navigator.userAgentData` (`userAgentMetadata`, lidas uma vez ao abrir, numa página `chrome://version` que fecha em seguida: `about:blank` não tem `userAgentData`), e `navigator.webdriver` é `false` (`--disable-blink-features=AutomationControlled`). O disfarce para aí: nada de script injetado na página, e `outerWidth` e `outerHeight` continuam 0. O login de quem olha mais que isso, como o do Google, se faz numa janela de verdade (21.11). - **Diálogos** (`alert`, `confirm`, `prompt`, "sair da página?"): aceitos na hora; a resposta seguinte da tool diz ao bot o texto que apareceu. - **Escolher arquivo:** o pedido da página é interceptado e cancelado, e o bot lê que enviar arquivos pelo navegador ainda não é possível. - **Downloads:** vão para `\downloads\` (`Browser.setDownloadBehavior`) e aparecem no painel de arquivos (8.4); a tool diz quando um download termina. @@ -1371,7 +1371,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] | `browser.list` | | `BrowserState[]`: os navegadores que não estão fechados | | `browser.watch` | `botId` | `BrowserView {state, frame}`: o estado e o último quadro. A conexão passa a receber os quadros desse bot | | `browser.unwatch` | | `null` | -| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que o painel tem para a página, em pixels do app (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | +| `browser.resize` | `botId`, `width`, `height` | `null`: o espaço que a página tem na mesa do painel (21.3), em pixels do app (de 1 a 10 000 cada). O daemon ajusta a altura da página à proporção desse espaço (21.3). Só da conexão que assiste esse bot; vale também com o navegador fechado, para quando ele abrir. Cada navegador tem um tamanho só: o último pedido vale | | `browser.take` | `botId` | `BrowserState`: o dono assume o navegador (21.10). A conexão precisa estar assistindo esse bot, e o navegador aberto | | `browser.release` | `botId` | `BrowserState`: devolve o navegador ao bot; um pedido de ajuda aberto recebe **Pronto** | | `browser.input` | `botId`, `input` | `null`: um evento do dono na página. Só da conexão que controla | @@ -1394,7 +1394,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] - O painel divide o lugar com detalhes e arquivos (15.1) e é redimensionável como eles (`botloft.panel.computer`, a mesma do terminal e dos arquivos, 600 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 a mesa em volta (10 px dos lados e em cima, 26 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. +- 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, que ocupa a mesa toda menos uma borda fina (10 px; embaixo, 26 px, onde a pílula do controle se pendura sem nunca cobrir a página). 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". @@ -1452,7 +1452,7 @@ O dono pode usar o navegador do bot com as próprias mãos: clicar, arrastar, ro - 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. 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. +- 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, o que o bot pediu, numa linha, e uma fileira só de botões: "Ensinar uma tarefa" (21.13), "Entrar numa janela" e "Como funciona". O resto começa dobrado em "Como funciona", para a página ficar com o espaço: "Clique na tela para digitar" (ou, com a tela em foco, que o que se digita vai para a página), que o bot espera, que ele continua na aba que ficar aberta (com mais de uma aba), o porquê de ensinar e 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. Esc vai para a página, não devolve. ### 21.11 Entrar numa janela de verdade (N3.4) @@ -1493,7 +1493,7 @@ O dono mostra ao bot, uma vez, como fazer uma tarefa no navegador dele; o que fe - Enter, Escape e Tab sem Ctrl, Alt ou Meta viram `press`; - um endereço que o dono digita (`browser.open`) vira `open`. - **O que nunca é guardado.** O que o dono digita, cola ou escolhe: o script não lê valor de campo. Um endereço guarda só o esquema, o site e o caminho, sem usuário e senha, query e fragmento, onde viajam tokens e dados pessoais. O rótulo de cada passo tem no máximo 80 caracteres, e uma lição, 100 passos. Os passos ficam só em memória, em `BrowserState.lesson`, e não vão para o log. -- **No app.** Embaixo da pílula, "Ensinar uma tarefa" e a explicação de que o bot guarda cada passo, nunca o que é digitado. Gravando, uma faixa "Gravando a lição" mostra os passos ao vivo, numerados, com "Cancelar"; o botão da pílula vira "Terminar a lição". Terminada, um diálogo pede o nome da tarefa e mostra os passos, cada um com um X para tirar, e oferece: +- **No app.** Embaixo da pílula, "Ensinar uma tarefa", com a explicação de que o bot guarda cada passo, nunca o que é digitado, dobrada em "Como funciona" (21.10). Gravando, uma faixa "Gravando a lição" mostra os passos ao vivo, numerados, numa altura fixa que rola, para a página não mudar de tamanho a cada passo, com "Cancelar"; o botão da pílula vira "Terminar a lição". Terminada, um diálogo pede o nome da tarefa e mostra os passos, cada um com um X para tirar, e oferece: - **Fazer virar rotina…**: abre o diálogo de rotina nova (20.9) com o nome e o pedido já escritos: a lição em palavras e "Faça isso agora."; - **Mandar para guardar**: manda ao bot, como message do dono, a lição em palavras e o pedido de guardá-la na memória (5.1) para fazer quando o dono pedir. - **A lição em palavras** é montada pelo app, no idioma do dono, e pode ser editada na rotina: "Como fazer "" no navegador, do jeito que eu mostrei:", os passos numerados ("Abra ", "Clique em """, "Escreva em """, "Escreva a senha em "": peça para eu digitar", "Aperte Enter") e, se houve texto digitado, que o dono não mostra o quê: o bot usa o que a tarefa pede ou pergunta. Senhas, o bot pede com `browser_ask_owner` (21.10).