+
diff --git a/app/src/shell/rail.test.tsx b/app/src/shell/rail.test.tsx
new file mode 100644
index 00000000..29867895
--- /dev/null
+++ b/app/src/shell/rail.test.tsx
@@ -0,0 +1,49 @@
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../lib/fake";
+import { crewOpened, openBot, rail, renderApp } from "../test/app";
+import { prefs } from "./prefs";
+
+afterEach(() => {
+ cleanup();
+ prefs.sidebar.reset();
+});
+
+const list = () => screen.getByRole("navigation", { name: "Crews", hidden: true });
+const slot = () => list().closest(".sidebar-slot") as HTMLElement;
+const place = (name: string) => within(rail()).getByRole("button", { name });
+
+describe("the rail", () => {
+ test("goes to each place and marks the one open", async () => {
+ const fake = new FakeBotloft();
+ fake.addBot(fake.addCrew("Ops").id, "Scout");
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ expect(place("Home").getAttribute("aria-current")).toBeNull();
+
+ fireEvent.click(place("Routines"));
+ expect(place("Routines").getAttribute("aria-current")).toBe("page");
+ fireEvent.click(place("Search"));
+ expect(await screen.findByRole("searchbox")).toBeDefined();
+ fireEvent.click(place("Home"));
+ expect(place("Home").getAttribute("aria-current")).toBe("page");
+ expect(place("Search").getAttribute("aria-current")).toBeNull();
+ });
+
+ test("stays while the list is hidden, with the count of questions, and Home brings the list back", async () => {
+ const fake = new FakeBotloft();
+ const scout = fake.addBot(fake.addCrew("Ops").id, "Scout");
+ renderApp(fake);
+ await crewOpened("Ops");
+ fireEvent.click(screen.getByRole("button", { name: "Hide the bots list" }));
+ expect(slot().dataset.open).toBe("false");
+ act(() => {
+ fake.questions.ask(scout.id, "Which client first?");
+ });
+ expect(place("Questions").textContent).toContain("1 question waits for you");
+
+ fireEvent.click(place("Home"));
+ expect(slot().dataset.open).toBe("true");
+ });
+});
diff --git a/app/src/test/app.tsx b/app/src/test/app.tsx
index d94191f4..bb45d5b7 100644
--- a/app/src/test/app.tsx
+++ b/app/src/test/app.tsx
@@ -13,6 +13,8 @@ export function renderApp(fake = new FakeBotloft(), host = new FakeHost()) {
/** The crews sidebar. */
export const sidebar = () => screen.getByRole("navigation", { name: "Crews" });
+/** The icons at the far left (shell/Rail.tsx). */
+export const rail = () => screen.getByRole("navigation", { name: "Places" });
/** Waits for the first crew to open. */
export async function crewOpened(name: string) {
diff --git a/docs/spec.md b/docs/spec.md
index 09854bda..235c8193 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -433,7 +433,7 @@ O dono procura palavras em todas as conversas: o que ele mandou, o que outros bo
- **Por fora da fila.** `chat.search` responde quando fica pronta, como `files.list` (11.1): uma busca lenta não segura o texto ao vivo.
- **Privacidade.** A consulta é texto do dono e nunca vai para o log em `info` ou acima (8.5).
-**No app.** "Buscar", no topo da barra lateral (ou Ctrl+K), abre a busca no meio da janela: um campo, a escolha de uma equipe ou de todas, e os resultados conforme o dono escreve, cada um com o rosto e o nome do bot, a equipe, a hora, quem escreveu (o dono, outro bot, o Botloft ou o próprio bot) e o trecho com as palavras em destaque. "Mostrar mais" traz os anteriores. Um clique abre o chat do bot nesse ponto, com o item em destaque por um instante. A busca guarda o que foi digitado enquanto o app está aberto, e o texto volta selecionado, pronto para uma busca nova. Sem nada achado: "Nada encontrado".
+**No app.** "Buscar", no trilho de ícones (15.1; ou Ctrl+K), abre a busca no meio da janela: um campo, a escolha de uma equipe ou de todas, e os resultados conforme o dono escreve, cada um com o rosto e o nome do bot, a equipe, a hora, quem escreveu (o dono, outro bot, o Botloft ou o próprio bot) e o trecho com as palavras em destaque. "Mostrar mais" traz os anteriores. Um clique abre o chat do bot nesse ponto, com o item em destaque por um instante. A busca guarda o que foi digitado enquanto o app está aberto, e o texto volta selecionado, pronto para uma busca nova. Sem nada achado: "Nada encontrado".
### 8.9 Reações
@@ -820,7 +820,8 @@ 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; 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. 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.
+- **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.
+- **Área da conta**, no pé do trilho, como nos apps de chat: um círculo com a inicial; o tooltip diz 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, ao lado do trilho, um menu com o nome, o plano e o e-mail em cima 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.
- **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha.
- **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). **Sons do app** (`botloft.appSounds`, ligada por padrão) é outra chave, para sons mais baixos que esse, também feitos na hora e no mesmo timbre, só com o Botloft na frente: um "tic" quando a mensagem do dono sai, um "pop" quando chega uma resposta no chat aberto, duas notas claras quando um bot compartilha um arquivo nesse chat (só o que chega depois de abrir o chat) e uma faísca subindo quando nasce um bot ou uma equipe (de qualquer jeito: pelo dono, pelo chefe ou por outra janela). Nada toca a menos de 1,5 s de outro som, para muitos bots juntos não virarem um coral; só o pedido de um bot que precisa do dono passa por cima. Nada de som em clique, tecla, troca de chat ou texto chegando. O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso.
@@ -1223,7 +1224,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho
- "Avançado": expressão cron.
- No chat, a message da rotina aparece só com a etiqueta "Rotina ·
" e uma seta; o texto que a rotina manda fica dobrado até o dono abrir.
- Na página da crew, uma aba com as rotinas de todos os bots dela.
-- Na barra lateral, embaixo de Buscar e Perguntas, a página **Rotinas**, com as rotinas de todas as crews; a entrada aparece quando existe ao menos uma rotina. Em cima, "Próximas": as ligadas que rodam por horário, da mais próxima para a mais distante, cinco de início e "Mostrar mais" para o resto. Cada uma mostra o mascote do bot, o nome, quando roda de novo ("Amanhã às 09:00") e a frase de quando; uma rotina por intervalo diz "Vigiando" no lugar da hora, porque está sempre de olho; uma que está rodando diz "Rodando agora". Uma rotina por sinal não entra, porque espera o sinal. Um clique abre a rotina para editar. Embaixo, um grupo por bot que tem rotina, na ordem das crews e dos bots: o mascote, o nome do bot e o da crew, uma seta para dobrar o grupo e **+** para uma rotina nova desse bot, com a lista de 20.9 (interruptor, rodar agora, editar e apagar).
+- No trilho de ícones (15.1), a página **Rotinas**, com as rotinas de todas as crews; sem nenhuma, ela explica como ter uma. Em cima, "Próximas": as ligadas que rodam por horário, da mais próxima para a mais distante, cinco de início e "Mostrar mais" para o resto. Cada uma mostra o mascote do bot, o nome, quando roda de novo ("Amanhã às 09:00") e a frase de quando; uma rotina por intervalo diz "Vigiando" no lugar da hora, porque está sempre de olho; uma que está rodando diz "Rodando agora". Uma rotina por sinal não entra, porque espera o sinal. Um clique abre a rotina para editar. Embaixo, um grupo por bot que tem rotina, na ordem das crews e dos bots: o mascote, o nome do bot e o da crew, uma seta para dobrar o grupo e **+** para uma rotina nova desse bot, com a lista de 20.9 (interruptor, rodar agora, editar e apagar).
- Uma execução `failed` entra na marca da barra de tarefas (15.2) até o dono abrir o bot. O app guarda quando o dono abriu cada bot (`localStorage`, `botloft.seen`); um bot aberto não marca.
- O store carrega `routines.list` a cada conexão e segue `routine.changed` e `routine.run`.
- Textos nos três idiomas (15.6); a frase de "quando" é montada pelo app a partir do `schedule`.
@@ -1601,7 +1602,7 @@ Answer to your question qst_01J9Z...: ""
### 23.6 App (P2)
- **Cartão no chat:** " pergunta", o texto da pergunta (markdown, como uma resposta do bot), as opções como botões e um campo para escrever outra resposta, com **Responder** e **Descartar**. Respondida, o cartão mostra a resposta e quando; descartada, "Descartada". Opção escolhida responde na hora, com o texto da opção.
-- **Caixa:** "Perguntas", no topo da barra lateral, abaixo de "Equipes", com o número de perguntas abertas na cor de aviso. Abre no meio da janela a lista das perguntas abertas, cada uma com o rosto e o nome do bot, a crew, a hora e o mesmo cartão para responder ali mesmo, e "Abrir conversa" para ir ao chat do bot. Sem perguntas: o mascote e "Nenhuma pergunta esperando você".
+- **Caixa:** "Perguntas", no trilho de ícones (15.1), com o número de perguntas abertas na cor de aviso. Abre no meio da janela a lista das perguntas abertas, cada uma com o rosto e o nome do bot, a crew, a hora e o mesmo cartão para responder ali mesmo, e "Abrir conversa" para ir ao chat do bot. Sem perguntas: o mascote e "Nenhuma pergunta esperando você".
- Uma pergunta aberta conta como algo que espera o dono: marca o ícone na barra de tarefas e o ponto do botão da barra lateral escondida (15.1, 15.2).
- Textos nos três idiomas (15.6).