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
10 changes: 10 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 replies = usePref(prefs.markReplies);
const tray = usePref(prefs.tray);
const keep = usePref(prefs.whenClosed) === "keep";
const nearClock = tray && keep;
Expand All @@ -29,6 +30,15 @@ export function AlertsSettings() {
checked={done}
onChange={prefs.notifyDone.set}
/>
{/* The mark on the icon is a taskbar overlay, which only Windows has. */}
{SYSTEM === "Windows" && (
<Toggle
label={s.markReplies}
hint={s.markRepliesHint}
checked={replies}
onChange={prefs.markReplies.set}
/>
)}
<Toggle label={s.sound} hint={s.soundHint} checked={sound} onChange={prefs.sound.set} />
{!nearClock && <p className="text-muted text-xs leading-relaxed">{s.alertsNeedTray}</p>}
</Section>
Expand Down
18 changes: 17 additions & 1 deletion app/src/features/bots/botActions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react";
import {
Archive,
Crown,
FolderOpen,
Mail,
MailOpen,
Pencil,
RefreshCcw,
Trash2,
} 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 { isUnread } from "../../store/seen";
import { Confirm } from "../../ui/Confirm";
import type { MenuItem } from "../../ui/Menu";
import { attempt } from "../../ui/toast";
Expand All @@ -27,6 +37,9 @@ export function useBotActions(
const host = useHost();
const putBot = useApp((state) => state.putBot);
const putCrew = useApp((state) => state.putCrew);
const unread = useApp((state) => isUnread(state, bot.id));
const markSeen = useApp((state) => state.markSeen);
const markUnread = useApp((state) => state.markUnread);
const chief = isChief(bot, crew);
const [open, setOpen] = useState<Open>(null);
const close = () => setOpen(null);
Expand Down Expand Up @@ -54,6 +67,9 @@ export function useBotActions(
icon: FolderOpen,
onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)),
},
unread
? { label: words.markRead, icon: MailOpen, onSelect: () => markSeen(bot.id) }
: { label: words.markUnread, icon: Mail, onSelect: () => markUnread(bot.id) },
{ label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") },
{ label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
];
Expand Down
11 changes: 9 additions & 2 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { when } from "../../lib/format";
import type { Activity, Bot, Crew } from "../../lib/protocol.gen";
import { activityOf, botsOf, crewList } from "../../store/app";
import { useApp } from "../../store/context";
import { hasUnreadReply } from "../../store/seen";
import { isUnread, unreadIn } from "../../store/seen";
import { Button } from "../../ui/Button";
import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu";
import { APP_OPENED } from "../../ui/motion";
Expand Down Expand Up @@ -58,6 +58,7 @@ function CrewEntry({ crew }: { crew: Crew }) {
const t = useT();
const bots = useApp(useShallow((state) => botsOf(state, crew.id)));
const selected = useApp((state) => state.selectedCrewId === crew.id && !state.selectedBotId);
const unread = useApp((state) => unreadIn(state, crew.id));
const selectCrew = useApp((state) => state.selectCrew);

return (
Expand All @@ -69,6 +70,12 @@ function CrewEntry({ crew }: { crew: Crew }) {
className={`${row} h-8 gap-2 px-2.5 font-semibold text-sm ${selected ? "bg-sunken" : ""}`}
>
<span className="min-w-0 flex-1 truncate">{crew.name}</span>
{unread > 0 && (
<span className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-semibold text-[11px] text-canvas tabular-nums">
<span aria-hidden>{unread}</span>
<span className="sr-only">{t.crews.sidebar.unreadCount(unread)}</span>
</span>
)}
{crew.paused && (
<span className="flex items-center gap-1 font-medium text-quiet text-xs">
<Pause aria-hidden size={12} />
Expand All @@ -95,7 +102,7 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
const [menuAt, setMenuAt] = useState<Point | null>(null);
const closeMenu = useCallback(() => setMenuAt(null), []);
const activity = useApp((state) => activityOf(state, bot.id));
const unread = useApp((state) => hasUnreadReply(state, bot.id));
const unread = useApp((state) => isUnread(state, bot.id));
return (
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
<button
Expand Down
1 change: 1 addition & 0 deletions app/src/features/crews/botMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ describe("a bot's right-click menu", () => {
"Make crew chief",
"Restart with a new conversation",
"Open folder",
"Mark as unread",
"Archive bot",
"Delete bot",
]);
Expand Down
66 changes: 62 additions & 4 deletions app/src/features/crews/unread.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { act, cleanup, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { prefs, resetPrefs } from "../../shell/prefs";
import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";

beforeEach(() => {
Expand All @@ -11,6 +12,8 @@ beforeEach(() => {

afterEach(() => {
cleanup();
resetPrefs();
localStorage.removeItem("botloft.unread");
localStorage.removeItem("botloft.seenSince");
localStorage.removeItem("botloft.seen");
});
Expand All @@ -36,7 +39,7 @@ describe("unread replies in the conversation list", () => {
// Something after the reply does not hide it.
fake.chat.tool(scout.id, "Read");
});
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, new reply");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread");

// The open chat's replies are seen as they come, even ones stamped
// after the owner opened it.
Expand All @@ -59,7 +62,7 @@ describe("unread replies in the conversation list", () => {
fake.chat.reply(scout.id, "Done while you were away.");
renderApp(fake);
await crewOpened("Ops");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, new reply");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread");
});

test("replies from before the first start with unread marks count as seen", async () => {
Expand All @@ -73,4 +76,59 @@ describe("unread replies in the conversation list", () => {
await crewOpened("Ops");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle");
});

test("the owner marks a bot unread from its menu, and read again", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
const writer = fake.addBot(crew.id, "Writer");
fake.setBotState(scout.id, "idle");
fake.setBotState(writer.id, "idle");
renderApp(fake);
await crewOpened("Ops");

menuItem("Writer", "Mark as unread");
expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle, unread");
expect(within(sidebar()).getByText("1 unread")).toBeTruthy();

menuItem("Writer", "Mark as read");
expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle");
expect(within(sidebar()).queryByText("1 unread")).toBeNull();

// Marking the open chat unread puts it away, back on the crew's page.
openBot("Scout");
await screen.findByRole("list", { name: "Messages" });
menuItem("Scout", "Mark as unread");
await crewOpened("Ops");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread");

// Opening it again reads it.
openBot("Scout");
expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle");
});

test("an unread bot marks the app's icon, unless the owner turned that off", async () => {
const fake = new FakeBotloft();
const crew = fake.addCrew("Ops");
const scout = fake.addBot(crew.id, "Scout");
fake.setBotState(scout.id, "idle");
const { host } = renderApp(fake);
await crewOpened("Ops");
expect(host.attention).toBe(false);

act(() => {
fake.chat.reply(scout.id, "Found three sources.");
});
await vi.waitFor(() => expect(host.attention).toBe(true));

act(() => prefs.markReplies.set(false));
await vi.waitFor(() => expect(host.attention).toBe(false));
});
});

/** Picks `label` in the right-click menu of the bot `name`. */
function menuItem(name: string, label: string) {
fireEvent.contextMenu(row(name), { clientX: 120, clientY: 200 });
const menu = screen.getByRole("menu", { name: `Actions for ${name}` });
fireEvent.click(within(menu).getByRole("menuitem", { name: label }));
}
2 changes: 2 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export const account = {
`A ${system} notification when a bot asks for permission or needs you to sign in, if Botloft is not in front.`,
notifyDone: "When a bot finishes",
notifyDoneHint: "A notification when a bot finishes what it was doing.",
markReplies: "Mark the icon when a bot replies",
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.",
alertsNeedTray:
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/en/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export const bots = {
openFolder: "Open folder",
makeChief: "Make crew chief",
stopChief: "Stop being chief",
markUnread: "Mark as unread",
markRead: "Mark as read",
archive: "Archive bot",
delete: "Delete bot",
failed: {
Expand Down
4 changes: 3 additions & 1 deletion app/src/i18n/en/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export const crews = {
fromOwner: (text: string) => `You: ${text}`,
/** The conversation-list line for an approval the bot is waiting on. */
awaitingApproval: (text: string) => `Waiting for approval: ${text}`,
unread: "new reply",
unread: "unread",
/** Beside a crew's name: how many of its bots are unread. */
unreadCount: (count: number) => `${count} unread`,
},
dialog: {
folder: "Work folder",
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 @@ -68,6 +68,9 @@ export const account: Messages["account"] = {
`Un aviso de ${system} cuando un bot pide permiso o necesita que inicies sesión, si Botloft no está al frente.`,
notifyDone: "Cuando un bot termine",
notifyDoneHint: "Un aviso cuando un bot termina lo que estaba haciendo.",
markReplies: "Marcar el icono cuando un bot responda",
markRepliesHint:
"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.",
alertsNeedTray:
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/es/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const bots: Messages["bots"] = {
openFolder: "Abrir carpeta",
makeChief: "Hacer jefe del equipo",
stopChief: "Dejar de ser jefe",
markUnread: "Marcar como no leído",
markRead: "Marcar como leído",
archive: "Archivar bot",
delete: "Eliminar bot",
failed: {
Expand Down
3 changes: 2 additions & 1 deletion app/src/i18n/es/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const crews: Messages["crews"] = {
noMessages: "Aún no hay mensajes",
fromOwner: (text: string) => `Tú: ${text}`,
awaitingApproval: (text: string) => `Esperando aprobación: ${text}`,
unread: "respuesta nueva",
unread: "sin leer",
unreadCount: (count: number) => `${count} sin leer`,
},
dialog: {
folder: "Carpeta de trabajo",
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 @@ -68,6 +68,9 @@ export const account: Messages["account"] = {
`Um aviso do ${system} quando um bot pede permissão ou precisa que você entre na conta, se o Botloft não estiver na frente.`,
notifyDone: "Quando um bot terminar",
notifyDoneHint: "Um aviso quando um bot termina o que estava fazendo.",
markReplies: "Marcar o ícone quando um bot responder",
markRepliesHint:
"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.",
alertsNeedTray:
Expand Down
2 changes: 2 additions & 0 deletions app/src/i18n/pt-BR/bots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const bots: Messages["bots"] = {
openFolder: "Abrir pasta",
makeChief: "Tornar chefe da equipe",
stopChief: "Deixar de ser chefe",
markUnread: "Marcar como não lida",
markRead: "Marcar como lida",
archive: "Arquivar bot",
delete: "Excluir bot",
failed: {
Expand Down
3 changes: 2 additions & 1 deletion app/src/i18n/pt-BR/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export const crews: Messages["crews"] = {
noMessages: "Nenhuma mensagem ainda",
fromOwner: (text: string) => `Você: ${text}`,
awaitingApproval: (text: string) => `Aguardando aprovação: ${text}`,
unread: "resposta nova",
unread: "não lida",
unreadCount: (count: number) => (count === 1 ? "1 não lida" : `${count} não lidas`),
},
dialog: {
folder: "Pasta de trabalho",
Expand Down
6 changes: 4 additions & 2 deletions app/src/shell/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { useEffect } from "react";
import { actionableDead } from "../features/messages/FailedDeliveries";
import type { AppState } from "../store/app";
import { useApp, useHost } from "../store/context";
import { unseenFailures } from "../store/seen";
import { anyUnread, unseenFailures } from "../store/seen";
import { prefs, usePref } from "./prefs";

/**
* Bots waiting for an answer or a sign-in, messages that gave up, and
Expand Down Expand Up @@ -35,7 +36,8 @@ let last: { inputs: readonly unknown[]; count: number } | null = null;

export function useAttentionMark(): void {
const host = useHost();
const needed = useApp((state) => attentionCount(state) > 0);
const replies = usePref(prefs.markReplies);
const needed = useApp((state) => attentionCount(state) > 0 || (replies && anyUnread(state)));
useEffect(() => {
host.window.setAttention(needed).catch(() => {});
}, [host, needed]);
Expand Down
2 changes: 2 additions & 0 deletions app/src/shell/prefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export const prefs = {
notifyNeeds: pref<boolean>("botloft.notifyNeeds", true, ON_OFF),
/** A Windows notification when a bot finishes what it was doing. */
notifyDone: pref<boolean>("botloft.notifyDone", false, ON_OFF),
/** The mark on the app's icon also shows while a bot is unread. */
markReplies: pref<boolean>("botloft.markReplies", true, ON_OFF),
/** A short sound with each of those. */
sound: pref<boolean>("botloft.sound", true, ON_OFF),
};
Expand Down
4 changes: 2 additions & 2 deletions app/src/shell/sidebarToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { type ReactNode, useEffect } from "react";
import { useT } from "../i18n";
import { useApp } from "../store/context";
import { anyUnreadReply } from "../store/seen";
import { anyUnread } from "../store/seen";
import { attentionCount } from "./attention";
import { prefs, usePref } from "./prefs";

Expand All @@ -19,7 +19,7 @@ export function SidebarToggle() {
const t = useT().shell.sidebar;
const open = usePref(prefs.sidebar);
const waiting = useApp((state) => attentionCount(state) > 0);
const unread = useApp(anyUnreadReply);
const unread = useApp(anyUnread);
const Icon = open ? PanelLeftClose : PanelLeftOpen;
return (
<span className="relative grid">
Expand Down
Loading
Loading