From 759452959c928c9213d5f650abdbcab586b085a5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:55:10 -0300 Subject: [PATCH] feat(app): soft sounds for a message sent, a reply, a file and a new bot The app only chimed for notifications. Now, while Botloft is in front, it also plays quieter sounds made on the spot, in the same timbre: a tick when the owner's message leaves, a pop for a reply in the open chat, two bright notes for a file shared there, and a rising spark when a bot or a crew is born. "App sounds" in Settings > Notifications turns them off (on by default). Nothing plays within 1.5 s of another sound, except a bot that needs the owner. chime.ts becomes tones.ts (the notes) and sounds.ts (when to play). Spec 15.1 updated. --- app/src/features/account/AlertsSettings.tsx | 7 ++ app/src/features/chat/ChatComposer.tsx | 2 + app/src/features/chat/ChatView.tsx | 2 + app/src/features/chat/useSharedSound.ts | 29 ++++++ app/src/i18n/en/account.ts | 3 + app/src/i18n/es/account.ts | 3 + app/src/i18n/pt-BR/account.ts | 3 + app/src/shell/Workspace.tsx | 2 + app/src/shell/alerts.ts | 6 +- app/src/shell/chime.ts | 37 ------- app/src/shell/prefs.ts | 2 + app/src/shell/sounds.test.tsx | 107 ++++++++++++++++++++ app/src/shell/sounds.ts | 79 +++++++++++++++ app/src/shell/tones.ts | 71 +++++++++++++ docs/spec.md | 4 +- 15 files changed, 314 insertions(+), 43 deletions(-) create mode 100644 app/src/features/chat/useSharedSound.ts delete mode 100644 app/src/shell/chime.ts create mode 100644 app/src/shell/sounds.test.tsx create mode 100644 app/src/shell/sounds.ts create mode 100644 app/src/shell/tones.ts diff --git a/app/src/features/account/AlertsSettings.tsx b/app/src/features/account/AlertsSettings.tsx index a95ddfd4..3fe0699f 100644 --- a/app/src/features/account/AlertsSettings.tsx +++ b/app/src/features/account/AlertsSettings.tsx @@ -12,6 +12,7 @@ export function AlertsSettings() { const needs = usePref(prefs.notifyNeeds); const done = usePref(prefs.notifyDone); const sound = usePref(prefs.sound); + const appSounds = usePref(prefs.appSounds); const replies = usePref(prefs.markReplies); const tray = usePref(prefs.tray); const keep = usePref(prefs.whenClosed) === "keep"; @@ -40,6 +41,12 @@ export function AlertsSettings() { /> )} + {!nearClock &&

