From 92044eaccf8cfb89015a13abb91666f920169e37 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:51:06 -0300 Subject: [PATCH] feat(app): hide the bots list to keep only the chat and the panel A button in the title bar, or Ctrl+B, slides the crews and bots out to the left so a small screen keeps only the chat and the bot's panel. The choice is remembered; while hidden, a dot on the button says something waits for the owner. The live dot's base rule moves into the components layer: unlayered, its position: relative beat Tailwind's absolute, so the dots on the header's browser and screens buttons sat below the button instead of on its corner. --- app/src/i18n/en/shell.ts | 5 ++ app/src/i18n/es/shell.ts | 5 ++ app/src/i18n/pt-BR/shell.ts | 5 ++ app/src/index.css | 1 + app/src/motion.css | 17 ++++--- app/src/shell/Workspace.tsx | 6 ++- app/src/shell/prefs.ts | 2 + app/src/shell/sidebarToggle.test.tsx | 55 +++++++++++++++++++++ app/src/shell/sidebarToggle.tsx | 73 ++++++++++++++++++++++++++++ app/src/sidebar.css | 12 +++++ docs/spec.md | 2 +- 11 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 app/src/shell/sidebarToggle.test.tsx create mode 100644 app/src/shell/sidebarToggle.tsx create mode 100644 app/src/sidebar.css diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index 7be45d3c..d9913069 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -8,6 +8,11 @@ export const shell = { restore: "Restore", close: "Close", }, + /** The button that hides the crews and bots on the left (Ctrl+B). */ + sidebar: { + hide: "Hide the bots list", + show: "Show the bots list", + }, zoom: { level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (default)` : `${percent}%`, diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index e4f239d3..acef4a27 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -7,6 +7,11 @@ export const shell: Messages["shell"] = { restore: "Restaurar", close: "Cerrar", }, + /** The button that hides the crews and bots on the left (Ctrl+B). */ + sidebar: { + hide: "Ocultar la lista de bots", + show: "Mostrar la lista de bots", + }, zoom: { level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (predeterminado)` : `${percent}%`, diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index e95ed305..abc42c74 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -7,6 +7,11 @@ export const shell: Messages["shell"] = { restore: "Restaurar", close: "Fechar", }, + /** The button that hides the crews and bots on the left (Ctrl+B). */ + sidebar: { + hide: "Esconder a lista de bots", + show: "Mostrar a lista de bots", + }, zoom: { level: (percent: number, isDefault: boolean) => isDefault ? `${percent}% (padrão)` : `${percent}%`, diff --git a/app/src/index.css b/app/src/index.css index 27edc1c5..ef766525 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -8,6 +8,7 @@ @import "./mascot.css"; @import "./mascot-flame.css"; @import "./panels.css"; +@import "./sidebar.css"; @import "./effort.css"; @import "./motion-less.css"; @import "./paused.css"; diff --git a/app/src/motion.css b/app/src/motion.css index 4a37a20b..ad2940cf 100644 --- a/app/src/motion.css +++ b/app/src/motion.css @@ -222,13 +222,16 @@ animation: caret 0.9s steps(2, jump-none) infinite; } -.live-dot { - position: relative; - display: inline-block; - width: 7px; - height: 7px; - border-radius: 9999px; - background: var(--danger); +/* In the components layer, so `absolute` and the like can place it. */ +@layer components { + .live-dot { + position: relative; + display: inline-block; + width: 7px; + height: 7px; + border-radius: 9999px; + background: var(--danger); + } } .live-dot::after { diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index 1dcf878d..32d7a0d6 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -18,6 +18,7 @@ import { Callout } from "../ui/Callout"; import { useBotAlerts } from "./alerts"; import { useAttentionMark } from "./attention"; import { useFolderNotices } from "./folders"; +import { SidebarSlot, SidebarToggle } from "./sidebarToggle"; import { useOpenAtSignIn } from "./signIn"; import { TitleBar } from "./TitleBar"; import { useTray } from "./tray"; @@ -57,7 +58,9 @@ export function Workspace() { } else { main = (
- + + +
{runtimeError ? (
@@ -92,6 +95,7 @@ export function Workspace() { } > + {hasCrews && } {main} diff --git a/app/src/shell/prefs.ts b/app/src/shell/prefs.ts index b4518e93..cb61a28c 100644 --- a/app/src/shell/prefs.ts +++ b/app/src/shell/prefs.ts @@ -56,6 +56,8 @@ export const prefs = { enterSends: pref("botloft.enterSends", true, ON_OFF), /** The browser and the screens open by themselves when a bot starts on them. */ followBot: pref("botloft.followBot", true, ON_OFF), + /** The crews and bots show on the left; off, only the selection does. */ + sidebar: pref("botloft.sidebar", true, ON_OFF), /** No motion in the window, whatever Windows says. */ lessMotion: pref("botloft.lessMotion", false, ON_OFF), /** With the bots working, closing the window leaves Botloft near the clock. */ diff --git a/app/src/shell/sidebarToggle.test.tsx b/app/src/shell/sidebarToggle.test.tsx new file mode 100644 index 00000000..bcabbf8a --- /dev/null +++ b/app/src/shell/sidebarToggle.test.tsx @@ -0,0 +1,55 @@ +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../lib/fake"; +import { crewOpened, 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; + +async function oneCrew() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + renderApp(fake); + await crewOpened("Ops"); + return { fake, scout }; +} + +describe("hiding the bots list", () => { + test("the title bar button hides the list and brings it back, and it is remembered", async () => { + await oneCrew(); + expect(slot().dataset.open).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Hide the bots list" })); + expect(slot().dataset.open).toBe("false"); + expect(slot().hasAttribute("inert")).toBe(true); + expect(localStorage.getItem("botloft.sidebar")).toBe("false"); + fireEvent.click(screen.getByRole("button", { name: "Show the bots list" })); + expect(slot().dataset.open).toBe("true"); + expect(slot().hasAttribute("inert")).toBe(false); + }); + + test("Ctrl+B does the same", async () => { + await oneCrew(); + fireEvent.keyDown(window, { key: "b", ctrlKey: true }); + expect(slot().dataset.open).toBe("false"); + fireEvent.keyDown(window, { key: "B", ctrlKey: true }); + expect(slot().dataset.open).toBe("true"); + fireEvent.keyDown(window, { key: "b" }); + expect(slot().dataset.open).toBe("true"); + }); + + test("with the list hidden, a dot on the button says a bot is waiting", async () => { + const { fake, scout } = await oneCrew(); + fireEvent.click(screen.getByRole("button", { name: "Hide the bots list" })); + const button = screen.getByRole("button", { name: "Show the bots list" }); + expect(button.parentElement?.querySelector(".live-dot")).toBeNull(); + fake.setBotState(scout.id, "needs_approval"); + await waitFor(() => expect(button.parentElement?.querySelector(".live-dot")).not.toBeNull()); + }); +}); diff --git a/app/src/shell/sidebarToggle.tsx b/app/src/shell/sidebarToggle.tsx new file mode 100644 index 00000000..80537ac8 --- /dev/null +++ b/app/src/shell/sidebarToggle.tsx @@ -0,0 +1,73 @@ +// Hiding the crews and bots on the left (spec 15.1), to keep only the chat +// and the bot's panel on a small screen. The choice is remembered. + +import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; +import { type ReactNode, useEffect } from "react"; +import { useT } from "../i18n"; +import { useApp } from "../store/context"; +import { attentionCount } from "./attention"; +import { prefs, usePref } from "./prefs"; + +export const toggleSidebar = () => prefs.sidebar.set(!prefs.sidebar.get()); + +/** The button in the title bar, with a dot while the list is hidden and something waits. */ +export function SidebarToggle() { + const t = useT().shell.sidebar; + const open = usePref(prefs.sidebar); + const waiting = useApp((state) => attentionCount(state) > 0); + const Icon = open ? PanelLeftClose : PanelLeftOpen; + return ( + + + {!open && waiting && ( + + )} + + ); +} + +/** Holds the list, sliding it out to the left when hidden. */ +export function SidebarSlot({ children }: { children: ReactNode }) { + const open = usePref(prefs.sidebar); + useSidebarKey(); + return ( +
+ {children} +
+ ); +} + +/** Ctrl+B shows or hides the list, as in code editors. */ +function useSidebarKey(): void { + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) { + return; + } + if (event.key.toLowerCase() === "b") { + event.preventDefault(); + toggleSidebar(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); +} diff --git a/app/src/sidebar.css b/app/src/sidebar.css new file mode 100644 index 00000000..08f0c168 --- /dev/null +++ b/app/src/sidebar.css @@ -0,0 +1,12 @@ +/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the list keeps + its width and slides out under the left edge while the chat widens. + Reduced motion makes it instant (motion.css). */ + +.sidebar-slot { + width: 18rem; + transition: width 320ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar-slot[data-open="false"] { + width: 0; +} diff --git a/docs/spec.md b/docs/spec.md index 9c50d4e0..0027b974 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -742,7 +742,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. 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, 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. +- **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. 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, 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: ela desliza para a esquerda e o resto se alarga. 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). Na tela de boas-vindas, antes da primeira crew, ela não se esconde. - **Á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.sound`, `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) 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.