diff --git a/app/src/features/account/AccountArea.tsx b/app/src/features/account/AccountArea.tsx index b60b71b4..dc2167ac 100644 --- a/app/src/features/account/AccountArea.tsx +++ b/app/src/features/account/AccountArea.tsx @@ -1,12 +1,11 @@ -// The owner at the bottom of the sidebar, like the account corner of a -// chat app: their name and Claude plan, and a menu with usage, settings, -// language, what's new and help (spec 15.1). +// The owner at the foot of the rail, like the account corner of a chat +// app: their initial, and a menu with their name and Claude plan, usage, +// settings, language, what's new and help (spec 15.1). import { ChartColumn, Check, ChevronRight, - ChevronsUpDown, CircleHelp, Languages, Settings, @@ -53,10 +52,11 @@ export function AccountArea() { }; return ( -
+
{open && ( setOpen(false)} @@ -67,8 +67,9 @@ export function AccountArea() { aria-haspopup="menu" aria-expanded={open} aria-label={a.open(name)} + title={detail ? `${name} · ${detail}` : name} onClick={() => setOpen(!open)} - className={`flex w-full items-center gap-2.5 rounded-lg px-2 py-1.5 text-left hover:bg-sunken ${open ? "bg-sunken" : ""}`} + className={`grid size-10 place-items-center rounded-xl hover:bg-sunken ${open ? "bg-sunken" : ""}`} > {Array.from(name)[0]?.toUpperCase()} - - {name} - {detail && {detail}} - - {dialog === "usage" && setDialog(null)} />} {dialog === "settings" && setDialog(null)} />} @@ -101,11 +97,13 @@ function describe( function AccountMenu({ name, + detail, email, onShow, onClose, }: { name: string; + detail: string; email: string | null; onShow(which: "usage" | "settings"): void; onClose(): void; @@ -125,13 +123,17 @@ function AccountMenu({
- {email && ( -

- {email} -

- )} +
+

{name}

+ {detail &&

{detail}

} + {email && ( +

+ {email} +

+ )} +
onShow("usage")}> {m.usage} diff --git a/app/src/features/account/account.test.tsx b/app/src/features/account/account.test.tsx index 378c9d2f..5a72fcd2 100644 --- a/app/src/features/account/account.test.tsx +++ b/app/src/features/account/account.test.tsx @@ -21,12 +21,15 @@ async function openAccount() { } describe("account area", () => { - test("the owner and their plan show at the bottom of the sidebar, even before a crew", async () => { + test("the owner sits at the foot of the rail, even before a crew, with their plan", async () => { renderApp(); await screen.findByRole("heading", { name: "Welcome to Botloft" }); const button = screen.getByRole("button", { name: "Ana Lima: account and settings" }); - expect(button.textContent).toContain("Ana Lima"); - expect(button.textContent).toContain("Max plan"); + expect(button.textContent).toBe("A"); + expect(button.title).toBe("Ana Lima · Max plan"); + fireEvent.click(button); + const menu = screen.getByRole("menu", { name: "Ana Lima" }); + expect(menu.textContent).toContain("Max plan"); }); test("the title bar keeps no settings once connected", async () => { diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 19fe8307..74575336 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -13,7 +13,6 @@ import { CountBadge } from "../../ui/Badge"; import { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; -import { AccountArea } from "../account/AccountArea"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; @@ -21,7 +20,7 @@ import { ListAvatar } from "../bots/ListAvatar"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; import { setAllCollapsed, setCollapsed, useCollapsed, useCollapsedSet } from "./collapsed"; -import { QuestionsEntry, RoutinesEntry, row, SearchEntry } from "./SidebarPages"; +import { row } from "./SidebarPages"; /** Crews as sections and their bots as conversations (spec 15.1). */ export function Sidebar() { @@ -63,16 +62,12 @@ export function Sidebar() { onClick={() => setCreating(true)} />
- - -
    {crews.map((crew) => ( ))}
- {creating && setCreating(false)} />}
); diff --git a/app/src/features/crews/SidebarPages.tsx b/app/src/features/crews/SidebarPages.tsx index 34e838c0..e06293fc 100644 --- a/app/src/features/crews/SidebarPages.tsx +++ b/app/src/features/crews/SidebarPages.tsx @@ -1,86 +1,6 @@ -// The pages at the top of the sidebar: the search (spec 8.8), the -// question box (spec 23.6) and every routine (spec 20.9). - -import { AlarmClock, MessageCircleQuestion, Search } from "lucide-react"; -import { useT } from "../../i18n"; -import { useApp } from "../../store/context"; -import { openQuestions } from "../../store/questions"; -import { CountBadge } from "../../ui/Badge"; +// What the sidebar's rows share. The pages that used to sit at its top +// (search, questions, routines) are on the rail now (shell/Rail.tsx). /** A row of the sidebar, as the conversations are. */ export const row = "relative flex w-full items-center rounded-lg text-left transition-colors duration-150 hover:bg-sunken"; - -/** The search (spec 8.8). */ -export function SearchEntry() { - const t = useT(); - const open = useApp((state) => state.page === "search"); - const openPage = useApp((state) => state.openPage); - return ( -
- -
- ); -} - -/** The question box (spec 23.6), with how many questions wait. */ -export function QuestionsEntry() { - const t = useT(); - const words = t.questions.box; - const count = useApp((state) => openQuestions(state).length); - const open = useApp((state) => state.page === "questions"); - const openPage = useApp((state) => state.openPage); - return ( -
- -
- ); -} - -/** Every routine of every bot (spec 20.9), shown once there is one. */ -export function RoutinesEntry() { - const t = useT(); - const any = useApp((state) => Object.keys(state.routines).length > 0); - const open = useApp((state) => state.page === "routines"); - const openPage = useApp((state) => state.openPage); - if (!any && !open) { - return null; - } - return ( -
- -
- ); -} diff --git a/app/src/features/questions/questions.test.tsx b/app/src/features/questions/questions.test.tsx index a2091af6..f3688786 100644 --- a/app/src/features/questions/questions.test.tsx +++ b/app/src/features/questions/questions.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { FakeBotloft } from "../../lib/fake"; -import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; +import { crewOpened, openBot, rail, renderApp, sidebar } from "../../test/app"; afterEach(cleanup); @@ -16,7 +16,7 @@ function crew() { return { fake, scout, writer }; } -const entry = () => within(sidebar()).getByRole("button", { name: /^Questions/ }); +const entry = () => within(rail()).getByRole("button", { name: "Questions" }); describe("questions to the owner", () => { test("a question in the chat is answered with a ready answer", async () => { @@ -52,7 +52,8 @@ describe("questions to the owner", () => { const { fake, scout, writer } = crew(); const { host } = renderApp(fake); await crewOpened("Ops"); - expect(entry().textContent).toBe("Questions"); + // No count while nothing waits. + expect(entry().textContent).toBe(""); expect(host.attention).toBe(false); act(() => { diff --git a/app/src/features/routines/routinesPage.test.tsx b/app/src/features/routines/routinesPage.test.tsx index b2f3c3e2..8e766d7c 100644 --- a/app/src/features/routines/routinesPage.test.tsx +++ b/app/src/features/routines/routinesPage.test.tsx @@ -42,12 +42,11 @@ async function openPage(fake: FakeBotloft) { } describe("the routines page", () => { - test("is in the sidebar only once there is a routine", async () => { + test("is on the rail, and says how to get one before any routine", async () => { const fake = new FakeBotloft(); fake.addBot(fake.addCrew("Ops").id, "Scout", "Finds sources"); - renderApp(fake); - await crewOpened("Ops"); - expect(screen.queryByRole("button", { name: "Routines" })).toBeNull(); + await openPage(fake); + expect(screen.getByText("No routines yet")).toBeDefined(); }); test("lists what comes up across crews, interval routines as keeping watch", async () => { diff --git a/app/src/features/search/search.test.tsx b/app/src/features/search/search.test.tsx index 4444bb27..ea652e09 100644 --- a/app/src/features/search/search.test.tsx +++ b/app/src/features/search/search.test.tsx @@ -1,7 +1,7 @@ import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; import { afterEach, describe, expect, test } from "vitest"; import { FakeBotloft } from "../../lib/fake"; -import { crewOpened, renderApp, sidebar } from "../../test/app"; +import { crewOpened, rail, renderApp } from "../../test/app"; import { snippetParts } from "./SearchResult"; afterEach(cleanup); @@ -56,7 +56,7 @@ describe("searching the chats", () => { const { fake } = world(); renderApp(fake); await crewOpened("Ops"); - fireEvent.click(within(sidebar()).getByRole("button", { name: /^Search/ })); + fireEvent.click(within(rail()).getByRole("button", { name: "Search" })); const all = await search("saturday"); expect(within(all).getAllByRole("listitem")).toHaveLength(1); fireEvent.click(screen.getByRole("button", { name: /All crews/ })); diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts index aa0bbe9b..54ccb310 100644 --- a/app/src/i18n/en/shell.ts +++ b/app/src/i18n/en/shell.ts @@ -9,6 +9,11 @@ export const shell = { close: "Close", }, /** The button that hides the crews and bots on the left (Ctrl+B). */ + /** The icons at the far left. */ + rail: { + label: "Places", + home: "Home", + }, sidebar: { hide: "Hide the bots list", show: "Show the bots list", diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts index f61b6eda..bbf94a9a 100644 --- a/app/src/i18n/es/shell.ts +++ b/app/src/i18n/es/shell.ts @@ -8,6 +8,10 @@ export const shell: Messages["shell"] = { close: "Cerrar", }, /** The button that hides the crews and bots on the left (Ctrl+B). */ + rail: { + label: "Lugares", + home: "Inicio", + }, sidebar: { hide: "Ocultar la lista de bots", show: "Mostrar la lista de bots", diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts index 204dad0f..ab009a07 100644 --- a/app/src/i18n/pt-BR/shell.ts +++ b/app/src/i18n/pt-BR/shell.ts @@ -8,6 +8,10 @@ export const shell: Messages["shell"] = { close: "Fechar", }, /** The button that hides the crews and bots on the left (Ctrl+B). */ + rail: { + label: "Lugares", + home: "Início", + }, sidebar: { hide: "Esconder a lista de bots", show: "Mostrar a lista de bots", diff --git a/app/src/shell/Rail.tsx b/app/src/shell/Rail.tsx new file mode 100644 index 00000000..2653fcd4 --- /dev/null +++ b/app/src/shell/Rail.tsx @@ -0,0 +1,105 @@ +// The rail at the far left (spec 15.1): the places of the app as icons, +// home, search, questions and routines, with the owner's account at the +// foot. It stays while the list of crews and bots is hidden, with the +// counts that call the owner. + +import { AlarmClock, House, type LucideIcon, MessageCircleQuestion, Search } from "lucide-react"; +import { AccountArea } from "../features/account/AccountArea"; +import { useT } from "../i18n"; +import type { Page } from "../store/app"; +import { useApp } from "../store/context"; +import { openQuestions } from "../store/questions"; +import { CountBadge } from "../ui/Badge"; +import { prefs } from "./prefs"; + +function RailButton({ + icon: Icon, + label, + hint = label, + active, + count = 0, + countLabel, + onClick, +}: { + icon: LucideIcon; + label: string; + /** The tooltip, when it says more than the name. */ + hint?: string; + active: boolean; + count?: number; + countLabel?: string; + onClick(): void; +}) { + return ( + + ); +} + +export function Rail() { + const t = useT(); + const words = t.shell.rail; + const page = useApp((state) => state.page); + const home = useApp((state) => state.page === null && state.selectedCrewId === null); + const selectCrew = useApp((state) => state.selectCrew); + const openPage = useApp((state) => state.openPage); + const questions = useApp((state) => openQuestions(state).length); + const go = (to: Exclude) => () => openPage(to); + return ( + + ); +} diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index d5ee006e..aeea1ff6 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -23,6 +23,7 @@ import { Callout } from "../ui/Callout"; import { useBotAlerts } from "./alerts"; import { useAttentionMark } from "./attention"; import { useFolderNotices } from "./folders"; +import { Rail } from "./Rail"; import { SidebarSlot, SidebarToggle } from "./sidebarToggle"; import { useOpenAtSignIn } from "./signIn"; import { useAppSounds } from "./sounds"; @@ -59,6 +60,7 @@ export function Workspace() { // The sidebar stays, with the account area, before the first crew too. main = (
+
@@ -66,6 +68,7 @@ export function Workspace() { } else { main = (
+ 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).