From 1a5b41419ed57f5d24b06acf73495bcdd5b7bbbe Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:47:54 -0300 Subject: [PATCH 1/2] refactor(app): a bot's menu actions in one place useBotActions holds the items of the bot's menu and the dialogs they open, so another menu can offer exactly the same. --- app/src/features/bots/BotHeader.tsx | 80 +++--------------------- app/src/features/bots/botActions.tsx | 91 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 72 deletions(-) create mode 100644 app/src/features/bots/botActions.tsx diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index d92d78c4..8ab746ca 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -1,34 +1,25 @@ import { - Archive, - Crown, Ellipsis, Files, - FolderOpen, Globe, LayoutTemplate, PanelRight, Pause, - Pencil, Play, - RefreshCcw, RotateCw, ShieldOff, } from "lucide-react"; -import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; -import { useApi, useApp, useHost } from "../../store/context"; +import { useApi, useApp } from "../../store/context"; import { Button } from "../../ui/Button"; -import { Confirm } from "../../ui/Confirm"; import { Menu } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotAvatar, moodOf } from "./BotAvatar"; -import { BotDialog } from "./BotDialog"; import { BotStateBadge } from "./BotStateBadge"; +import { useBotActions } from "./botActions"; import { ChiefBadge, isChief } from "./ChiefBadge"; -type Open = "edit" | "archive" | "fresh" | null; - /** The bot's name, state and actions. */ export function BotHeader({ bot, @@ -68,25 +59,18 @@ export function BotHeader({ const t = useT(); const words = t.bots.header; const api = useApi(); - const host = useHost(); const putBot = useApp((state) => state.putBot); - const putCrew = useApp((state) => state.putCrew); const chief = isChief(bot, crew); - const [open, setOpen] = useState(null); - const close = () => setOpen(null); + const actions = useBotActions(bot, crew); const stopped = bot.paused || crew.paused; const setPaused = (paused: boolean) => attempt(paused ? words.failed.pause : words.failed.resume, async () => putBot(await api.call("bots.setPaused", { botId: bot.id, paused })), ); - const setChief = (on: boolean) => - attempt(words.failed.chief, async () => - putCrew(await api.call("crews.setLead", { crewId: crew.id, botId: on ? bot.id : null })), - ); - const restart = (fresh: boolean) => + const restart = () => attempt(words.failed.restart, async () => - putBot(await api.call("bots.restart", { botId: bot.id, fresh })), + putBot(await api.call("bots.restart", { botId: bot.id, fresh: false })), ); return ( @@ -123,7 +107,7 @@ export function BotHeader({ {words.pause} )} - @@ -202,56 +186,8 @@ export function BotHeader({ aria-pressed={detailsOpen} onClick={onToggleDetails} /> - setOpen("edit") }, - { - label: chief ? words.stopChief : words.makeChief, - icon: Crown, - onSelect: () => setChief(!chief), - }, - { - label: words.restartFresh, - icon: RefreshCcw, - disabled: stopped, - onSelect: () => setOpen("fresh"), - }, - { - label: words.openFolder, - icon: FolderOpen, - onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), - }, - { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, - ]} - /> - - {open === "edit" && } - {open === "fresh" && ( - restart(true)} - > - {words.fresh.body(bot.name)} - - )} - {open === "archive" && ( - - attempt(words.failed.archive, async () => - putBot(await api.call("bots.archive", { botId: bot.id })), - ) - } - > - {words.archiveConfirm.body} - - )} + + {actions.dialogs} ); } diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx new file mode 100644 index 00000000..246b2c9d --- /dev/null +++ b/app/src/features/bots/botActions.tsx @@ -0,0 +1,91 @@ +import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot, Crew } from "../../lib/protocol.gen"; +import { useApi, useApp, useHost } from "../../store/context"; +import { Confirm } from "../../ui/Confirm"; +import type { MenuItem } from "../../ui/Menu"; +import { attempt } from "../../ui/toast"; +import { BotDialog } from "./BotDialog"; +import { isChief } from "./ChiefBadge"; + +type Open = "edit" | "archive" | "fresh" | null; + +/** + * What the owner can do with a bot from a menu, and the dialogs those + * actions open (`null` while none is). The header's menu and the right-click + * menu of the conversation list both come from here, so they stay the same. + */ +export function useBotActions( + bot: Bot, + crew: Crew, +): { items: MenuItem[]; dialogs: ReactNode | null } { + const t = useT(); + const words = t.bots.header; + const api = useApi(); + const host = useHost(); + const putBot = useApp((state) => state.putBot); + const putCrew = useApp((state) => state.putCrew); + const chief = isChief(bot, crew); + const [open, setOpen] = useState(null); + const close = () => setOpen(null); + + const items: MenuItem[] = [ + { label: words.edit, icon: Pencil, onSelect: () => setOpen("edit") }, + { + label: chief ? words.stopChief : words.makeChief, + icon: Crown, + onSelect: () => + attempt(words.failed.chief, async () => + putCrew( + await api.call("crews.setLead", { crewId: crew.id, botId: chief ? null : bot.id }), + ), + ), + }, + { + label: words.restartFresh, + icon: RefreshCcw, + disabled: bot.paused || crew.paused, + onSelect: () => setOpen("fresh"), + }, + { + label: words.openFolder, + icon: FolderOpen, + onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), + }, + { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + ]; + + const dialogs = + open === "edit" ? ( + + ) : open === "fresh" ? ( + + attempt(words.failed.restart, async () => + putBot(await api.call("bots.restart", { botId: bot.id, fresh: true })), + ) + } + > + {words.fresh.body(bot.name)} + + ) : open === "archive" ? ( + + attempt(words.failed.archive, async () => + putBot(await api.call("bots.archive", { botId: bot.id })), + ) + } + > + {words.archiveConfirm.body} + + ) : null; + + return { items, dialogs }; +} From 6f4958c629be78981c8de44752856cef0b8cc958 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:47:54 -0300 Subject: [PATCH 2/2] feat(app): right-click a bot in the sidebar for its menu The menu opens at the pointer (or from the keyboard's menu key), without opening the bot, with the same actions as the header's menu. ContextMenu reuses the rows of Menu, stays inside the window and walks with the arrow keys; Menu gets the arrow keys too. --- app/src/features/crews/Sidebar.tsx | 25 ++++- app/src/features/crews/botMenu.test.tsx | 125 ++++++++++++++++++++++++ app/src/i18n/en/bots.ts | 1 + app/src/i18n/es/bots.ts | 1 + app/src/i18n/pt-BR/bots.ts | 1 + app/src/ui/ContextMenu.tsx | 107 ++++++++++++++++++++ app/src/ui/Menu.tsx | 78 ++++++++++----- docs/spec.md | 2 +- 8 files changed, 315 insertions(+), 25 deletions(-) create mode 100644 app/src/features/crews/botMenu.test.tsx create mode 100644 app/src/ui/ContextMenu.tsx diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index 73883475..3dd85325 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -1,5 +1,6 @@ import { Pause, Plus } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; +import { createPortal } from "react-dom"; import { useShallow } from "zustand/react/shallow"; import { type Messages, useT } from "../../i18n"; import { when } from "../../lib/format"; @@ -7,10 +8,12 @@ import type { Activity, Bot, Crew } from "../../lib/protocol.gen"; import { botsOf, crewList } from "../../store/app"; import { useApp } from "../../store/context"; 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 { BotAvatar, moodOf } from "../bots/BotAvatar"; import { BotStateBadge, stateView } from "../bots/BotStateBadge"; +import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { toolAction, toolTitle } from "../chat/toolNames"; import { CrewDialog } from "./CrewDialog"; @@ -85,6 +88,10 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { const t = useT(); const selected = useApp((state) => state.selectedBotId === bot.id); const selectBot = useApp((state) => state.selectBot); + // A right-click opens the bot's menu, the same as in its header. + const actions = useBotActions(bot, crew); + const [menuAt, setMenuAt] = useState(null); + const closeMenu = useCallback(() => setMenuAt(null), []); const activity = bot.lastActivity; return (
  • APP_OPENED ? "animate-rise" : undefined}> @@ -93,7 +100,11 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { aria-current={selected ? "page" : undefined} aria-label={`${bot.name}, ${stateView(bot, crew.paused).label}`} onClick={() => selectBot(bot.id)} - className={`${row} gap-2.5 px-2.5 py-2 ${selected ? "bg-sunken" : ""}`} + onContextMenu={(event) => { + event.preventDefault(); + setMenuAt(menuPoint(event)); + }} + className={`${row} gap-2.5 px-2.5 py-2 ${selected || menuAt ? "bg-sunken" : ""}`} > @@ -119,6 +130,16 @@ function Conversation({ bot, crew }: { bot: Bot; crew: Crew }) { + {menuAt && ( + + )} + {/* Outside the list: a dialog is not part of the navigation. */} + {actions.dialogs && createPortal(actions.dialogs, document.body)}
  • ); } diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx new file mode 100644 index 00000000..f26c0988 --- /dev/null +++ b/app/src/features/crews/botMenu.test.tsx @@ -0,0 +1,125 @@ +import { 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"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + const { host } = renderApp(fake); + await crewOpened("Ops"); + return { fake, host, crew, scout, writer }; +} + +const row = (name: string) => within(sidebar()).getByRole("button", { name: new RegExp(name) }); + +/** Right-clicks the bot's conversation and returns the menu that opens. */ +function rightClick(name: string) { + fireEvent.contextMenu(row(name), { clientX: 120, clientY: 200 }); + return screen.getByRole("menu", { name: `Actions for ${name}` }); +} + +const labels = (menu: HTMLElement) => + within(menu) + .getAllByRole("menuitem") + .map((item) => item.textContent); + +describe("a bot's right-click menu", () => { + test("offers what the header's menu offers, without opening the bot", async () => { + await twoBots(); + const menu = rightClick("Scout"); + const offered = labels(menu); + expect(offered).toEqual([ + "Edit", + "Make crew chief", + "Restart with a new conversation", + "Open folder", + "Archive bot", + ]); + // The crew's page is still the one open. + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(menu.style.left).toBe("120px"); + expect(menu.style.top).toBe("200px"); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + openBot("Scout"); + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + expect(labels(screen.getByRole("menu"))).toEqual(offered); + }); + + test("acts on the bot that was clicked, not the one that is open", async () => { + const { fake, host, crew, scout, writer } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Open folder" })); + expect(host.opened).toEqual([scout.workspace]); + expect(screen.queryByRole("menu")).toBeNull(); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Make crew chief" })); + await waitFor(() => expect(fake.crews.get(crew.id)?.leadBotId).toBe(scout.id)); + expect(labels(rightClick("Scout"))).toContain("Stop being chief"); + expect(labels(rightClick("Writer"))).toContain("Make crew chief"); + + fireEvent.click(within(rightClick("Scout")).getByRole("menuitem", { name: "Archive bot" })); + const dialog = screen.getByRole("dialog", { name: "Archive Scout?" }); + expect(sidebar().contains(dialog)).toBe(false); + fireEvent.click(within(dialog).getByRole("button", { name: "Archive bot" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Scout/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "bots.archive", params: { botId: scout.id } }); + // Writer stays open through it all. + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + expect(fake.bots.get(writer.id)?.archivedAt).toBeNull(); + }); + + test("edits from the menu and keeps a paused bot from restarting", async () => { + const { fake, scout } = await twoBots(); + await fake.call("bots.setPaused", { botId: scout.id, paused: true }); + const menu = rightClick("Scout"); + const fresh = within(menu).getByRole("menuitem", { name: "Restart with a new conversation" }); + expect((fresh as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Edit" })); + expect(screen.getByRole("dialog", { name: "Edit Scout" })).toBeDefined(); + }); + + test("opens from the keyboard and walks with the arrows", async () => { + await twoBots(); + const scout = row("Scout"); + scout.focus(); + // The menu key sends a context menu event without a pointer. + fireEvent.contextMenu(scout); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + const items = within(menu).getAllByRole("menuitem"); + expect(document.activeElement).toBe(items[0]); + fireEvent.keyDown(menu, { key: "ArrowDown" }); + expect(document.activeElement).toBe(items[1]); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + fireEvent.keyDown(menu, { key: "ArrowUp" }); + expect(document.activeElement).toBe(items.at(-1)); + fireEvent.keyDown(menu, { key: "Home" }); + expect(document.activeElement).toBe(items[0]); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(document.activeElement).toBe(scout); + }); + + test("closes on a click elsewhere, and another bot's menu takes its place", async () => { + await twoBots(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(screen.queryByRole("menu")).toBeNull(); + rightClick("Scout"); + fireEvent.pointerDown(row("Writer")); + expect(rightClick("Writer")).toBeDefined(); + expect(screen.getAllByRole("menu")).toHaveLength(1); + }); +}); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 14d5b269..47896821 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -41,6 +41,7 @@ export const bots = { showDetails: "Show details", hideDetails: "Hide details", more: "More bot actions", + menuOf: (name: string) => `Actions for ${name}`, edit: "Edit", restartFresh: "Restart with a new conversation", openFolder: "Open folder", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index b4dcfab9..ef9fa0e4 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -37,6 +37,7 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalles", hideDetails: "Ocultar detalles", more: "Más acciones del bot", + menuOf: (name: string) => `Acciones de ${name}`, edit: "Editar", restartFresh: "Reiniciar con una conversación nueva", openFolder: "Abrir carpeta", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 9cad8fc3..add69c70 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -37,6 +37,7 @@ export const bots: Messages["bots"] = { showDetails: "Mostrar detalhes", hideDetails: "Ocultar detalhes", more: "Mais ações do bot", + menuOf: (name: string) => `Ações de ${name}`, edit: "Editar", restartFresh: "Reiniciar com uma nova conversa", openFolder: "Abrir pasta", diff --git a/app/src/ui/ContextMenu.tsx b/app/src/ui/ContextMenu.tsx new file mode 100644 index 00000000..c4f5801f --- /dev/null +++ b/app/src/ui/ContextMenu.tsx @@ -0,0 +1,107 @@ +import { type MouseEvent, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { type MenuItem, MenuItems, menuKeys } from "./Menu"; +import { POPOVER } from "./surface"; +import { useDismiss } from "./useDismiss"; + +/** A place in the window, in CSS pixels. */ +export interface Point { + x: number; + y: number; +} + +/** How close to the window's edge a menu may get. */ +const EDGE = 8; + +/** + * Where a `contextmenu` event asks for its menu: at the pointer, or, from + * the keyboard's menu key, which has none, just inside the element. + */ +export function menuPoint(event: MouseEvent): Point { + if (event.clientX !== 0 || event.clientY !== 0) { + return { x: event.clientX, y: event.clientY }; + } + const box = event.currentTarget.getBoundingClientRect(); + return { x: box.left + 24, y: box.top + box.height / 2 }; +} + +/** + * Window pixels, which the pointer is in, per pixel of the page. 1 in the + * app, whose webview does the zoom; the preview zooms the page with CSS. + */ +function pageScale(): number { + const page = document.documentElement; + return (page.offsetWidth && page.getBoundingClientRect().width / page.offsetWidth) || 1; +} + +/** + * The menu of a right-click: the rows of `Menu`, opened at `at` and kept + * inside the window. Closes on a pick, Escape, a click elsewhere, or when + * what is under it moves. + */ +export function ContextMenu({ + label, + items, + at, + onClose, +}: { + label: string; + items: MenuItem[]; + at: Point; + onClose(): void; +}) { + const root = useRef(null); + // Measured and put in place before it is first painted. + const [place, setPlace] = useState(at); + useDismiss(true, root, onClose); + + useLayoutEffect(() => { + const menu = root.current; + if (!menu) { + return; + } + // Its own size, not its box: it is still growing into place. + const scale = pageScale(); + const width = menu.offsetWidth * scale; + const height = menu.offsetHeight * scale; + setPlace({ + x: Math.max(EDGE, Math.min(at.x, window.innerWidth - width - EDGE)) / scale, + y: Math.max(EDGE, Math.min(at.y, window.innerHeight - height - EDGE)) / scale, + }); + // The keyboard follows the menu, and goes back where it was after it. + const before = document.activeElement; + menu.querySelector('[role^="menuitem"]:not(:disabled)')?.focus(); + return () => { + if (menu.contains(document.activeElement) && before instanceof HTMLElement) { + before.focus(); + } + }; + }, [at]); + + useLayoutEffect(() => { + window.addEventListener("resize", onClose); + window.addEventListener("blur", onClose); + window.addEventListener("scroll", onClose, true); + return () => { + window.removeEventListener("resize", onClose); + window.removeEventListener("blur", onClose); + window.removeEventListener("scroll", onClose, true); + }; + }, [onClose]); + + // In the body: no list that scrolls or moves can clip or carry it. + return createPortal( +
    event.preventDefault()} + style={{ left: place.x, top: place.y }} + className={`fixed min-w-60 origin-top-left ${POPOVER}`} + > + +
    , + document.body, + ); +} diff --git a/app/src/ui/Menu.tsx b/app/src/ui/Menu.tsx index 8341378f..5e22fc03 100644 --- a/app/src/ui/Menu.tsx +++ b/app/src/ui/Menu.tsx @@ -1,5 +1,5 @@ import { Check, type LucideIcon } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { type KeyboardEvent, useCallback, useRef, useState } from "react"; import { Button } from "./Button"; import { POPOVER, POPOVER_ITEM } from "./surface"; import { useDismiss } from "./useDismiss"; @@ -14,6 +14,59 @@ export interface MenuItem { onSelect(): void; } +/** The arrow keys, Home and End walk the items of the menu they are pressed in. */ +export function menuKeys(event: KeyboardEvent): void { + const items = [ + ...event.currentTarget.querySelectorAll('[role^="menuitem"]:not(:disabled)'), + ]; + const last = items.length - 1; + const at = items.indexOf(document.activeElement as HTMLElement); + let next: number; + switch (event.key) { + case "ArrowDown": + next = at >= last ? 0 : at + 1; + break; + case "ArrowUp": + next = at <= 0 ? last : at - 1; + break; + case "Home": + next = 0; + break; + case "End": + next = last; + break; + default: + return; + } + event.preventDefault(); + items[next]?.focus(); +} + +/** The rows of a menu. `onPick` runs first: it closes the menu. */ +export function MenuItems({ items, onPick }: { items: MenuItem[]; onPick(): void }) { + return items.map(({ label, icon: Icon, danger, disabled, checked, onSelect }) => ( + + )); +} + /** A button that opens a short list of actions. */ export function Menu({ label, @@ -46,29 +99,10 @@ export function Menu({ {open && (
    - {items.map(({ label: itemLabel, icon: Icon, danger, disabled, checked, onSelect }) => ( - - ))} + setOpen(false)} />
    )} diff --git a/docs/spec.md b/docs/spec.md index 062638e2..ba5c2dd0 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -620,7 +620,7 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg 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. +- **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), 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. - **Á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** 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 **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.