diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 231279b8..25f4e169 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -136,6 +136,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { side, writing: screens.writing, open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)), + close: (panel) => side === panel && setSide(null), }); const view = stateView(bot, crew.paused, t); const tabs: Tab[] = [ diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts index a8d05630..69e28a2f 100644 --- a/app/src/features/bots/useFollowBot.ts +++ b/app/src/features/bots/useFollowBot.ts @@ -2,8 +2,9 @@ // so the owner sees it happen: its browser starting or a request for a // hand opens the browser (spec 21.8), and a screen it starts writing opens // the design area (spec 22.5), whatever panel was open. Each opens once per -// start; closing it leaves the button's dot. The browser in the owner's -// hands is never taken away. The owner can turn this off in Settings +// start; closing it leaves the button's dot. When the browser goes to rest +// (spec 21.2) its panel closes, and it opens again once the bot wakes it. +// The browser in the owner's hands is never taken away. The owner can turn this off in Settings // (`prefs.followBot`): then only the buttons' dots say so. Coming back to a // bot is opening it again: the panel the owner left is the one open (the // store keeps it), and what opens by itself here takes its place. @@ -20,6 +21,7 @@ export function useFollowBot({ side, writing, open, + close, }: { bot: Pick; /** The panel open now, if any. */ @@ -27,9 +29,12 @@ export function useFollowBot({ /** The screen the bot is writing now, if any. */ writing: string | null; open(panel: Followed): void; + close(panel: Followed): void; }): void { const browser = useApp((state) => state.browsers[bot.id]); - const browsing = browser?.status === "open" || browser?.status === "starting"; + // A browser at rest is open, but the bot is not using it. + const resting = browser?.status === "open" && browser.resting; + const browsing = browser?.status === "starting" || (browser?.status === "open" && !resting); const asking = Boolean(browser?.ask); const held = browser?.control === "owner"; // What was going on when the bot was opened: a browser left open stays @@ -51,6 +56,10 @@ export function useFollowBot({ if (side !== "browser" && prefs.followBot.get()) { open("browser"); } + } else if (resting && was.browsing && !asking && !held) { + if (side === "browser" && prefs.followBot.get()) { + close("browser"); + } } }, [bot.id, browsing, asking]); diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index c9429ebf..39750efa 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -113,26 +113,60 @@ describe("browser panel", () => { }); expect(within(panel()).getByText("Live")).toBeDefined(); - // The bot's turn ended: the page stays, and the panel says it rests. + // The bot's turn ended: the panel closes by itself, and the button does + // not say the bot is using it. act(() => { fake.browser.rest(scout.id); }); + await waitFor(() => expect(toggle().getAttribute("aria-label")).toBe("Show browser")); + // Opened again, the page is still there and the panel says it rests. + fireEvent.click(toggle()); const resting = within(panel()).getByText("Resting"); expect(resting.title).toContain("Scout isn't using the browser"); expect(within(panel()).queryByText("Live")).toBeNull(); expect(within(panel()).getByRole("figure").querySelector("img")).not.toBeNull(); - // Closed, its button does not say the bot is using it. - fireEvent.click(toggle()); - expect(toggle().getAttribute("aria-label")).toBe("Show browser"); // The owner's hands wake it. - fireEvent.click(toggle()); fireEvent.click(await within(panel()).findByRole("button", { name: "Take control" })); expect(await within(panel()).findByText("You are in control")).toBeDefined(); expect(within(panel()).queryByText("Resting")).toBeNull(); expect(fake.browser.state(scout.id).resting).toBe(false); }); + test("closes when the browser rests and opens again when the bot wakes it, but not out of the owner's hands", async () => { + const { fake, scout } = crew(); + await openScout(fake); + act(() => { + fake.browser.open(scout.id, "https://example.com/", "Example"); + }); + await waitFor(() => expect(toggle().getAttribute("aria-label")).toBe("Hide browser")); + + act(() => { + fake.browser.rest(scout.id); + }); + await waitFor(() => + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(), + ); + + // The bot's next step wakes it, and the panel follows. + act(() => { + fake.browser.open(scout.id, "https://example.com/next", "Next"); + }); + await waitFor(() => expect(toggle().getAttribute("aria-label")).toBe("Hide browser")); + + // With the browser in the owner's hands, it stays. + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + fireEvent.click(await within(panel()).findByRole("button", { name: "Take control" })); + expect(await within(panel()).findByText("You are in control")).toBeDefined(); + act(() => { + fake.browser.rest(scout.id); + }); + expect(toggle().getAttribute("aria-label")).toBe("Hide browser"); + }); + test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => { const { fake, scout } = crew(); await openScout(fake); diff --git a/docs/spec.md b/docs/spec.md index 9c50d4e0..26650b81 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1270,7 +1270,7 @@ Esqueceu a senha? [e5 link "Recuperar acesso"] ### 21.8 App (N2) -- 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 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). +- 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").