diff --git a/app/src/features/bots/BotAvatar.tsx b/app/src/features/bots/BotAvatar.tsx index 1c5f5ba0..31bfb639 100644 --- a/app/src/features/bots/BotAvatar.tsx +++ b/app/src/features/bots/BotAvatar.tsx @@ -77,11 +77,14 @@ export function BotAvatar({ size = 28, framed = false, mood, + still = false, }: { color: string; size?: number; framed?: boolean; mood?: Mood | undefined; + /** Shows the mood without moving. */ + still?: boolean; }) { // Every avatar on the page needs its own clip, blur and gradients. const unique = useId().replace(/[^\w-]/g, ""); @@ -106,6 +109,7 @@ export function BotAvatar({ viewBox={box.join(" ")} className={`shrink-0 overflow-visible ${framed ? "rounded-lg bg-[#0b0b0b]" : ""}`} data-mood={mood} + data-still={still || undefined} style={style} > @@ -203,3 +207,22 @@ function Eye({ eye, side, fill }: { eye: Ellipse; side: "l" | "r"; fill: string ); } + +/** + * A bot's mascot in a list (the sidebar, a crew's cards): it moves only + * while the bot does something. One idle mascot per bot, all moving, cost + * a repaint every frame even with nothing going on. + */ +export function ListAvatar({ + bot, + crewPaused, + size, +}: { + bot: Pick; + crewPaused: boolean; + size: number; +}) { + const mood = moodOf(bot, crewPaused); + const still = mood === "idle" || mood === "sleeping"; + return ; +} diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 25e93a3f..f4c07e91 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -60,7 +60,7 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { [side, leaving], ); const [pane, setPane] = useState("chat"); - const files = useBotFiles(bot); + const files = useBotFiles(bot, side === "files"); // What the owner has seen: files newer than this are new to them. const [seenAt, setSeenAt] = useState(() => Date.now()); const [since, setSince] = useState(seenAt); diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index dd2b9a35..f3e393e7 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -1,7 +1,7 @@ import { cleanup, render } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import flameFrames from "../../mascot-flame.css?raw"; -import { BOTLOFT_COLOR, BotAvatar } from "./BotAvatar"; +import { BOTLOFT_COLOR, BotAvatar, ListAvatar } from "./BotAvatar"; import { BotStateBadge } from "./BotStateBadge"; import { BODY, DRAWN_IN, OUTLINE, SHADES } from "./mascotArt"; import { retint, toHex, toHsl } from "./retint"; @@ -116,3 +116,28 @@ describe("state badge", () => { expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100"); }); }); + +describe("ListAvatar", () => { + const bot = (state: "idle" | "busy" | "offline") => ({ + color: BOTLOFT_COLOR, + state, + paused: false, + }); + + test("a bot at rest keeps its look but does not move; a working one moves", () => { + const { container, rerender } = render( + , + ); + const svg = () => container.querySelector("svg") as SVGElement; + expect(svg().dataset.mood).toBe("idle"); + expect(svg().hasAttribute("data-still")).toBe(true); + + rerender(); + expect(svg().dataset.mood).toBe("sleeping"); + expect(svg().hasAttribute("data-still")).toBe(true); + + rerender(); + expect(svg().dataset.mood).toBe("working"); + expect(svg().hasAttribute("data-still")).toBe(false); + }); +}); diff --git a/app/src/features/crews/CrewBots.tsx b/app/src/features/crews/CrewBots.tsx index fb35bd5e..fd67bdcb 100644 --- a/app/src/features/crews/CrewBots.tsx +++ b/app/src/features/crews/CrewBots.tsx @@ -3,7 +3,7 @@ import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { BotAvatar, moodOf } from "../bots/BotAvatar"; +import { ListAvatar } from "../bots/BotAvatar"; import { BotStateBadge } from "../bots/BotStateBadge"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; @@ -32,7 +32,7 @@ export function CrewBots({ crew, bots, onNewBot }: { crew: Crew; bots: Bot[]; on className="flex h-full w-full flex-col gap-2 rounded-xl border border-line bg-panel p-3.5 text-left transition-[border-color,transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-line-strong hover:shadow-sm" >
- +

{bot.name} diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 3dd85325..131374a4 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -11,7 +11,7 @@ import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; import { AccountArea } from "../account/AccountArea"; -import { BotAvatar, moodOf } from "../bots/BotAvatar"; +import { ListAvatar } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; @@ -106,7 +106,7 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { }} className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`} > - + diff --git a/app/src/features/files/useBotFiles.ts b/app/src/features/files/useBotFiles.ts index 2d34a70f..356b0d47 100644 --- a/app/src/features/files/useBotFiles.ts +++ b/app/src/features/files/useBotFiles.ts @@ -1,13 +1,16 @@ // The files a bot made, kept current (spec 8.5, 15.1): read when the bot is -// opened, and again while it works and when it stops, since a scan of its -// folders is the only way to see what a script or a command made. +// opened, again while it works with the files panel in sight, and when it +// stops, since a scan of its folders is the only way to see what a script +// or a command made. Each read walks the folders, so nobody pays for it +// while the list is out of sight. import { useCallback, useEffect, useRef, useState } from "react"; import { errorText } from "../../lib/api"; import type { Bot, BotFile } from "../../lib/protocol.gen"; +import { useWindowVisible } from "../../shell/visibility"; import { useApi, useApp } from "../../store/context"; -/** How often the list is read while the bot works. */ +/** How often the list is read while the bot works and the panel is in sight. */ const WHILE_WORKING_MS = 4000; export interface BotFiles { @@ -18,7 +21,8 @@ export interface BotFiles { refresh(): Promise; } -export function useBotFiles(bot: Pick): BotFiles { +/** `watching`: the files panel is open. */ +export function useBotFiles(bot: Pick, watching: boolean): BotFiles { const api = useApi(); const open = useApp((state) => state.connection.kind === "open"); const [files, setFiles] = useState([]); @@ -60,6 +64,7 @@ export function useBotFiles(bot: Pick): BotFiles { const working = bot.state === "busy" || bot.state === "needs_approval"; const wasWorking = useRef(working); + const inSight = useWindowVisible() && watching; useEffect(() => { if (!open) { return; @@ -69,12 +74,14 @@ export function useBotFiles(bot: Pick): BotFiles { void refresh(); } wasWorking.current = working; - if (!working) { + if (!working || !inSight) { return; } + // Coming into sight mid-turn shows what was made meanwhile. + void refresh(); const timer = setInterval(() => void refresh(), WHILE_WORKING_MS); return () => clearInterval(timer); - }, [open, working, refresh]); + }, [open, working, inSight, refresh]); return { files, loading, error, refresh }; } diff --git a/app/src/index.css b/app/src/index.css index 05d1659d..36aa5f29 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -10,6 +10,7 @@ @import "./panels.css"; @import "./effort.css"; @import "./motion-less.css"; +@import "./paused.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/main.tsx b/app/src/main.tsx index 3173c76a..2ea635eb 100644 --- a/app/src/main.tsx +++ b/app/src/main.tsx @@ -7,6 +7,7 @@ import { connect, rpcUrl } from "./lib/client"; import type { Host } from "./lib/host"; import { tauriHost } from "./lib/tauriHost"; import { prefs } from "./shell/prefs"; +import { markHidden, watchVisibility } from "./shell/visibility"; import { currentZoom } from "./shell/zoom"; import "./index.css"; @@ -37,6 +38,12 @@ props().then(({ host, connect: open }) => { if (prefs.lessMotion.get()) { document.documentElement.dataset.motion = "less"; } + watchVisibility(); + // Opened at sign-in, the window stays hidden until the owner opens it. + host.launchedAtSignIn().then( + (hidden) => hidden && !document.hasFocus() && markHidden(true), + () => {}, + ); createRoot(root).render( diff --git a/app/src/mascot.css b/app/src/mascot.css index 59bb2050..2c112b14 100644 --- a/app/src/mascot.css +++ b/app/src/mascot.css @@ -97,6 +97,13 @@ animation: spark 1.5s ease-out infinite; animation-delay: var(--spark-delay, 0s); } + + /* In a list, a bot at rest keeps its look (shut eyes, low flame) but + does not move: a morphing clip path repaints every frame, one per + mascot on screen. */ + [data-mood][data-still] * { + animation: none; + } } @keyframes eye-blink { diff --git a/app/src/paused.css b/app/src/paused.css new file mode 100644 index 00000000..3bec8271 --- /dev/null +++ b/app/src/paused.css @@ -0,0 +1,10 @@ +/* The window is out of sight (shell/visibility.ts): nothing animates for + nobody. Paused, not stopped, so each animation goes on where it was. */ + +/* biome-ignore-start lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */ +:root[data-hidden] *, +:root[data-hidden] *::before, +:root[data-hidden] *::after { + animation-play-state: paused !important; +} +/* biome-ignore-end lint/complexity/noImportantStyles: a hidden window must pause every animation, inline ones included */ diff --git a/app/src/shell/closing.ts b/app/src/shell/closing.ts index b1cbde0c..05f6966f 100644 --- a/app/src/shell/closing.ts +++ b/app/src/shell/closing.ts @@ -7,6 +7,7 @@ import { useEffect } from "react"; import type { Host } from "../lib/host"; import { prefs } from "./prefs"; import { trayShown } from "./tray"; +import { markHidden } from "./visibility"; /** * Stops the bots as the window closes, when the owner chose so. The window @@ -26,6 +27,7 @@ export function useCloseBehavior(host: Host): void { } if (trayShown()) { await host.window.hide().catch(() => {}); + markHidden(true); return "stay"; } return "close"; diff --git a/app/src/shell/visibility.test.tsx b/app/src/shell/visibility.test.tsx new file mode 100644 index 00000000..e624674a --- /dev/null +++ b/app/src/shell/visibility.test.tsx @@ -0,0 +1,74 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeAll, describe, expect, test, vi } from "vitest"; +import { useBotFiles } from "../features/files/useBotFiles"; +import { FakeBotloft } from "../lib/fake"; +import { createAppStore } from "../store/app"; +import { DaemonProvider } from "../store/context"; +import { syncStore } from "../store/sync"; +import { markHidden, watchVisibility, windowHidden } from "./visibility"; + +beforeAll(watchVisibility); + +afterEach(() => { + markHidden(false); + cleanup(); + vi.useRealTimers(); +}); + +const polls = (fake: FakeBotloft, method: string) => + fake.calls.filter((call) => call.method === method).length; + +describe("window visibility", () => { + test("hiding the window pauses the animations until it comes back", () => { + markHidden(true); + expect(windowHidden()).toBe(true); + expect(document.documentElement.hasAttribute("data-hidden")).toBe(true); + window.dispatchEvent(new Event("focus")); + expect(windowHidden()).toBe(false); + expect(document.documentElement.hasAttribute("data-hidden")).toBe(false); + }); + + test("the status is not polled while the window is hidden, and is read when it comes back", async () => { + vi.useFakeTimers(); + const fake = new FakeBotloft(); + const stop = syncStore(createAppStore(fake), fake); + await vi.advanceTimersByTimeAsync(0); + const before = polls(fake, "system.status"); + markHidden(true); + await vi.advanceTimersByTimeAsync(60_000); + expect(polls(fake, "system.status")).toBe(before); + markHidden(false); + await vi.advanceTimersByTimeAsync(0); + expect(polls(fake, "system.status")).toBe(before + 1); + stop(); + }); + + test("a working bot's files are read over and over only while their panel is in sight", async () => { + vi.useFakeTimers(); + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const busy = { id: scout.id, state: "busy" as const }; + const { rerender } = renderHook(({ watching }) => useBotFiles(busy, watching), { + wrapper, + initialProps: { watching: false }, + }); + await act(() => vi.advanceTimersByTimeAsync(0)); + const opened = polls(fake, "files.list"); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(polls(fake, "files.list")).toBe(opened); + + rerender({ watching: true }); + await act(() => vi.advanceTimersByTimeAsync(8_000)); + expect(polls(fake, "files.list")).toBeGreaterThanOrEqual(opened + 3); + + act(() => markHidden(true)); + const hidden = polls(fake, "files.list"); + await act(() => vi.advanceTimersByTimeAsync(20_000)); + expect(polls(fake, "files.list")).toBe(hidden); + }); +}); diff --git a/app/src/shell/visibility.ts b/app/src/shell/visibility.ts new file mode 100644 index 00000000..26d8bb5a --- /dev/null +++ b/app/src/shell/visibility.ts @@ -0,0 +1,48 @@ +// Whether the owner can see the window: not minimized, not hidden near the +// clock (spec 15.1), not opened hidden at sign-in. While they cannot, the +// app stops animating (paused.css) and stops asking the daemon for what +// only the screen needs. +// +// Windows may not tell the page when the app hides its own window, so the +// app marks that itself; the window coming back to the front clears it. + +import { useSyncExternalStore } from "react"; + +let hiddenByApp = false; +const listeners = new Set<() => void>(); + +export function windowHidden(): boolean { + return hiddenByApp || document.visibilityState === "hidden"; +} + +function changed(): void { + document.documentElement.toggleAttribute("data-hidden", windowHidden()); + for (const listener of listeners) { + listener(); + } +} + +/** The app hid its window, or showed it again. */ +export function markHidden(hidden: boolean): void { + if (hiddenByApp !== hidden) { + hiddenByApp = hidden; + changed(); + } +} + +/** Calls `listener` when the window is hidden or shown; returns the unsubscribe. */ +export function onVisibility(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Starts following the window; once, before the app renders. */ +export function watchVisibility(): void { + document.addEventListener("visibilitychange", changed); + window.addEventListener("focus", () => markHidden(false)); + changed(); +} + +export function useWindowVisible(): boolean { + return useSyncExternalStore(onVisibility, () => !windowHidden()); +} diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts index 3aa82c57..cc5f98f4 100644 --- a/app/src/store/sync.ts +++ b/app/src/store/sync.ts @@ -2,9 +2,13 @@ import { type BotloftApi, type ConnectionState, errorText } from "../lib/api"; import type { Delivery } from "../lib/protocol.gen"; +import { onVisibility, windowHidden } from "../shell/visibility"; import { type AppStore, applyEvent, crewList } from "./app"; -/** How often `system.status` is refreshed; it has no notification. */ +/** + * How often `system.status` is refreshed; it has no notification. Not while + * the window is out of sight: it is read again when it comes back. + */ const STATUS_POLL_MS = 15_000; /** Keeps `store` in sync with the daemon until the returned function runs. */ @@ -91,7 +95,7 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void { clearInterval(poll); if (connection.kind === "open") { load(); - poll = setInterval(refreshStatus, STATUS_POLL_MS); + poll = setInterval(() => !windowHidden() && refreshStatus(), STATUS_POLL_MS); } }; @@ -103,11 +107,17 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void { }); const unsubscribeConnection = api.onConnection(onConnection); onConnection(api.connection()); + const unsubscribeVisibility = onVisibility(() => { + if (!windowHidden() && api.connection().kind === "open") { + refreshStatus(); + } + }); return () => { alive = false; clearInterval(poll); unsubscribeEvents(); unsubscribeConnection(); + unsubscribeVisibility(); }; } diff --git a/docs/spec.md b/docs/spec.md index 084d4bd6..c9340b26 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -729,7 +729,7 @@ app/src/ Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem do Tauri. -O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s. +O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s, menos com a janela fora de vista (minimizada, escondida perto do relógio ou aberta escondida no login do Windows): aí para, e é relido quando ela volta. Fora de vista, as animações também param onde estão (`paused.css`) e continuam quando a janela volta. O Windows pode não avisar a página quando o próprio app esconde a janela, então o app marca isso ele mesmo (`shell/visibility.ts`), e a janela voltar à frente desmarca. - **Deliveries:** entram no store pela message (cada message tem uma). Vêm as 500 atualizadas mais recentemente e todas as mortas, depois cada `delivery.changed`. - **Tasks:** todas, depois cada `task.changed`. @@ -755,7 +755,7 @@ Layout, como um app de mensagens: - **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". 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. 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. 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, 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. +- **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 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. 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, 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 @@ -805,7 +805,7 @@ Tamanho: a janela inteira é desenhada numa escala (zoom do webview, `setZoom`), Movimento: o app se mexe para parecer vivo, sem atrapalhar o trabalho. Animações curtas (150 a 400 ms, saída suave) em CSS, sem biblioteca (`motion.css`), e todas param quando o Windows desliga os efeitos de animação (`prefers-reduced-motion`) ou quando o dono liga "Menos animações" nas Configurações (`motion-less.css` repete as mesmas regras para `:root[data-motion="less"]`, e `reducedMotion()` olha os dois). -- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes da barra lateral, do cabeçalho, dos cartões da crew e da resposta em andamento; o histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto. +- **Mascote:** cada bot tem um humor que segue o estado, e a chama queima de verdade: o contorno muda de forma quadro a quadro e o sombreado acompanha (`mascot.css`, `mascot-flame.css`). Parado, a chama balança devagar e os olhos dão uma olhada para baixo e para o lado de vez em quando, como nas poses do desenho; trabalhando (`busy`, `launching`), queima forte e solta faíscas pelo alto; esperando o dono (`needs_approval`), inclina rápido e dá pulinhos; no limite de uso, queima baixo, de pálpebras pesadas que caem de vez em quando; pausado ou parado, vira uma brasa pequena e dorme, de olhos fechados em dois arcos. Os olhos piscam fechando de cima e de baixo, cada bot num ritmo, para uma equipe não piscar junta. Mexem-se os mascotes do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot faz alguma coisa (trabalha, espera o dono, está no limite de uso); nessas listas, um bot parado ou dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto. - **Chegadas:** o que chega enquanto o dono olha entra subindo e aparecendo: mensagens, ferramentas, pedidos, avisos e bots novos na barra lateral. O que já estava lá quando ele abriu o chat (ou o app) aparece parado, e páginas antigas carregadas em cima não se mexem. A resposta já aparece enquanto é escrita, então não anima de novo ao terminar. - **Trabalhando:** os três pontos pulam em sequência; o texto sendo escrito tem um cursor piscando no fim; a mão de "Precisa de aprovação" acena; um pedido pendente pulsa uma vez ao aparecer. No estado do bot, o ponto de "Disponível" solta dois anéis, um depois do outro, e na linha de "Trabalhando" corre um trecho aceso (`StateIcons.tsx`); com menos movimento, a linha fica inteira acesa. - **Trocar de bot ou de crew:** o painel principal some rápido e o novo sobe (View Transitions do webview); a barra lateral fica onde está.