Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/src/i18n/en/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`,
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/es/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`,
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/pt-BR/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`,
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
17 changes: 10 additions & 7 deletions app/src/motion.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion app/src/shell/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,7 +58,9 @@ export function Workspace() {
} else {
main = (
<div className="flex min-h-0 flex-1">
<Sidebar />
<SidebarSlot>
<Sidebar />
</SidebarSlot>
<main className="flex min-w-0 flex-1 flex-col [view-transition-name:main-pane]">
{runtimeError ? (
<div className="border-line border-b p-3">
Expand Down Expand Up @@ -92,6 +95,7 @@ export function Workspace() {
</>
}
>
{hasCrews && <SidebarToggle />}
<Breadcrumb />
</TitleBar>
{main}
Expand Down
2 changes: 2 additions & 0 deletions app/src/shell/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export const prefs = {
enterSends: pref<boolean>("botloft.enterSends", true, ON_OFF),
/** The browser and the screens open by themselves when a bot starts on them. */
followBot: pref<boolean>("botloft.followBot", true, ON_OFF),
/** The crews and bots show on the left; off, only the selection does. */
sidebar: pref<boolean>("botloft.sidebar", true, ON_OFF),
/** No motion in the window, whatever Windows says. */
lessMotion: pref<boolean>("botloft.lessMotion", false, ON_OFF),
/** With the bots working, closing the window leaves Botloft near the clock. */
Expand Down
55 changes: 55 additions & 0 deletions app/src/shell/sidebarToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -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());
});
});
73 changes: 73 additions & 0 deletions app/src/shell/sidebarToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="relative grid">
<button
type="button"
aria-label={open ? t.hide : t.show}
title={`${open ? t.hide : t.show} (Ctrl+B)`}
aria-pressed={!open}
onClick={toggleSidebar}
className="grid h-7 w-7 place-items-center rounded-md text-ink-soft hover:bg-sunken hover:text-ink"
>
<Icon aria-hidden size={15} />
</button>
{!open && waiting && (
<span
aria-hidden
className="live-dot pointer-events-none absolute top-0.5 right-0.5"
style={{ background: "var(--warn)" }}
/>
)}
</span>
);
}

/** Holds the list, sliding it out to the left when hidden. */
export function SidebarSlot({ children }: { children: ReactNode }) {
const open = usePref(prefs.sidebar);
useSidebarKey();
return (
<div
inert={!open}
aria-hidden={!open}
data-open={open}
className="sidebar-slot flex shrink-0 overflow-hidden"
>
{children}
</div>
);
}

/** 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);
}, []);
}
12 changes: 12 additions & 0 deletions app/src/sidebar.css
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading