diff --git a/app/src/features/bots/BotStateBadge.tsx b/app/src/features/bots/BotStateBadge.tsx index e2b67ab7..577cd6fa 100644 --- a/app/src/features/bots/BotStateBadge.tsx +++ b/app/src/features/bots/BotStateBadge.tsx @@ -8,17 +8,19 @@ import { Power, RotateCw, } from "lucide-react"; +import { useState } from "react"; import { type Messages, t, useT } from "../../i18n"; import type { Bot, BotState } from "../../lib/protocol.gen"; import { TONES, type Tone as ToneName } from "../../ui/tone"; -import { ReadyIcon, type StateIcon, WorkingIcon } from "./StateIcons"; +import { type StateIcon, WorkingIcon } from "./StateIcons"; type Tone = Exclude; export interface StateView { label: string; tone: Tone; - icon: StateIcon; + /** None for idle: a bot doing nothing is free, which needs no badge. */ + icon: StateIcon | null; spin?: boolean; /** One sentence on what the state means for the owner. */ hint: string; @@ -30,7 +32,7 @@ type Look = Omit; const looks: Record = { offline: { tone: "quiet", icon: Power }, launching: { tone: "work", icon: LoaderCircle, spin: true }, - idle: { tone: "ok", icon: ReadyIcon }, + idle: { tone: "ok", icon: null }, busy: { tone: "work", icon: WorkingIcon }, needs_approval: { tone: "warn", icon: Hand }, rate_limited: { tone: "warn", icon: Hourglass }, @@ -70,10 +72,19 @@ export function BotStateBadge({ const messages = useT(); const view = stateView(bot, crewPaused, messages); const Icon = view.icon; + // The badge fades in when a bot leaves idle in front of the owner; one + // that was already there when the list opened shows still. + const [wasShown, setWasShown] = useState(Icon !== null); + const [entering, setEntering] = useState(false); + if ((Icon !== null) !== wasShown) { + setWasShown(Icon !== null); + setEntering(Icon !== null); + } + if (!Icon) return null; return ( ; -/** "Ready": a dot with two rings spreading from it, one after the other. */ -export function ReadyIcon({ size = 14, className = "" }: StateIconProps) { - return ( - - - - - - ); -} - // The line of lucide's Activity, the icon this state had before it moved. const PULSE = "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"; diff --git a/app/src/features/bots/avatar.test.tsx b/app/src/features/bots/avatar.test.tsx index 88d281fc..cc520e95 100644 --- a/app/src/features/bots/avatar.test.tsx +++ b/app/src/features/bots/avatar.test.tsx @@ -106,15 +106,26 @@ describe("mascot", () => { }); describe("state badge", () => { - test("ready pulses and working runs its line", () => { + test("idle shows nothing and working runs its line", () => { const { container, rerender } = render( , ); - expect(container.querySelectorAll(".state-ring")).toHaveLength(2); + expect(container.textContent).toBe(""); rerender(); expect(container.querySelector(".state-trace")?.getAttribute("pathLength")).toBe("100"); }); + + test("fades in when the bot starts working, not when it was already working", () => { + const { container, rerender } = render( + , + ); + expect(container.querySelector(".animate-fade")).toBeNull(); + + rerender(); + rerender(); + expect(container.querySelector(".animate-fade")).not.toBeNull(); + }); }); describe("ListAvatar", () => { diff --git a/app/src/motion.css b/app/src/motion.css index d22ad1d3..9aceac3d 100644 --- a/app/src/motion.css +++ b/app/src/motion.css @@ -111,25 +111,12 @@ } @layer components { - /* The state icons of "Ready" and "Working" (features/bots/StateIcons.tsx): - two rings spread from the dot, one after the other; a bright stretch - runs along the pulse line. The mascot has its own file (mascot.css). */ - .state-ring, + /* The state icon of "Working" (features/bots/StateIcons.tsx): a bright + stretch runs along the pulse line. The mascot has its own file + (mascot.css). */ .state-trace { transform-box: fill-box; transform-origin: 50%; - } - - .state-ring { - opacity: 0; - animation: ring 2.6s var(--ease-out) infinite; - } - - .state-ring-late { - animation-delay: 1.3s; - } - - .state-trace { stroke-dasharray: 26 74; animation: trace 1.5s linear infinite; } @@ -162,17 +149,6 @@ } } -@keyframes ring { - from { - opacity: 0.6; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(2); - } -} - @keyframes trace { to { stroke-dashoffset: -100px; diff --git a/docs/spec.md b/docs/spec.md index 5271c28b..8fd56948 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -797,7 +797,7 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As Layout, como um app de mensagens: -- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. 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. 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`). +- **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. 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. 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. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. - **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.markReplies`, `botloft.sound`, `botloft.appSounds`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada), "Marcar o ícone quando um bot responder" (ligada, `botloft.markReplies`: a marca do ícone na barra de tarefas, 15.2, aparece também enquanto algum bot está não lido; só no Windows, onde essa marca existe) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. @@ -871,9 +871,9 @@ 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 do cabeçalho e da resposta em andamento, e os da barra lateral e dos cartões da crew enquanto o bot está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. 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 está acordado, parado também: é pela chama devagar e pelas olhadas que o dono vê de relance que ele está livre. Nessas listas, só um bot dormindo mostra o humor sem se mexer (`ListAvatar`), porque cada chama que muda de forma redesenha a tela a cada quadro e um bot desligado não tem o que mostrar. O histórico do chat fica parado. O humor soma ao estado, que continua com cor, ícone e texto, menos o de parado e livre (`idle`), que não aparece: a chama devagar já diz isso. - **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. +- **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. O estado de um bot que sai de parado aparece suave (o que já estava na tela ao abrir fica parado), 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á. - **Superfícies:** menus e seletores abrem a partir de onde estão presos; diálogos aparecem com o fundo escurecendo e, fechados de qualquer jeito (X, Esc, Cancelar, Salvar), somem esmaecendo e encolhendo um pouco (150 ms); avisos entram deslizando; `
` abre deslizando; a linha da aba selecionada desliza até a nova; as barras de uso enchem ao abrir; botões afundam um pouco ao clicar; o mascote da tela de boas-vindas e do chat vazio flutua.