diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 968c4de0..acc7791c 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; @@ -7,6 +7,7 @@ import { routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; +import { PanelClosing } from "../../ui/panelMotion"; import { SidePanel } from "../../ui/SidePanel"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { BrowserPanel } from "../browser/BrowserPanel"; @@ -22,6 +23,7 @@ import { ShowScreen } from "../screens/showScreen"; import { useScreens } from "../screens/useScreens"; import { BotHeader } from "./BotHeader"; import { stateView } from "./BotStateBadge"; +import { useFollowBot } from "./useFollowBot"; type Pane = "chat" | "routines"; /** What the panel beside the chat shows. */ @@ -34,6 +36,19 @@ type Side = "details" | "files" | "browser" | "screens" | null; export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const [side, setSide] = useState(null); + // The panel on screen: the one open, or the last one while it slides + // closed (spec 15.1). + const [leaving, setLeaving] = useState(null); + useEffect(() => { + if (side !== null) { + setLeaving(side); + } + }, [side]); + const beside = side ?? leaving; + const closing = useMemo( + () => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null), + [side, leaving], + ); const [pane, setPane] = useState("chat"); const files = useBotFiles(bot); // What the owner has seen: files newer than this are new to them. @@ -90,25 +105,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setScreenPath(path); void screens.refresh(); }; - // The design area opens by itself when the bot starts drawing a screen, - // once per screen, if nothing else is open beside the chat (spec 22.5). - const drawn = useRef(new Set()); - // biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts with its own screens - useEffect(() => { - drawn.current = new Set(); - setScreenPath(null); - }, [bot.id]); - // biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it - useEffect(() => { - const path = screens.writing?.toLowerCase(); - if (path && !drawn.current.has(path)) { - drawn.current.add(path); - if (side === null) { - setSide("screens"); - setScreenPath(null); - } - } - }, [screens.writing]); + // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's + useEffect(() => setScreenPath(null), [bot.id]); const showBrowser = (options?: { take?: boolean }) => { if (filesOpen) { seen(); @@ -118,6 +116,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { setTakeBrowser((count) => count + 1); } }; + // The panel follows what the bot starts doing (spec 15.1). + useFollowBot({ + bot, + side, + writing: screens.writing, + open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)), + }); const view = stateView(bot, crew.paused, t); const tabs: Tab[] = [ { id: "chat", label: t.routines.chatTab }, @@ -161,27 +166,31 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { )} - {side === "details" &&
setSide(null)} />} - {browserOpen && setSide(null)} />} - {screensOpen && ( - setSide(null)} - /> - )} - {filesOpen && ( - - )} + + {beside === "details" &&
setSide(null)} />} + {beside === "browser" && ( + setSide(null)} /> + )} + {beside === "screens" && ( + setSide(null)} + /> + )} + {beside === "files" && ( + + )} + ); diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts new file mode 100644 index 00000000..0ae6a9b7 --- /dev/null +++ b/app/src/features/bots/useFollowBot.ts @@ -0,0 +1,65 @@ +// The panel beside the chat follows what the bot starts doing (spec 15.1), +// 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. + +import { useEffect, useRef } from "react"; +import type { Bot } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; + +export type Followed = "browser" | "screens"; + +export function useFollowBot({ + bot, + side, + writing, + open, +}: { + bot: Pick; + /** The panel open now, if any. */ + side: string | null; + /** The screen the bot is writing now, if any. */ + writing: string | null; + open(panel: Followed): void; +}): void { + const browser = useApp((state) => state.browsers[bot.id]); + const browsing = browser?.status === "open" || browser?.status === "starting"; + const asking = Boolean(browser?.ask); + const held = browser?.control === "owner"; + // What was going on when the bot was opened: a browser left open stays + // behind its button, a request for a hand does not. + const before = useRef<{ browsing: boolean; asking: boolean } | null>(null); + const drawn = useRef(new Set()); + + // biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts afresh + useEffect(() => { + before.current = null; + drawn.current = new Set(); + }, [bot.id]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only a change in what the bot does opens it + useEffect(() => { + const was = before.current ?? { browsing, asking: false }; + before.current = { browsing, asking }; + if ((asking && !was.asking) || (browsing && !was.browsing)) { + if (side !== "browser") { + open("browser"); + } + } + }, [bot.id, browsing, asking]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it + useEffect(() => { + const path = writing?.toLowerCase(); + if (!path || drawn.current.has(path)) { + return; + } + drawn.current.add(path); + if (side === "screens" || (held && side === "browser")) { + return; + } + open("screens"); + }, [bot.id, writing]); +} diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index 86abd473..bf8501e6 100644 --- a/app/src/features/browser/browser.test.tsx +++ b/app/src/features/browser/browser.test.tsx @@ -68,18 +68,33 @@ describe("browser panel", () => { expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull(); }); - test("the button marks a bot that is browsing while the panel is closed", async () => { + test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => { const { fake, scout } = crew(); await openScout(fake); + fireEvent.click(screen.getByRole("button", { name: "Show files" })); expect(toggle().getAttribute("aria-label")).toBe("Show browser"); act(() => { fake.browser.open(scout.id, "https://example.com/", "Example"); }); - await waitFor(() => - expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"), - ); + // It takes the place of the files, so the owner sees the bot browse. + expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined(); + expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull(); + fireEvent.click(toggle()); - expect(toggle().getAttribute("aria-label")).toBe("Hide browser"); + expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"); + // Going on browsing does not open it again. + act(() => { + fake.browser.open(scout.id, "https://example.com/more", "More"); + }); + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(); + }); + + test("a browser already open when the bot is opened stays behind its button", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"); + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(); }); test("a closed browser keeps its last page, faded; one that failed says why", async () => { diff --git a/app/src/features/browser/hands.test.tsx b/app/src/features/browser/hands.test.tsx index b756ae1c..2d976515 100644 --- a/app/src/features/browser/hands.test.tsx +++ b/app/src/features/browser/hands.test.tsx @@ -101,6 +101,9 @@ describe("the owner's hands in the browser", () => { const { fake, scout } = crew(); fake.browser.ask(scout.id, "Sign in to your GitHub account"); await openScout(fake); + // The bot needs a hand: its browser opens by itself. + expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined(); + fireEvent.click(screen.getByRole("button", { name: "Hide browser" })); expect( screen.getByRole("button", { name: "Show browser: Scout needs you in the browser" }), ).toBeDefined(); diff --git a/app/src/features/messages/messages.test.tsx b/app/src/features/messages/messages.test.tsx index 72ed6702..ff5b5ed0 100644 --- a/app/src/features/messages/messages.test.tsx +++ b/app/src/features/messages/messages.test.tsx @@ -64,7 +64,9 @@ describe("messages", () => { expect(await within(messages()).findByText("note 1")).toBeDefined(); expect(within(messages()).getAllByRole("listitem")).toHaveLength(60); expect(screen.queryByRole("button", { name: "Load older messages" })).toBeNull(); - }); + // Sixty messages drawn twice: about 2 s alone, past the default 5 s + // when the whole suite runs at once. + }, 15_000); test("a message that was not delivered can be retried from the title bar", async () => { const { fake, lead, writer } = crew(); diff --git a/app/src/features/screens/screens.test.tsx b/app/src/features/screens/screens.test.tsx index 3910e7b9..74a501cd 100644 --- a/app/src/features/screens/screens.test.tsx +++ b/app/src/features/screens/screens.test.tsx @@ -103,17 +103,45 @@ describe("design area", () => { expect(within(panel()).getByText("landing.html")).toBeDefined(); }); - test("with another panel open, the button marks the drawing instead", async () => { + test("a screen being written takes the place of another panel, once", async () => { const { fake, scout } = crew(); await openScout(fake); fireEvent.click(screen.getByRole("button", { name: "Show files" })); act(() => { fake.screens.draft(scout.id, "C:\\Work\\a.html", "