{s.alertsNeedTray}

} ); diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index 9a295da8..89538a3c 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -20,6 +20,7 @@ import { errorText } from "../../lib/api"; import { fileSize } from "../../lib/format"; import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; import { prefs, usePref } from "../../shell/prefs"; +import { playSound } from "../../shell/sounds"; import { useApi } from "../../store/context"; import { ContextMeter } from "./ContextMeter"; import { draftOf, keepDraft } from "./drafts"; @@ -129,6 +130,7 @@ export const ChatComposer = memo(function ChatComposer({ }); setText(""); files.clear(); + playSound("sent"); onSent(); } catch (failure) { files.setError(errorText(failure)); diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 2aa46a72..b053d480 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -26,6 +26,7 @@ import { NoticeRow } from "./NoticeRow"; import { chatRows } from "./rows"; import { useChat } from "./useChat"; import { useFiles } from "./useFiles"; +import { useSharedSound } from "./useSharedSound"; /** Distance from the bottom that still counts as "reading the latest". */ const STICKY_PX = 80; @@ -46,6 +47,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto // arrives while they look animates in. // biome-ignore lint/correctness/useExhaustiveDependencies: a new bot is a new chat const openedAt = useMemo(() => Date.now(), [bot.id]); + useSharedSound(chat.items, openedAt); const last = chat.items.at(-1); /** Changes whenever the end of the chat does. */ const end = `${chat.items.length}/${last?.id}/${last?.updatedAt}/${chat.draft.length}`; diff --git a/app/src/features/chat/useSharedSound.ts b/app/src/features/chat/useSharedSound.ts new file mode 100644 index 00000000..c3816d4b --- /dev/null +++ b/app/src/features/chat/useSharedSound.ts @@ -0,0 +1,29 @@ +// A soft sound when a bot shares a file in the chat the owner is looking at +// (spec 15.1). Only for what arrives after the chat opened: older items, +// and those loaded from further up, stay quiet. + +import { useEffect, useRef } from "react"; +import type { ChatItem } from "../../lib/protocol.gen"; +import { playSound } from "../../shell/sounds"; +import { useHost } from "../../store/context"; +import { isSharedFiles } from "./shared"; + +export function useSharedSound(items: ChatItem[], since: number): void { + const host = useHost(); + const played = useRef(new Set()); + + useEffect(() => { + const fresh = items.filter( + (item) => item.updatedAt > since && !played.current.has(item.id) && isSharedFiles(item), + ); + if (fresh.length === 0) { + return; + } + for (const item of fresh) { + played.current.add(item.id); + } + if (host.window.inFront()) { + playSound("file"); + } + }, [items, since, host]); +} diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 27491cf7..ff9d603e 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -72,6 +72,9 @@ export const account = { markRepliesHint: "A dot on the Botloft icon in the taskbar while a conversation is unread.", sound: "Play a sound", soundHint: "A short sound with each notification, also with Botloft in front.", + appSounds: "App sounds", + appSoundsHint: + "Soft sounds while Botloft is in front: a message sent, a reply or a file in the open chat, a new bot or crew.", alertsNeedTray: "With the window closed, notifications only come with Botloft near the clock (in General).", keepAwake: "Keep the computer awake while bots work", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index f349debc..3ceaf455 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -73,6 +73,9 @@ export const account: Messages["account"] = { "Un punto en el icono de Botloft en la barra de tareas mientras haya una conversación sin leer.", sound: "Reproducir un sonido", soundHint: "Un sonido corto con cada aviso, también con Botloft al frente.", + appSounds: "Sonidos de la app", + appSoundsHint: + "Sonidos suaves con Botloft al frente: mensaje enviado, respuesta o archivo en el chat abierto, bot o equipo nuevo.", alertsNeedTray: "Con la ventana cerrada, los avisos solo llegan con Botloft junto al reloj (en General).", keepAwake: "Mantener el equipo despierto mientras los bots trabajan", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 071a628e..8496d764 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -73,6 +73,9 @@ export const account: Messages["account"] = { "Um ponto no ícone do Botloft na barra de tarefas enquanto houver conversa que você não leu.", sound: "Tocar um som", soundHint: "Um som curto com cada aviso, também com o Botloft na frente.", + appSounds: "Sons do app", + appSoundsHint: + "Sons suaves com o Botloft na frente: mensagem enviada, resposta ou arquivo no chat aberto, bot ou equipe nova.", alertsNeedTray: "Com a janela fechada, os avisos só chegam com o Botloft perto do relógio (em Geral).", keepAwake: "Não deixar o computador dormir enquanto os bots trabalham", diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx index bfd9ab31..d7d580ed 100644 --- a/app/src/shell/Workspace.tsx +++ b/app/src/shell/Workspace.tsx @@ -21,6 +21,7 @@ import { useAttentionMark } from "./attention"; import { useFolderNotices } from "./folders"; import { SidebarSlot, SidebarToggle } from "./sidebarToggle"; import { useOpenAtSignIn } from "./signIn"; +import { useAppSounds } from "./sounds"; import { TitleBar } from "./TitleBar"; import { useTray } from "./tray"; @@ -34,6 +35,7 @@ export function Workspace() { useAttentionMark(); useTray(); useBotAlerts(); + useAppSounds(); useFolderNotices(); useOpenAtSignIn(); diff --git a/app/src/shell/alerts.ts b/app/src/shell/alerts.ts index f2809799..c72889ab 100644 --- a/app/src/shell/alerts.ts +++ b/app/src/shell/alerts.ts @@ -9,8 +9,8 @@ import { t } from "../i18n"; import type { Bot, BotId, BotState } from "../lib/protocol.gen"; import { type AppState, activityOf } from "../store/app"; import { useAppStore, useHost } from "../store/context"; -import { chime } from "./chime"; import { prefs } from "./prefs"; +import { playSound } from "./sounds"; /** How long after a notification opening Botloft goes to its bot. */ const FOLLOW_MS = 10 * 60 * 1000; @@ -74,9 +74,7 @@ export function useBotAlerts(): void { } const sound = prefs.sound.get(); if (host.window.inFront()) { - if (sound) { - chime(kind === "done" ? "done" : "needs"); - } + playSound(kind === "done" ? "done" : "needs"); continue; } last = { bot: bot.id, at: Date.now() }; diff --git a/app/src/shell/chime.ts b/app/src/shell/chime.ts deleted file mode 100644 index 2658ea57..00000000 --- a/app/src/shell/chime.ts +++ /dev/null @@ -1,37 +0,0 @@ -// A short, soft sound for a notification while Botloft is in front (spec -// 15.1): two notes made on the spot, so no sound file ships. - -type AudioWindow = Window & { AudioContext?: typeof AudioContext }; - -let context: AudioContext | null = null; - -/** Two notes, rising when a bot needs the owner and falling when one is done. */ -export function chime(kind: "needs" | "done"): void { - const Audio = (window as AudioWindow).AudioContext; - if (!Audio) { - return; - } - try { - context ??= new Audio(); - const start = context.currentTime + 0.01; - const notes = kind === "needs" ? [660, 880] : [784, 587]; - notes.forEach((frequency, index) => { - const at = start + index * 0.12; - const tone = context?.createOscillator(); - const volume = context?.createGain(); - if (!context || !tone || !volume) { - return; - } - tone.type = "sine"; - tone.frequency.value = frequency; - volume.gain.setValueAtTime(0.0001, at); - volume.gain.exponentialRampToValueAtTime(0.12, at + 0.015); - volume.gain.exponentialRampToValueAtTime(0.0001, at + 0.22); - tone.connect(volume).connect(context.destination); - tone.start(at); - tone.stop(at + 0.25); - }); - } catch { - // No sound device: the notification says it anyway. - } -} diff --git a/app/src/shell/prefs.ts b/app/src/shell/prefs.ts index 604a6e40..7c63431c 100644 --- a/app/src/shell/prefs.ts +++ b/app/src/shell/prefs.ts @@ -72,6 +72,8 @@ export const prefs = { markReplies: pref("botloft.markReplies", true, ON_OFF), /** A short sound with each of those. */ sound: pref("botloft.sound", true, ON_OFF), + /** Soft sounds for a message sent, a reply or a file, and a new bot or crew. */ + appSounds: pref("botloft.appSounds", true, ON_OFF), }; export function usePref(choice: Pref): T { diff --git a/app/src/shell/sounds.test.tsx b/app/src/shell/sounds.test.tsx new file mode 100644 index 00000000..aa9a8788 --- /dev/null +++ b/app/src/shell/sounds.test.tsx @@ -0,0 +1,107 @@ +import { act, cleanup, fireEvent, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { FakeBotloft } from "../lib/fake"; +import { FakeHost } from "../lib/fakeHost"; +import type { BotFile } from "../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../test/app"; +import { prefs, resetPrefs } from "./prefs"; +import { mayPlay, playSound, resetSounds } from "./sounds"; +import { tones } from "./tones"; + +vi.mock("./tones", () => ({ tones: vi.fn() })); + +const played = () => vi.mocked(tones).mock.calls.map(([sound]) => sound); + +beforeEach(() => { + vi.mocked(tones).mockClear(); + resetSounds(); +}); + +afterEach(() => { + cleanup(); + resetPrefs(); +}); + +async function scoutOpen() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + const writer = fake.addBot(ops.id, "Writer", "Writes"); + fake.setBotState(scout.id, "idle"); + fake.setBotState(writer.id, "idle"); + const host = new FakeHost(); + renderApp(fake, host); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + // Later than the chat opening, as anything new from the daemon is. + fake.now = Date.now() + 1000; + return { fake, host, ops, scout, writer }; +} + +const report: BotFile = { + path: "C:\\Work\\report.md", + name: "report.md", + mediaType: "text/markdown", + size: 476, + modifiedAt: 0, +} as BotFile; + +describe("app sounds", () => { + test("a message sent ticks, unless the owner turned app sounds off", async () => { + await scoutOpen(); + const field = screen.getByLabelText("Message to Scout"); + fireEvent.change(field, { target: { value: "Hi" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await screen.findByText("Hi"); + expect(played()).toEqual(["sent"]); + prefs.appSounds.set(false); + resetSounds(); + fireEvent.change(field, { target: { value: "Again" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await screen.findByText("Again"); + expect(played()).toEqual(["sent"]); + }); + + test("a reply or a file in the open chat, but not in another one", async () => { + const { fake, scout, writer } = await scoutOpen(); + act(() => { + fake.chat.reply(writer.id, "Elsewhere"); + }); + expect(played()).toEqual([]); + act(() => { + fake.chat.reply(scout.id, "Here"); + }); + expect(played()).toEqual(["reply"]); + resetSounds(); + act(() => { + const call = fake.chat.tool(scout.id, "mcp__botloft__share_file"); + fake.chat.finish(call, JSON.stringify({ shown: [report] })); + }); + expect(played()).toEqual(["reply", "file"]); + }); + + test("a spark for a new bot, and nothing while Botloft is behind", async () => { + const { fake, host, ops } = await scoutOpen(); + act(() => { + fake.addBot(ops.id, "Clerk", "Files things"); + }); + expect(played()).toEqual(["spark"]); + resetSounds(); + host.front = false; + act(() => { + fake.addBot(ops.id, "Planner", "Plans"); + }); + expect(played()).toEqual(["spark"]); + }); + + test("never two within a moment, except a bot that needs the owner", () => { + playSound("reply"); + playSound("file"); + playSound("needs"); + expect(played()).toEqual(["reply", "needs"]); + expect(mayPlay("spark", Date.now() + 1500)).toBe(true); + prefs.sound.set(false); + expect(mayPlay("needs", Date.now())).toBe(false); + }); +}); diff --git a/app/src/shell/sounds.ts b/app/src/shell/sounds.ts new file mode 100644 index 00000000..6d30d3a5 --- /dev/null +++ b/app/src/shell/sounds.ts @@ -0,0 +1,79 @@ +// When the app plays a sound (spec 15.1). The chimes of the notifications +// follow "Play a sound"; the others ("App sounds") mark a message sent, a +// reply or a file in the open chat, and a new bot or crew. Only while +// Botloft is in front, and never two within a moment, so many bots at once +// do not make a choir. + +import { useEffect } from "react"; +import type { BotId, CrewId } from "../lib/protocol.gen"; +import { useAppStore, useHost } from "../store/context"; +import { prefs } from "./prefs"; +import { type Sound, tones } from "./tones"; + +/** Nothing plays this soon after another sound, except a bot that needs the owner. */ +const QUIET_MS = 1500; + +let lastAt = Number.NEGATIVE_INFINITY; + +/** Whether `sound` may play at `now`; plays nothing. */ +export function mayPlay(sound: Sound, now: number): boolean { + const alert = sound === "needs" || sound === "done"; + if (!(alert ? prefs.sound.get() : prefs.appSounds.get())) { + return false; + } + return sound === "needs" || now - lastAt >= QUIET_MS; +} + +export function playSound(sound: Sound): void { + const now = Date.now(); + if (!mayPlay(sound, now)) { + return; + } + lastAt = now; + tones(sound); +} + +/** For tests: the next sound plays whenever. */ +export function resetSounds(): void { + lastAt = Number.NEGATIVE_INFINITY; +} + +/** A spark for each new bot or crew, and a pop for a reply in the open chat. */ +export function useAppSounds(): void { + const host = useHost(); + const store = useAppStore(); + + useEffect(() => { + let bots = new Set(); + let crews = new Set(); + let replies: Record = {}; + let ready = false; + const take = () => { + const state = store.getState(); + bots = new Set(Object.keys(state.bots) as BotId[]); + crews = new Set(Object.keys(state.crews) as CrewId[]); + replies = state.replyAt; + const wasReady = ready; + ready = state.loaded; + return wasReady; + }; + take(); + return store.subscribe((state) => { + const before = { bots, crews, replies }; + // What was there on (re)connecting is not news. + if (!take() || !state.loaded || !host.window.inFront()) { + return; + } + const born = + [...bots].some((id) => !before.bots.has(id)) || + [...crews].some((id) => !before.crews.has(id)); + const open = state.selectedBotId; + const replied = open !== null && (replies[open] ?? 0) > (before.replies[open] ?? 0); + if (born) { + playSound("spark"); + } else if (replied) { + playSound("reply"); + } + }); + }, [host, store]); +} diff --git a/app/src/shell/tones.ts b/app/src/shell/tones.ts new file mode 100644 index 00000000..592a18d7 --- /dev/null +++ b/app/src/shell/tones.ts @@ -0,0 +1,71 @@ +// The app's sounds (spec 15.1): a few soft notes made on the spot, so no +// sound file ships. All of them share one timbre and stay quieter than the +// chime of a bot that needs the owner. + +export type Sound = "needs" | "done" | "sent" | "reply" | "file" | "spark"; + +interface Note { + /** Hz at the start, and where it slides to, if anywhere. */ + from: number; + to?: number; + /** Seconds after the sound starts. */ + at: number; + length: number; + gain: number; +} + +const note = (from: number, at: number, length: number, gain: number, to?: number): Note => ({ + from, + at, + length, + gain, + ...(to ? { to } : {}), +}); + +const SOUNDS: Record = { + // Two notes rising: a bot needs the owner. + needs: [note(660, 0, 0.22, 0.12), note(880, 0.12, 0.22, 0.12)], + // Two notes falling: a bot finished. + done: [note(784, 0, 0.22, 0.12), note(587, 0.12, 0.22, 0.12)], + // A short, high tick: the message left. + sent: [note(1046, 0, 0.06, 0.04)], + // A soft pop that settles: a reply came in the open chat. + reply: [note(880, 0, 0.12, 0.06, 660)], + // Two bright notes close together: a file arrived. + file: [note(784, 0, 0.1, 0.06), note(1175, 0.07, 0.14, 0.06)], + // A quick rising spark, like the flame: a bot or a crew was born. + spark: [note(784, 0, 0.1, 0.06), note(988, 0.06, 0.1, 0.06), note(1319, 0.12, 0.2, 0.07, 1397)], +}; + +type AudioWindow = Window & { AudioContext?: typeof AudioContext }; + +let context: AudioContext | null = null; + +export function tones(sound: Sound): void { + const Audio = (window as AudioWindow).AudioContext; + if (!Audio) { + return; + } + try { + context ??= new Audio(); + const start = context.currentTime + 0.01; + for (const { from, to, at, length, gain } of SOUNDS[sound]) { + const begin = start + at; + const tone = context.createOscillator(); + const volume = context.createGain(); + tone.type = "sine"; + tone.frequency.setValueAtTime(from, begin); + if (to) { + tone.frequency.exponentialRampToValueAtTime(to, begin + length); + } + volume.gain.setValueAtTime(0.0001, begin); + volume.gain.exponentialRampToValueAtTime(gain, begin + 0.012); + volume.gain.exponentialRampToValueAtTime(0.0001, begin + length); + tone.connect(volume).connect(context.destination); + tone.start(begin); + tone.stop(begin + length + 0.03); + } + } catch { + // No sound device: nothing else depends on it. + } +} diff --git a/docs/spec.md b/docs/spec.md index dca69367..b8873b93 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -778,9 +778,9 @@ 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. 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: 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) 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. -- **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.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. +- **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). 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. +- **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. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). O texto que o dono começou a escrever fica guardado por bot: ir para outro chat e voltar não o perde. Fica só na memória do app enquanto ele está aberto, porque uma mensagem pode ter dado pessoal; os arquivos escolhidos e ainda não enviados não são guardados. - **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo. - **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar.