Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/src/features/account/AlertsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,6 +41,12 @@ export function AlertsSettings() {
/>
)}
<Toggle label={s.sound} hint={s.soundHint} checked={sound} onChange={prefs.sound.set} />
<Toggle
label={s.appSounds}
hint={s.appSoundsHint}
checked={appSounds}
onChange={prefs.appSounds.set}
/>
{!nearClock && <p className="text-muted text-xs leading-relaxed">{s.alertsNeedTray}</p>}
</Section>
);
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -129,6 +130,7 @@ export const ChatComposer = memo(function ChatComposer({
});
setText("");
files.clear();
playSound("sent");
onSent();
} catch (failure) {
files.setError(errorText(failure));
Expand Down
2 changes: 2 additions & 0 deletions app/src/features/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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}`;
Expand Down
29 changes: 29 additions & 0 deletions app/src/features/chat/useSharedSound.ts
Original file line number Diff line number Diff line change
@@ -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<string>());

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]);
}
3 changes: 3 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions app/src/shell/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -34,6 +35,7 @@ export function Workspace() {
useAttentionMark();
useTray();
useBotAlerts();
useAppSounds();
useFolderNotices();
useOpenAtSignIn();

Expand Down
6 changes: 2 additions & 4 deletions app/src/shell/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() };
Expand Down
37 changes: 0 additions & 37 deletions app/src/shell/chime.ts

This file was deleted.

2 changes: 2 additions & 0 deletions app/src/shell/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ export const prefs = {
markReplies: pref<boolean>("botloft.markReplies", true, ON_OFF),
/** A short sound with each of those. */
sound: pref<boolean>("botloft.sound", true, ON_OFF),
/** Soft sounds for a message sent, a reply or a file, and a new bot or crew. */
appSounds: pref<boolean>("botloft.appSounds", true, ON_OFF),
};

export function usePref<T extends Value>(choice: Pref<T>): T {
Expand Down
107 changes: 107 additions & 0 deletions app/src/shell/sounds.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
79 changes: 79 additions & 0 deletions app/src/shell/sounds.ts
Original file line number Diff line number Diff line change
@@ -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<BotId>();
let crews = new Set<CrewId>();
let replies: Record<BotId, number> = {};
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]);
}
Loading
Loading