"); }); + expect(await within(panel()).findByRole("list", { name: "Screens" })).toBeDefined(); + expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull(); + + // Closed, it stays closed while that screen is written; the button marks it. + fireEvent.click(toggle()); + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\a.html", "

More"); + }); + await waitFor(() => + expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"), + ); + expect(screen.queryByRole("complementary", { name: "Scout's screens" })).toBeNull(); + }); + + test("the browser in the owner's hands stays when a screen starts", async () => { + const { fake, scout } = crew(); + fake.browser.open(scout.id, "https://example.com/", "Example"); + await openScout(fake); + fireEvent.click(screen.getByRole("button", { name: /^Show browser/ })); + await waitFor(() => expect(fake.browser.watching).toBe(scout.id)); + act(() => { + fake.browser.frame(scout.id, "AAAA"); + }); + fireEvent.click(await screen.findByRole("button", { name: "Take control" })); + expect(await screen.findByText("You are in control")).toBeDefined(); + act(() => { + fake.screens.draft(scout.id, "C:\\Work\\a.html", "

"); + }); await waitFor(() => expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"), ); - expect(screen.getByRole("complementary", { name: "Files from Scout" })).toBeDefined(); + expect(screen.getByRole("complementary", { name: "Scout's browser" })).toBeDefined(); }); test("a Write of an HTML file in the chat opens its screen", async () => { diff --git a/app/src/index.css b/app/src/index.css index 15f8c23f..65c06f55 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -7,6 +7,7 @@ @import "./motion.css"; @import "./mascot.css"; @import "./mascot-flame.css"; +@import "./panels.css"; /* Colors are tokens so the light and dark themes swap in one place (spec 15.3). State colors always go with an icon and a label. */ diff --git a/app/src/panels.css b/app/src/panels.css new file mode 100644 index 00000000..bfdfe473 --- /dev/null +++ b/app/src/panels.css @@ -0,0 +1,31 @@ +/* Side panels slide (spec 15.1, panelMotion.ts): the panel widens from the + window's right edge, or from the width of the panel it replaces, while + its content, already at its full width, comes in with it, and the chat + narrows smoothly instead of jumping. Closing runs it back to the edge. + Reduced motion makes both instant (motion.css). */ + +@keyframes panel-open { + from { + width: var(--panel-from, 0px); + min-width: var(--panel-from, 0px); + } +} + +@keyframes panel-close { + to { + width: 0; + min-width: 0; + border-left-width: 0; + } +} + +.panel-opening { + overflow: hidden; + animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both; +} + +.panel-closing { + overflow: hidden; + pointer-events: none; + animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both; +} diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx index 6a6383a3..dc8fc775 100644 --- a/app/src/ui/SidePanel.tsx +++ b/app/src/ui/SidePanel.tsx @@ -1,9 +1,18 @@ // A panel beside the main area whose width the owner sets by dragging its // left edge (or with the arrow keys), remembered per panel. It never takes -// more than the main area can spare, so the chat keeps its room. +// more than the main area can spare, so the chat keeps its room. It slides +// open and closed at the right edge (panelMotion.ts). -import { type KeyboardEvent, type PointerEvent, type ReactNode, useRef, useState } from "react"; +import { + type CSSProperties, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + useRef, + useState, +} from "react"; import { useT } from "../i18n"; +import { usePanelMotion } from "./panelMotion"; const MIN_PX = 256; const MAX_PX = 900; @@ -50,6 +59,10 @@ export function SidePanel({ const [width, setWidth] = useState(() => stored(name, defaultWidth)); const panel = useRef(null); const drag = useRef<{ x: number; width: number; scale: number } | null>(null); + const { motion, from, ended } = usePanelMotion(panel, width); + // While it slides, the content keeps its full width and is cut at the + // moving edge, so it comes and goes without reflowing. + const sliding = motion !== "open"; const set = (next: number, keep = false) => { const clamped = clamp(next); @@ -101,12 +114,20 @@ export function SidePanel({

); } diff --git a/app/src/ui/panelMotion.ts b/app/src/ui/panelMotion.ts new file mode 100644 index 00000000..6ebd4ef8 --- /dev/null +++ b/app/src/ui/panelMotion.ts @@ -0,0 +1,78 @@ +// How a side panel slides (spec 15.1): open from the window's right edge, +// or from the width of the panel it replaces, and back to the edge before +// it goes. The keyframes are in panels.css; reduced motion makes both +// instant, and a timer stands in when the animation never reports its end. + +import { + type AnimationEvent, + createContext, + type RefObject, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; + +export const OPEN_MS = 460; +export const CLOSE_MS = 360; +/** How close in time a panel going and one coming count as a switch. */ +const SWITCH_MS = 120; + +/** Given to a panel while it slides closed; `closed` lets it go. */ +export const PanelClosing = createContext<{ closed(): void } | null>(null); + +/** The panel that just went away without sliding: another replaces it. */ +let replaced: { width: number; at: number } | null = null; + +export type Motion = "opening" | "open" | "closing"; + +export function usePanelMotion(panel: RefObject, width: number) { + const exit = useContext(PanelClosing); + const closing = exit !== null; + const [motion, setMotion] = useState("opening"); + const [from, setFrom] = useState(0); + const now = useRef({ motion, width, exit }); + now.current = { motion, width, exit }; + + // Before the first paint: start from the panel this one replaces, and + // leave this one's width for the next when it goes the same way. + useLayoutEffect(() => { + if (replaced && performance.now() - replaced.at < SWITCH_MS) { + setFrom(replaced.width); + } + replaced = null; + const element = panel; + return () => { + if (now.current.motion !== "closing") { + const shown = element.current?.offsetWidth || now.current.width; + replaced = { width: shown, at: performance.now() }; + } + }; + }, [panel]); + + useEffect(() => { + if (closing) { + setMotion("closing"); + const gone = setTimeout(() => now.current.exit?.closed(), CLOSE_MS + 150); + return () => clearTimeout(gone); + } + // Opening, or opened again while it was closing. + setMotion((current) => (current === "open" ? "open" : "opening")); + const opened = setTimeout(() => setMotion("open"), OPEN_MS + 150); + return () => clearTimeout(opened); + }, [closing]); + + const ended = (event: AnimationEvent) => { + if (event.target !== event.currentTarget) { + return; + } + if (event.animationName === "panel-open") { + setMotion((current) => (current === "opening" ? "open" : current)); + } else if (event.animationName === "panel-close") { + now.current.exit?.closed(); + } + }; + + return { motion, from, ended }; +} diff --git a/app/src/ui/sidepanel.test.tsx b/app/src/ui/sidepanel.test.tsx index 1010f114..0d3d826c 100644 --- a/app/src/ui/sidepanel.test.tsx +++ b/app/src/ui/sidepanel.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PanelClosing } from "./panelMotion"; import { SidePanel } from "./SidePanel"; beforeEach(() => localStorage.clear()); @@ -50,3 +51,54 @@ describe("side panel", () => { expect(width()).toBe(320); }); }); + +/** + * The animation named `name` ends on the panel itself. jsdom has no + * AnimationEvent, so React listens for the prefixed name there. + */ +function ends(element: HTMLElement, name: string) { + const event = new Event("webkitAnimationEnd", { bubbles: true }); + Object.defineProperty(event, "animationName", { value: name }); + act(() => { + fireEvent(element, event); + }); +} + +describe("side panel motion", () => { + test("slides open, then stays open", () => { + const { container } = render(panel()); + const aside = container.querySelector("aside") as HTMLElement; + expect(aside.className).toContain("panel-opening"); + ends(aside, "panel-open"); + expect(aside.className).not.toContain("panel-opening"); + }); + + test("while closing it leaves the page to the rest, and goes when the slide ends", () => { + const closed = vi.fn(); + const { container } = render( + {panel()}, + ); + const aside = container.querySelector("aside") as HTMLElement; + expect(aside.className).toContain("panel-closing"); + expect(aside.getAttribute("aria-hidden")).toBe("true"); + expect(screen.queryByRole("complementary")).toBeNull(); + ends(aside, "panel-close"); + expect(closed).toHaveBeenCalled(); + }); + + test("a panel replacing another starts from that one's width", () => { + localStorage.setItem("botloft.panel.first", "480"); + const view = render( + + one + , + ); + view.rerender( + + two + , + ); + const aside = view.container.querySelector("aside") as HTMLElement; + expect(aside.style.getPropertyValue("--panel-from")).toBe("480px"); + }); +}); diff --git a/docs/spec.md b/docs/spec.md index 6516f9c4..92768a93 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -622,7 +622,7 @@ Layout, como um app de mensagens: - **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. -- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. +- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. ### 15.2 Comandos Tauri @@ -1027,7 +1027,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 aberto e o painel fechado, o botão ganha um ponto que pulsa. +- 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 aberto e o painel fechado, o botão ganha um ponto que pulsa. 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). - 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, a barra do endereço: carregando ou não, o título e o endereço (só leitura, selecionável) e "Abrir no meu navegador" (`open_url`, 15.2). Embaixo, a tela ao vivo, na largura do painel, com 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"). - 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". @@ -1135,7 +1135,7 @@ Uma área de design ao lado do chat: cada página HTML que o bot faz aparece com ### 22.5 App -- No cabeçalho do bot, o botão **Telas**, ao lado do Navegador. Com uma tela sendo escrita e o painel fechado, o botão ganha um ponto que pulsa; se nenhum painel está aberto, o de telas abre sozinho quando começa uma escrita, uma vez por tela. +- No cabeçalho do bot, o botão **Telas**, ao lado do Navegador. Com uma tela sendo escrita e o painel fechado, o botão ganha um ponto que pulsa; o painel de telas abre sozinho quando começa uma escrita, no lugar de outro painel aberto, menos o navegador nas mãos do dono, uma vez por tela (15.1). - O painel divide o lugar com detalhes, arquivos e navegador (15.1), redimensionável (`botloft.panel.screens`, 640 px de início) e com o botão de alargar. - **Visão geral:** um quadro com fundo pontilhado, como nos apps de design, com as telas lado a lado, cada uma com o nome em cima e o tamanho do aparelho, reduzidas pelo zoom do quadro (menos, mais e ajustar; `botloft.screens.zoom`). A que está sendo escrita ganha a borda na cor do bot, o mascote trabalhando e "Escrevendo…", e o quadro rola até ela. Cada versão nova carrega por trás e troca quando pronta, sem piscar. Nessa visão as telas não recebem clique: um clique abre a tela. - **Uma tela:** na largura do painel e de verdade (rolar, clicar, preencher), com Computador, Tablet e Celular (`botloft.screens.devices`, por arquivo), "Abrir" (`open_file`, 15.2, no navegador do Windows), "Mostrar na pasta" e a volta para todas.