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.