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 }; +} 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.