From 8184efdcc96d5a634a699cfd6f91967cc7ed557b Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 4 Oct 2026 15:34:01 -0300 Subject: [PATCH] fix(app): smooth switching in the bot's computer, and a page you can see The browser, the terminal and the files share one width and keep the dock at their foot, so the dock switches only what is inside: the new panel's content fades up in place instead of sliding in, and the last frame and commands show at once. The page in the browser now takes the panel's width (800 to 1600 px) instead of a fixed 1280, so it shows near its real size; the desk around it is thinner. --- app/src/features/browser/BrowserPanel.tsx | 21 ++++-------- app/src/features/browser/browser.test.tsx | 10 +++--- app/src/features/browser/useBrowserView.ts | 35 ++++++++++++++++--- app/src/features/files/FilesPanel.tsx | 8 ++--- app/src/features/terminal/ComputerDock.tsx | 20 ++++++++++- app/src/features/terminal/TerminalPanel.tsx | 24 ++++++++++--- app/src/features/terminal/terminal.test.tsx | 3 +- app/src/lib/fakePages.ts | 7 ++-- app/src/panels.css | 14 ++++++++ app/src/terminal.css | 7 ++-- app/src/ui/SidePanel.tsx | 6 +++- crates/botloftd/src/browser/input.rs | 2 +- crates/botloftd/src/browser/session.rs | 4 +-- crates/botloftd/src/browser/viewport.rs | 38 +++++++++++++++------ crates/botloftd/tests/browser_panel.rs | 17 ++++----- docs/spec.md | 10 +++--- 16 files changed, 156 insertions(+), 70 deletions(-) diff --git a/app/src/features/browser/BrowserPanel.tsx b/app/src/features/browser/BrowserPanel.tsx index 896be730..f13b93e8 100644 --- a/app/src/features/browser/BrowserPanel.tsx +++ b/app/src/features/browser/BrowserPanel.tsx @@ -16,7 +16,7 @@ import { Callout } from "../../ui/Callout"; import { EmptyState } from "../../ui/EmptyState"; import { SidePanel } from "../../ui/SidePanel"; import { BotAvatar } from "../bots/BotAvatar"; -import { ComputerDock } from "../terminal/ComputerDock"; +import { ComputerFooter } from "../terminal/ComputerDock"; import { AddressBar } from "./AddressBar"; import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars"; import { HandsLayer } from "./HandsLayer"; @@ -34,12 +34,10 @@ 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. */ -const STAGE = 14; -const STAGE_BOTTOM = 30; +const STAGE = 10; +const STAGE_BOTTOM = 26; /** Kept under the page, for the pill and what the bot just did. */ const UNDER = 56; -/** The dock of the bot's computer, on the desk under the page. */ -const DOCK = 56; export function BrowserPanel({ bot, @@ -70,7 +68,7 @@ export function BrowserPanel({ const [inside, body] = useRoom(); const room = inside && { width: inside.width - 2 * PAD - 2 * STAGE, - height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER - DOCK, + height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER, }; useFitPage(bot, room, watched); // A new tab is blank: the owner says where it goes. @@ -92,7 +90,7 @@ export function BrowserPanel({ }, [take, watched, status, hands.held, hands.take]); return ( - +

{t.heading} @@ -146,12 +144,7 @@ export function BrowserPanel({ )} ) : status === "closed" && !frame ? ( - !windowed && ( - <> - - - - ) + !windowed && ) : ( <> {live && ask && !hands.held && } @@ -199,7 +192,6 @@ export function BrowserPanel({ )} - {(hands.held || (live && !ask)) && ( @@ -229,6 +221,7 @@ export function BrowserPanel({ )} + {lesson.done && } ); diff --git a/app/src/features/browser/browser.test.tsx b/app/src/features/browser/browser.test.tsx index d0e786a5..afa7bd30 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: 508 by 624 + // 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. 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: 508, height: 624 }])); - // As wide as ever, and as tall as the room's shape asks. - expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1572 }); + await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 516, height: 688 }])); + // 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 }); 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: 1572, label: "More" }); + fake.browser.act(scout.id, "click", { x: 400, y: 1066, label: "More" }); }); const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement; expect(cursor.style.top).toBe("100%"); diff --git a/app/src/features/browser/useBrowserView.ts b/app/src/features/browser/useBrowserView.ts index 4a50d6fc..85052a7b 100644 --- a/app/src/features/browser/useBrowserView.ts +++ b/app/src/features/browser/useBrowserView.ts @@ -2,7 +2,7 @@ // asks for its live frames and hears what the bot does, and stops asking // when the panel closes. The browser's state itself lives in the store. -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import type { Bot, BrowserAction, BrowserFrame } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -15,17 +15,44 @@ export interface BrowserView { watched: boolean; } +/** + * The newest picture of each bot's page, so the panel opening again (from + * the dock) shows it at once while the watch starts again. + */ +const lastFrames = new WeakMap>(); + +function framesOf(api: object): Map { + let frames = lastFrames.get(api); + if (!frames) { + frames = new Map(); + lastFrames.set(api, frames); + } + return frames; +} + export function useBrowserView(bot: Bot, watching: boolean): BrowserView { const api = useApi(); const connected = useApp((state) => state.connection.kind === "open"); const putBrowser = useApp((state) => state.putBrowser); - const [frame, setFrame] = useState(null); + const frames = framesOf(api); + const [frame, setShown] = useState(() => frames.get(bot.id) ?? null); + const setFrame = useCallback( + (next: BrowserFrame | null | ((current: BrowserFrame | null) => BrowserFrame | null)) => + setShown((current) => { + const value = typeof next === "function" ? next(current) : next; + if (value) { + frames.set(value.botId, value); + } + return value; + }), + [frames], + ); const [action, setAction] = useState(null); const [watched, setWatched] = useState(false); // biome-ignore lint/correctness/useExhaustiveDependencies: another bot's page is not this bot's useEffect(() => { - setFrame(null); + setFrame(frames.get(bot.id) ?? null); setAction(null); }, [bot.id]); @@ -59,7 +86,7 @@ export function useBrowserView(bot: Bot, watching: boolean): BrowserView { unsubscribe(); api.call("browser.unwatch").catch(() => {}); }; - }, [api, bot.id, watching, connected, putBrowser]); + }, [api, bot.id, watching, connected, putBrowser, setFrame]); return { frame, action, watched }; } diff --git a/app/src/features/files/FilesPanel.tsx b/app/src/features/files/FilesPanel.tsx index 90a6bab6..95187b0a 100644 --- a/app/src/features/files/FilesPanel.tsx +++ b/app/src/features/files/FilesPanel.tsx @@ -11,7 +11,7 @@ import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { EmptyState } from "../../ui/EmptyState"; import { SidePanel } from "../../ui/SidePanel"; -import { ComputerDock } from "../terminal/ComputerDock"; +import { ComputerFooter } from "../terminal/ComputerDock"; import { FilePreview } from "./FilePreview"; import { fileKind, kindIcon } from "./kinds"; import type { BotFiles } from "./useBotFiles"; @@ -42,7 +42,7 @@ export function FilesPanel({ (described?.path === path ? described : undefined); return ( - +

{t.heading}

@@ -63,9 +63,7 @@ export function FilesPanel({ ) : ( )} -
- -
+ ); } diff --git a/app/src/features/terminal/ComputerDock.tsx b/app/src/features/terminal/ComputerDock.tsx index 0bb12cef..ea8f3172 100644 --- a/app/src/features/terminal/ComputerDock.tsx +++ b/app/src/features/terminal/ComputerDock.tsx @@ -1,5 +1,7 @@ // The dock of a bot's computer (spec 15.1): its browser, its terminal and -// its files, one click apart, at the foot of each of those panels. +// its files, one click apart, at the foot of each of those panels. The +// three share one width (`computer`), so moving between them only changes +// what is inside. import { Folder, Globe, type LucideIcon, SquareTerminal } from "lucide-react"; import { createContext, useContext } from "react"; @@ -17,6 +19,22 @@ const PLACES: { place: Place; icon: LucideIcon }[] = [ { place: "files", icon: Folder }, ]; +/** + * The dock as the foot of a panel: the same place in the browser, the + * terminal and the files, so it stays still while the panel above changes. + */ +export function ComputerFooter() { + const dock = useContext(Dock); + if (!dock) { + return null; + } + return ( +
+ +
+ ); +} + export function ComputerDock({ className = "" }: { className?: string }) { const t = useT().terminal.dock; const dock = useContext(Dock); diff --git a/app/src/features/terminal/TerminalPanel.tsx b/app/src/features/terminal/TerminalPanel.tsx index 77ba4527..2170abce 100644 --- a/app/src/features/terminal/TerminalPanel.tsx +++ b/app/src/features/terminal/TerminalPanel.tsx @@ -7,11 +7,12 @@ import { LoaderCircle, X } from "lucide-react"; import { useEffect, useRef } from "react"; import { useT } from "../../i18n"; import type { Bot, ToolItem } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; import { Button } from "../../ui/Button"; import { SidePanel } from "../../ui/SidePanel"; import { commandOf, isCommand } from "../chat/command"; import { useChat } from "../chat/useChat"; -import { ComputerDock } from "./ComputerDock"; +import { ComputerFooter } from "./ComputerDock"; function Command({ tool }: { tool: ToolItem }) { const t = useT().terminal; @@ -45,14 +46,27 @@ function Command({ tool }: { tool: ToolItem }) { ); } +/** + * The commands each terminal showed last, so coming back to it (from the + * dock) shows them at once while the chat loads again. + */ +const lastShown = new WeakMap>(); + export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) { const t = useT().terminal; const chat = useChat(bot.id); - const commands = chat.items.flatMap((item) => + const api = useApi(); + const shown = lastShown.get(api) ?? new Map(); + lastShown.set(api, shown); + const loaded = chat.items.flatMap((item) => item.body.kind === "tool" && isCommand(item.body.name) ? [{ id: item.id, tool: item.body }] : [], ); + const commands = chat.loading && loaded.length === 0 ? (shown.get(bot.id) ?? []) : loaded; + if (!chat.loading) { + shown.set(bot.id, loaded); + } const screen = useRef(null); const last = commands.at(-1); // Follows the newest command, as a terminal does. @@ -65,14 +79,14 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) { }, [last?.id, last?.tool.status]); return ( - +

{t.heading}

@@ -102,9 +116,9 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) { )}
-
+ ); } diff --git a/app/src/features/terminal/terminal.test.tsx b/app/src/features/terminal/terminal.test.tsx index 1e1c303e..da1fc871 100644 --- a/app/src/features/terminal/terminal.test.tsx +++ b/app/src/features/terminal/terminal.test.tsx @@ -63,7 +63,8 @@ describe("the bot's terminal", () => { expect(dock("Terminal").getAttribute("aria-current")).toBe("page"); fireEvent.click(dock("Browser")); - expect(panel("Scout's browser")).toBeDefined(); + // It replaces the terminal in place: its content fades up, nothing slides in. + expect(panel("Scout's browser").className).toContain("panel-swapped"); fireEvent.click(dock("Files")); expect(panel("Files from Scout")).toBeDefined(); expect(dock("Files").getAttribute("aria-current")).toBe("page"); diff --git a/app/src/lib/fakePages.ts b/app/src/lib/fakePages.ts index ebb98d63..44f0a0c8 100644 --- a/app/src/lib/fakePages.ts +++ b/app/src/lib/fakePages.ts @@ -10,10 +10,11 @@ export interface PageSize { export const PAGE: PageSize = { width: 1280, height: 800 }; -/** The page for a panel with `width` by `height` of room. */ +/** The page for a panel with `width` by `height` of room, as the daemon fits it. */ export function fitting(width: number, height: number): PageSize { - const tall = Math.floor((PAGE.width * height) / width); - return { width: PAGE.width, height: Math.min(2000, Math.max(600, tall)) }; + const wide = Math.min(1600, Math.max(800, width)); + const tall = Math.floor((wide * height) / Math.max(1, width)); + return { width: wide, height: Math.min(2000, Math.max(600, tall)) }; } /** diff --git a/app/src/panels.css b/app/src/panels.css index 84342bdb..a569441d 100644 --- a/app/src/panels.css +++ b/app/src/panels.css @@ -21,6 +21,20 @@ animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both; } +/* One panel replacing another (the dock, or a header button while one is + open): it does not slide in again, its content fades up in place. The + dock at its foot stays still. */ +@keyframes panel-swap { + from { + opacity: 0; + transform: translateY(4px); + } +} + +.panel-swapped > :not([data-steady]):not([role="separator"]) { + animation: panel-swap 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + /* Out of the flow, on the right edge of the chat and its panel. */ .panel-closing { position: absolute; diff --git a/app/src/terminal.css b/app/src/terminal.css index b286b6c7..0e1e405a 100644 --- a/app/src/terminal.css +++ b/app/src/terminal.css @@ -38,9 +38,8 @@ color: var(--terminal-danger); } -/* The dock sits on the desk, frosted, as a dock does. */ +/* The dock at the foot of the panel, the same in the three places. */ .computer-dock { - background: color-mix(in oklab, var(--panel) 55%, transparent); - backdrop-filter: blur(8px); - box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--panel) 70%, transparent); + background: var(--sunken); + box-shadow: inset 0 0 0 1px var(--line); } diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx index 51990430..9e7a4924 100644 --- a/app/src/ui/SidePanel.tsx +++ b/app/src/ui/SidePanel.tsx @@ -122,7 +122,11 @@ export function SidePanel({ } as CSSProperties } className={`relative flex shrink-0 flex-col border-line border-l bg-panel ${ - motion === "opening" ? "panel-opening" : motion === "closing" ? "panel-closing" : "" + motion === "opening" + ? `panel-opening ${from > 0 ? "panel-swapped" : ""}` + : motion === "closing" + ? "panel-closing" + : "" }`} onAnimationEnd={ended} > diff --git a/crates/botloftd/src/browser/input.rs b/crates/botloftd/src/browser/input.rs index 4151711a..172aecfd 100644 --- a/crates/botloftd/src/browser/input.rs +++ b/crates/botloftd/src/browser/input.rs @@ -279,7 +279,7 @@ mod tests { assert!(!fits(&mouse(f64::NAN, 10.0))); assert!(!fits(&mouse(10.0, 900.0))); // A taller page takes points further down. - let tall = Viewport::fitting(640, 700); + let tall = Viewport::fitting(640, 1000); assert!(super::fits(&mouse(10.0, 900.0), tall)); let long = BrowserInput::Text { text: "x".repeat(TEXT_MAX + 1), diff --git a/crates/botloftd/src/browser/session.rs b/crates/botloftd/src/browser/session.rs index a9361337..657e274d 100644 --- a/crates/botloftd/src/browser/session.rs +++ b/crates/botloftd/src/browser/session.rs @@ -16,7 +16,7 @@ use tracing::debug; use super::cdp::Cdp; use super::events::pump; use super::launch::{self, BrowserProcess}; -use super::viewport::{MAX_HEIGHT, Viewport, WIDTH}; +use super::viewport::{MAX_HEIGHT, MAX_WIDTH, Viewport}; use super::{BrowserError, agent}; /// Most tabs a browser keeps; past this, the one active longest ago closes. @@ -253,7 +253,7 @@ impl Session { let result = if on { // Frames come the size of the page, however tall it is. let params = json!({ - "format": "jpeg", "quality": 60, "maxWidth": WIDTH, "maxHeight": MAX_HEIGHT, + "format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT, }); self.cdp .call(Some(session), "Page.startScreencast", params) diff --git a/crates/botloftd/src/browser/viewport.rs b/crates/botloftd/src/browser/viewport.rs index 411d9bd8..bae9c821 100644 --- a/crates/botloftd/src/browser/viewport.rs +++ b/crates/botloftd/src/browser/viewport.rs @@ -1,5 +1,6 @@ -//! The size of the page (spec 21.3): as wide as a desktop screen always, -//! and as tall as the owner's panel asks while they watch. +//! The size of the page (spec 21.3): a desktop screen while the bot works +//! alone, and the shape and width of the owner's panel while they watch, +//! so the page shows near its own size there. use serde_json::{Value, json}; use tracing::debug; @@ -10,6 +11,10 @@ use super::session::{FRAME_GAP, Session}; /// one nobody asked to change. pub const WIDTH: u32 = 1280; pub const HEIGHT: u32 = 800; +/// How narrow and how wide the owner's panel may make the page: narrower, +/// most sites would drop their desktop layout. +pub const MIN_WIDTH: u32 = 800; +pub const MAX_WIDTH: u32 = 1600; /// How short and how tall the owner's panel may make the page. pub const MIN_HEIGHT: u32 = 600; pub const MAX_HEIGHT: u32 = 2000; @@ -32,12 +37,13 @@ impl Default for Viewport { impl Viewport { /// The page for a panel with `width` by `height` of room: as wide as - /// ever, so sites keep their desktop layout, and as tall as the room's - /// shape asks, within limits. + /// the room, so it shows at its own size, within limits that keep the + /// desktop layout, and as tall as the room's shape asks. pub fn fitting(width: u32, height: u32) -> Self { - let tall = u64::from(WIDTH) * u64::from(height) / u64::from(width.max(1)); + let wide = width.clamp(MIN_WIDTH, MAX_WIDTH); + let tall = u64::from(wide) * u64::from(height) / u64::from(width.max(1)); Self { - width: WIDTH, + width: wide, height: u32::try_from(tall) .unwrap_or(MAX_HEIGHT) .clamp(MIN_HEIGHT, MAX_HEIGHT), @@ -109,15 +115,24 @@ mod tests { #[test] fn the_page_takes_the_shape_of_the_room_within_limits() { let page = |width, height| Viewport::fitting(width, height); - // A panel 536 wide with 700 of height: the same shape, 1280 wide. + // A panel 1000 wide with 700 of height: the same size. + assert_eq!( + page(1000, 700), + Viewport { + width: 1000, + height: 700 + } + ); + // A narrow one keeps the desktop layout, in the room's shape. assert_eq!( page(536, 700), Viewport { - width: 1280, - height: 1671 + width: 800, + height: 1044 } ); assert_eq!(page(1280, 800), Viewport::default()); + assert_eq!(page(2400, 900).width, MAX_WIDTH); // Too flat or too tall a room stops at the limits. assert_eq!(page(1600, 300).height, MIN_HEIGHT); assert_eq!(page(256, 1400).height, MAX_HEIGHT); @@ -128,8 +143,9 @@ mod tests { #[test] fn points_are_on_the_page_up_to_its_edges() { let page = Viewport::fitting(640, 700); - assert!(page.contains(1280.0, 1400.0)); - assert!(!page.contains(10.0, 1400.5)); + assert!(page.contains(800.0, 875.0)); + assert!(!page.contains(10.0, 875.5)); + assert!(!page.contains(800.5, 10.0)); assert!(!page.contains(-1.0, 10.0)); assert!(!page.contains(f64::NAN, 10.0)); } diff --git a/crates/botloftd/tests/browser_panel.rs b/crates/botloftd/tests/browser_panel.rs index 7fd9943f..0ae7c893 100644 --- a/crates/botloftd/tests/browser_panel.rs +++ b/crates/botloftd/tests/browser_panel.rs @@ -70,24 +70,25 @@ async fn the_page_takes_the_shape_of_the_panel_while_the_owner_watches() { ); answer_site(&mut b.app, true, None).await; let page = opening.await.expect("task").expect("page"); - assert!(page.contains("Window: 1280 x 1671"), "{page}"); - let frame = frame_of(&mut b.app, 1671).await; - assert_eq!(frame["width"], 1280); + // A narrow panel: the desktop layout's width, in the panel's shape. + assert!(page.contains("Window: 800 x 1044"), "{page}"); + let frame = frame_of(&mut b.app, 1044).await; + assert_eq!(frame["width"], 800); // The panel changes: the page follows, and so do the frames. b.app - .call("browser.resize", room(640, 400)) + .call("browser.resize", room(1000, 400)) .await .expect("resize"); - until_window(&mut b, "1280 x 800").await; - frame_of(&mut b.app, 800).await; + until_window(&mut b, "1000 x 600").await; + frame_of(&mut b.app, 600).await; // The owner's points go as far as the page does now. b.app - .call("browser.resize", room(640, 700)) + .call("browser.resize", room(1000, 1400)) .await .expect("resize"); - until_window(&mut b, "1280 x 1400").await; + until_window(&mut b, "1000 x 1400").await; frame_of(&mut b.app, 1400).await; b.app .call("browser.take", json!({ "botId": id })) diff --git a/docs/spec.md b/docs/spec.md index 4b49f001..ee92c44a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -819,7 +819,7 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As Layout, como um app de mensagens: - **Terminal do bot:** os comandos que o bot rodou (as chamadas `Bash` e `PowerShell` do chat), numa janela de terminal sobre a mesa na cor do bot (21.8), ao vivo: o que ele disse que o comando faz, como comentário (`# ...`); o comando depois de `❯`; e o que ele imprimiu, em vermelho se falhou, ou "rodando…" enquanto roda. Lê a página mais nova do chat do bot e segue os itens novos, rolando até o último. O terminal é escuro nos dois temas (tokens `--terminal*`). Abre pelo botão "Ver no terminal" na linha de um comando no chat, ou pela doca. -- **Doca do computador do bot:** Navegador, Terminal e Arquivos como ícones numa faixa fosca, embaixo da página na mesa do navegador e do terminal, e no pé do painel de arquivos (e embaixo do navegador vazio). O lugar aberto fica marcado; um clique troca o painel. A doca ocupa o seu lugar na conta do tamanho da página (21.8). +- **Doca do computador do bot:** Navegador, Terminal e Arquivos como ícones numa faixa, no pé dos três painéis, no mesmo lugar. O lugar aberto fica marcado; um clique troca o painel. Os três dividem uma largura só (`botloft.panel.computer`, 600 px de início), então trocar não mexe na largura: o painel novo não desliza, o conteúdo dele aparece subindo um pouco (220 ms) e a doca fica parada. Isso vale para qualquer painel que troca outro, como pelos botões do cabeçalho. Voltar a um deles mostra na hora o que ele mostrava, o último quadro do navegador e os últimos comandos do terminal, enquanto o resto chega. - **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto; um bot parado e livre, `idle`, não mostra estado, porque é o normal) e a prévia da última atividade (`lastActivity`) com a hora. O que alguém escreveu (resposta, mensagem, pergunta) aparece em texto simples, sem as marcas de Markdown que o chat desenha (`**negrito**`, títulos, listas, links); um comando ou caminho de ferramenta fica como veio. Um bot que respondeu desde a última vez que o dono olhou o chat dele fica marcado como não lido, como num app de mensagens: nome em negrito, prévia e hora em destaque e um ponto na cor de destaque no fim da linha, até o dono abrir o chat. Vale toda resposta (`lastReplyAt`, e cada item `reply` que chega), mesmo com outra coisa depois dela na conversa. Com o chat aberto e a janela à vista, o que chega já conta como visto; com a janela escondida, conta quando ela volta. O app guarda no `localStorage` quando o dono olhou cada bot (`botloft.seen`) e, na primeira vez, a hora de começar a marcar (`botloft.seenSince`): respostas de antes contam como vistas, para uma atualização não marcar todos os bots de uma vez. O dono também marca um bot como não lido pelo menu dele ("Marcar como não lida"; num bot marcado, "Marcar como lida"), para lembrar de voltar à conversa: a marca fica no `localStorage` (`botloft.unread`) até ele abrir o chat, e marcar o chat aberto o fecha e volta à página da crew. Ao lado do nome de cada crew, um número na cor de destaque diz quantos bots dela estão não lidos. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, marcar como não lida ou lida, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. Um clique direito no nome de uma crew abre o menu da crew do mesmo jeito, sem abri-la: novo bot, pausar ou retomar, renomear, abrir a pasta de trabalho, mudar a pasta, arquivar e excluir, as mesmas ações dos botões e do menu "mais" da página da crew (`useCrewActions`). O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola. Um botão na barra de título, ao lado do nome do Botloft, esconde a barra lateral (ou Ctrl+B), para numa tela pequena sobrarem só o chat e o painel do bot: o resto se alarga de uma vez e ela desliza para a esquerda por cima dele (só `transform`, para a conversa não ser refeita a cada quadro). A escolha fica no `localStorage` (`botloft.sidebar`). Escondida, um ponto no botão avisa quando algo espera o dono (o mesmo que marca o ícone na barra de tarefas, 15.2) ou, parado e na cor de destaque, quando algum bot tem resposta não lida. Na tela de boas-vindas, antes da primeira crew, ela não se esconde. Uma seta ao lado do nome de cada crew a recolhe: ficam só o nome, o número de não lidos e, se um bot dela espera o dono, um ponto na cor de aviso; a conversa aberta continua à vista. Um botão no topo recolhe ou expande todas. As crews recolhidas ficam no `localStorage` (`botloft.collapsedCrews`). - **Página das crews:** clicar em "Equipes", no topo da barra lateral, abre no meio da janela todas as crews como cartões: nome, número de bots, os rostos dos primeiros seis (parados, para a página não redesenhar cada chama), quantos bots trabalham e quantos esperam o dono, ou "Pausada". Um cartão abre a crew. É também o que aparece quando nenhuma crew está escolhida (a crew aberta foi arquivada ou excluída), e ela continua aberta depois de uma reconexão; só a primeira carga abre a primeira crew. - **Trilho de ícones**, na beirada esquerda, antes da barra lateral: os lugares do app como ícones, com o nome no tooltip. **Início** (a casa) abre a página das crews e traz a barra lateral de volta se o dono a escondeu; **Buscar** (8.8), **Perguntas** (23.6), com o número de perguntas abertas na cor de aviso, e **Rotinas** (20.9). O lugar aberto fica marcado. O trilho fica mesmo com a barra lateral escondida (Ctrl+B), com as contagens à vista; aparece também antes da primeira crew. @@ -839,7 +839,7 @@ Layout, como um app de mensagens: - **Excluir** (7.6): "Excluir bot" é a última ação do menu do bot (no cabeçalho e no clique direito) e "Excluir equipe" a última do menu da crew, as duas em vermelho, depois de Arquivar. A confirmação leva o nome no título ("Excluir Scout?") e diz, em palavras simples, o que sai e o que fica: o bot para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte, e não dá para desfazer; se ele é o chefe, que a equipe fica sem chefe; e, num quadro à parte, que a pasta dele continua no computador, com o caminho para copiar. A da crew diz quantos bots param e saem com ela, e mostra a pasta de trabalho, lembrando que a pasta de cada bot também fica. No mesmo quadro, uma caixa desmarcada: "Mandar esta pasta para a Lixeira" (na crew, "Mandar as pastas da equipe para a Lixeira"). Marcada, o texto do quadro passa a dizer que a pasta vai para a Lixeira, de onde ainda dá para recuperar, e a crew mostra a pasta dela no Botloft em vez da `shared\`; se o dono escolheu a pasta de trabalho, o quadro diz que a pasta de cada bot vai e a escolhida fica onde está. O app manda `recycleFolder` só com a caixa marcada. Se a pasta não pôde ir (`folder.recycled` com `error`), um aviso diz que ela não foi para a Lixeira e continua lá, com o caminho e o motivo. Confirmado, o app tira o bot ou a crew da tela sem esperar o aviso do daemon; se falhar, um aviso diz que não foi possível e nada muda. Excluído o bot aberto, fica a página da crew; excluída a crew aberta, nenhuma fica escolhida (sem crews, a tela de boas-vindas). Uma message de um bot que saiu, arquivado ou excluído, aparece na conversa e na timeline como de "um bot que não está mais na equipe". - **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. - **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Quando o pedido tem `always` (10.1), entre Permitir e Negar fica um terceiro botão que diz o que cobre: "Permitir sempre este comando", "Permitir sempre em example.com", "Permitir sempre neste arquivo" ou só "Permitir sempre"; a dica dele avisa que o bot não vai pedir aquilo de novo e que dá para desfazer nos detalhes. Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. -- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. 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. +- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. No fim dele, "Permitido sem perguntar" lista as regras do bot (10.1): a ação no idioma do dono ("Rodar um comando") e, embaixo, o comando, o site ou o arquivo, cada uma com um X que faz o bot voltar a perguntar. A lista é lida ao abrir o painel e segue `bot.rules`; vazia, diz que "Permitir sempre" coloca as coisas ali. 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.computer` para o navegador, o terminal e os arquivos, que dividem a largura, e `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 e cor pelo tipo, nome, pasta, tamanho, "há 5 min"). Cada tipo tem seu ícone num quadrado da sua cor, para Word, planilha e PDF se distinguirem de longe: PDF vermelho, Word azul, planilha verde, apresentação laranja, imagem roxa, áudio e vídeo rosa, compactado âmbar, código azul-petróleo, texto e o resto cinza. O tipo vem primeiro da extensão (os `.doc`, `.xls`, `.ppt` e OpenDocument chegam como `application/octet-stream`) e depois do media type. 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 com o painel aberto e a janela à vista, e de novo quando ele para: cada leitura percorre as pastas do bot, então ninguém paga por ela sem olhar a lista. Com o painel fechado, o contador abaixo se atualiza quando o turno acaba. 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. Um `share_file` que deu certo (8.4) não vira linha: cada arquivo vira um cartão no chat, com o quadrado do tipo (ou a miniatura de uma imagem de até 8 MB, lida com `files.read`), nome, extensão e tamanho, "Abrir" e "Salvar como…" (`save_file_as`, 15.2). Um clique no arquivo abre a prévia no painel. Num chat estreito, o botão de salvar fica só com o ícone. Se a resposta da tool não puder ser lida, ou se a chamada falhou, fica a linha da ferramenta, com o erro. Detalhes, arquivos, o navegador (21.8), as telas (22.5) e o terminal (abaixo) 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; o chat toma a largura nova de uma vez, e fechando o painel sai deslizando por cima dele. Só um `transform` se move, para a conversa não ser refeita a cada quadro (a 180 Hz, cada quadro tem 5,5 ms). 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, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou. ### 15.2 Comandos Tauri @@ -1298,7 +1298,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 px de largura, escala 1 (`Emulation.setDeviceMetricsOverride`), e 800 px de altura enquanto ninguém assiste. Com o painel aberto, a altura acompanha o espaço que o painel tem para a página (`browser.resize`, 21.7): a mesma proporção desse espaço, entre 600 e 2000 px, para a tela ao vivo ocupar o painel em vez de deixar um vazio embaixo. A largura não muda, para os sites mostrarem sempre a versão de computador: um painel estreito encolhe a imagem, não a 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 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). - **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. @@ -1388,9 +1388,9 @@ 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, 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. +- 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 (14 px dos lados e em cima, 30 px embaixo) o lugar da doca (56 px, 15.1) e o 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"). +- 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. - 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.