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.