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.