From ffb008898882f54f57531399e7cb4f1ff83b798d Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:31:57 -0300
Subject: [PATCH] feat(app): mark a bot unread, count unread bots per crew,
mark the icon
Follow-ups to the unread replies in the sidebar:
- "Mark as unread" in a bot's menu (sidebar right-click and header),
"Mark as read" on an unread one. The mark stays (`botloft.unread`)
until the chat is opened; marking the open chat puts it away, back on
the crew's page.
- Beside each crew's name, how many of its bots are unread.
- Settings > Notifications, on Windows: "Mark the icon when a bot
replies" (on by default) shows the taskbar mark while a bot is unread,
as it does when something waits for the owner.
The seen marks and their actions move to store/seen.ts, which keeps
store/app.ts shorter.
---
app/src/features/account/AlertsSettings.tsx | 10 +++
app/src/features/bots/botActions.tsx | 18 ++++-
app/src/features/crews/Sidebar.tsx | 11 ++-
app/src/features/crews/botMenu.test.tsx | 1 +
app/src/features/crews/unread.test.tsx | 66 ++++++++++++++-
app/src/i18n/en/account.ts | 2 +
app/src/i18n/en/bots.ts | 2 +
app/src/i18n/en/crews.ts | 4 +-
app/src/i18n/es/account.ts | 3 +
app/src/i18n/es/bots.ts | 2 +
app/src/i18n/es/crews.ts | 3 +-
app/src/i18n/pt-BR/account.ts | 3 +
app/src/i18n/pt-BR/bots.ts | 2 +
app/src/i18n/pt-BR/crews.ts | 3 +-
app/src/shell/attention.ts | 6 +-
app/src/shell/prefs.ts | 2 +
app/src/shell/sidebarToggle.tsx | 4 +-
app/src/store/app.ts | 23 ++----
app/src/store/seen.ts | 90 +++++++++++++++++++--
app/src/store/sync.ts | 3 +-
docs/spec.md | 6 +-
21 files changed, 223 insertions(+), 41 deletions(-)
diff --git a/app/src/features/account/AlertsSettings.tsx b/app/src/features/account/AlertsSettings.tsx
index 6f734e1a..a95ddfd4 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 replies = usePref(prefs.markReplies);
const tray = usePref(prefs.tray);
const keep = usePref(prefs.whenClosed) === "keep";
const nearClock = tray && keep;
@@ -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" && (
+
+ )}
{!nearClock &&
{s.alertsNeedTray}
}
diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx
index 58524ce5..f2a879cc 100644
--- a/app/src/features/bots/botActions.tsx
+++ b/app/src/features/bots/botActions.tsx
@@ -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";
@@ -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(null);
const close = () => setOpen(null);
@@ -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") },
];
diff --git a/app/src/features/crews/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx
index 7c2c7a8b..58a066cf 100644
--- a/app/src/features/crews/Sidebar.tsx
+++ b/app/src/features/crews/Sidebar.tsx
@@ -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";
@@ -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 (
@@ -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" : ""}`}
>
{crew.name}
+ {unread > 0 && (
+
+ {unread}
+ {t.crews.sidebar.unreadCount(unread)}
+
+ )}
{crew.paused && (
@@ -95,7 +102,7 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
const [menuAt, setMenuAt] = useState(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 (
APP_OPENED ? "animate-rise" : undefined}>
{
"Make crew chief",
"Restart with a new conversation",
"Open folder",
+ "Mark as unread",
"Archive bot",
"Delete bot",
]);
diff --git a/app/src/features/crews/unread.test.tsx b/app/src/features/crews/unread.test.tsx
index eee0f796..46d282ad 100644
--- a/app/src/features/crews/unread.test.tsx
+++ b/app/src/features/crews/unread.test.tsx
@@ -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(() => {
@@ -11,6 +12,8 @@ beforeEach(() => {
afterEach(() => {
cleanup();
+ resetPrefs();
+ localStorage.removeItem("botloft.unread");
localStorage.removeItem("botloft.seenSince");
localStorage.removeItem("botloft.seen");
});
@@ -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.
@@ -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 () => {
@@ -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 }));
+}
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index 9dd8da0a..27491cf7 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -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:
diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts
index a8234f5e..963fcb16 100644
--- a/app/src/i18n/en/bots.ts
+++ b/app/src/i18n/en/bots.ts
@@ -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: {
diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts
index f14b99a1..7145ae7e 100644
--- a/app/src/i18n/en/crews.ts
+++ b/app/src/i18n/en/crews.ts
@@ -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",
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 71d42121..f349debc 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -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:
diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts
index 0cb7480a..ab777a13 100644
--- a/app/src/i18n/es/bots.ts
+++ b/app/src/i18n/es/bots.ts
@@ -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: {
diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts
index ea91827a..4493a8af 100644
--- a/app/src/i18n/es/crews.ts
+++ b/app/src/i18n/es/crews.ts
@@ -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",
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index cc7341cb..071a628e 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -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:
diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts
index 8469dc91..3fbf9f91 100644
--- a/app/src/i18n/pt-BR/bots.ts
+++ b/app/src/i18n/pt-BR/bots.ts
@@ -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: {
diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts
index 3cdd1874..1df5abac 100644
--- a/app/src/i18n/pt-BR/crews.ts
+++ b/app/src/i18n/pt-BR/crews.ts
@@ -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",
diff --git a/app/src/shell/attention.ts b/app/src/shell/attention.ts
index ddb26d76..5a02d12a 100644
--- a/app/src/shell/attention.ts
+++ b/app/src/shell/attention.ts
@@ -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
@@ -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]);
diff --git a/app/src/shell/prefs.ts b/app/src/shell/prefs.ts
index cb61a28c..604a6e40 100644
--- a/app/src/shell/prefs.ts
+++ b/app/src/shell/prefs.ts
@@ -68,6 +68,8 @@ export const prefs = {
notifyNeeds: pref("botloft.notifyNeeds", true, ON_OFF),
/** A Windows notification when a bot finishes what it was doing. */
notifyDone: pref("botloft.notifyDone", false, ON_OFF),
+ /** The mark on the app's icon also shows while a bot is unread. */
+ markReplies: pref("botloft.markReplies", true, ON_OFF),
/** A short sound with each of those. */
sound: pref("botloft.sound", true, ON_OFF),
};
diff --git a/app/src/shell/sidebarToggle.tsx b/app/src/shell/sidebarToggle.tsx
index 2ac8f438..c7611e24 100644
--- a/app/src/shell/sidebarToggle.tsx
+++ b/app/src/shell/sidebarToggle.tsx
@@ -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";
@@ -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 (
diff --git a/app/src/store/app.ts b/app/src/store/app.ts
index 08b4af34..5a630018 100644
--- a/app/src/store/app.ts
+++ b/app/src/store/app.ts
@@ -21,7 +21,7 @@ import type {
TaskId,
} from "../lib/protocol.gen";
import { viewTransition } from "../ui/motion";
-import { loadSeen, loadSeenSince, saveSeen } from "./seen";
+import { loadMarked, loadSeen, loadSeenSince, seenActions, withReply } from "./seen";
/** A panel beside a bot's chat (spec 15.1). */
export type BotPanel = "details" | "files" | "browser" | "screens";
@@ -61,6 +61,8 @@ export interface AppState {
seenSince: number;
/** When each bot last finished a reply (`Bot.lastReplyAt`), then every new one. */
replyAt: Record;
+ /** Bots the owner marked unread themselves, until they open them again. */
+ markedUnread: Record;
/**
* The panel the owner left beside each bot's chat (null: closed), so it is
* back when they come back to the bot. Kept until the app closes (spec 15.1).
@@ -73,6 +75,8 @@ export interface AppState {
setPanel(botId: BotId, panel: BotPanel | null): void;
/** The owner is looking at the bot's chat: what it said so far is seen. */
markSeen(botId: BotId): void;
+ /** Marks the bot unread until its chat is opened; leaves it if it is open. */
+ markUnread(botId: BotId): void;
/** Applies a record a call returned, before its notification arrives. */
putCrew(crew: Crew): void;
putBot(bot: Bot): void;
@@ -135,6 +139,7 @@ export function createAppStore(api: BotloftApi): AppStore {
seenAt: loadSeen(),
seenSince: loadSeenSince(),
replyAt: {},
+ markedUnread: loadMarked(),
panels: {},
selectedCrewId: null,
selectedBotId: null,
@@ -151,12 +156,7 @@ export function createAppStore(api: BotloftApi): AppStore {
viewTransition(() => set({ selectedCrewId: bot.crewId, selectedBotId: botId }));
}
},
- markSeen: (botId) => {
- const { seenAt, replyAt } = get();
- const next = { ...seenAt, [botId]: Math.max(Date.now(), replyAt[botId] ?? 0) };
- saveSeen(next);
- set({ seenAt: next });
- },
+ ...seenActions(get, set),
setPanel: (botId, panel) => set((state) => ({ panels: { ...state.panels, [botId]: panel } })),
putCrew: (crew) => set((state) => withCrew(state, crew)),
putBot: (bot) => set((state) => withBot(state, bot)),
@@ -312,15 +312,6 @@ function withRoutine(state: AppState, routine: Routine): Partial {
return { routines: routine.archivedAt === null ? { ...rest, [routine.id]: routine } : rest };
}
-/** `replyAt` with a reply of `botId` at `at`, if it is newer. */
-export function withReply(
- replyAt: Record,
- botId: BotId,
- at: number | null,
-): Record {
- return at !== null && at > (replyAt[botId] ?? 0) ? { ...replyAt, [botId]: at } : replyAt;
-}
-
/** The routines of `botId`, oldest first. */
export function routinesOf(state: AppState, botId: BotId): Routine[] {
return Object.values(state.routines)
diff --git a/app/src/store/seen.ts b/app/src/store/seen.ts
index 29614ff7..7eb7caf3 100644
--- a/app/src/store/seen.ts
+++ b/app/src/store/seen.ts
@@ -3,11 +3,13 @@
// marks the bot unread in the conversation list; a routine run that failed
// after it marks the app's icon (`unseenFailures`).
-import type { BotId, Routine } from "../lib/protocol.gen";
+import type { StoreApi } from "zustand/vanilla";
+import type { BotId, CrewId, Routine } from "../lib/protocol.gen";
import type { AppState } from "./app";
const SEEN_KEY = "botloft.seen";
const SINCE_KEY = "botloft.seenSince";
+const MARKED_KEY = "botloft.unread";
export function loadSeen(): Record {
try {
@@ -44,18 +46,92 @@ export function loadSeenSince(): number {
}
}
-/** The bot replied since the owner last looked at its chat. */
-export function hasUnreadReply(state: AppState, botId: BotId): boolean {
- if (botId === state.selectedBotId) {
+/** The bots the owner marked unread, remembered across restarts. */
+export function loadMarked(): Record {
+ try {
+ const parsed: unknown = JSON.parse(localStorage.getItem(MARKED_KEY) ?? "[]");
+ return Array.isArray(parsed)
+ ? Object.fromEntries(parsed.filter((id) => typeof id === "string").map((id) => [id, true]))
+ : {};
+ } catch {
+ return {};
+ }
+}
+
+function saveMarked(marked: Record): void {
+ try {
+ localStorage.setItem(MARKED_KEY, JSON.stringify(Object.keys(marked)));
+ } catch {
+ // Storage may be off; the marks last until the app closes.
+ }
+}
+
+/** `replyAt` with a reply of `botId` at `at`, if it is newer. */
+export function withReply(
+ replyAt: Record,
+ botId: BotId,
+ at: number | null,
+): Record {
+ return at !== null && at > (replyAt[botId] ?? 0) ? { ...replyAt, [botId]: at } : replyAt;
+}
+
+/** The store's `markSeen` and `markUnread`. */
+export function seenActions(
+ get: StoreApi["getState"],
+ set: StoreApi["setState"],
+): Pick {
+ return {
+ markSeen: (botId) => {
+ const { seenAt, replyAt, markedUnread } = get();
+ const next = { ...seenAt, [botId]: Math.max(Date.now(), replyAt[botId] ?? 0) };
+ saveSeen(next);
+ const { [botId]: wasMarked, ...marked } = markedUnread;
+ if (wasMarked) {
+ saveMarked(marked);
+ }
+ set(wasMarked ? { seenAt: next, markedUnread: marked } : { seenAt: next });
+ },
+ markUnread: (botId) => {
+ const { bots, markedUnread, selectedBotId, selectCrew } = get();
+ const bot = bots[botId];
+ if (!bot) {
+ return;
+ }
+ const marked: Record = { ...markedUnread, [botId]: true };
+ saveMarked(marked);
+ set({ markedUnread: marked });
+ // An open chat is being read: marking it unread puts it away.
+ if (selectedBotId === botId) {
+ selectCrew(bot.crewId);
+ }
+ },
+ };
+}
+
+/**
+ * The bot replied since the owner last looked at its chat, or the owner
+ * marked it unread. The open chat is never unread.
+ */
+export function isUnread(state: AppState, botId: BotId): boolean {
+ if (botId === state.selectedBotId || !state.bots[botId]) {
return false;
}
+ if (state.markedUnread[botId]) {
+ return true;
+ }
const at = state.replyAt[botId];
return at !== undefined && at > Math.max(state.seenAt[botId] ?? 0, state.seenSince);
}
-/** Some bot replied since the owner last looked at its chat. */
-export function anyUnreadReply(state: AppState): boolean {
- return Object.keys(state.replyAt).some((botId) => hasUnreadReply(state, botId));
+/** How many bots of `crewId` are unread. */
+export function unreadIn(state: AppState, crewId: CrewId): number {
+ return Object.values(state.bots).filter((bot) => bot.crewId === crewId && isUnread(state, bot.id))
+ .length;
+}
+
+/** Some bot is unread. */
+export function anyUnread(state: AppState): boolean {
+ return Object.keys(state.bots).some((botId) => isUnread(state, botId));
}
/** Routines whose last run failed after the owner last looked at their bot. */
diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts
index 3821e947..9fff58e6 100644
--- a/app/src/store/sync.ts
+++ b/app/src/store/sync.ts
@@ -3,7 +3,8 @@
import { type BotloftApi, type ConnectionState, errorText } from "../lib/api";
import type { Delivery } from "../lib/protocol.gen";
import { onVisibility, windowHidden } from "../shell/visibility";
-import { type AppStore, applyEvent, crewList, withReply } from "./app";
+import { type AppStore, applyEvent, crewList } from "./app";
+import { withReply } from "./seen";
/**
* How often `system.status` is refreshed; it has no notification. Not while
diff --git a/docs/spec.md b/docs/spec.md
index 98d1305b..e4284e9c 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -759,9 +759,9 @@ O chat e as mensagens de uma equipe guardam todas as páginas já carregadas. As
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. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar, 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.
+- **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.
- **Á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.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **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.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.
- **Á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).
@@ -801,7 +801,7 @@ Layout, como um app de mensagens:
| `open_file` | abre um arquivo do bot com o programa que o Windows usa para ele (`explorer.exe `). Só extensões de documento, imagem, som e vídeo (`pdf`, `png`, `md`, `docx`, `xlsx`, `mp4`...); qualquer outra, como `.exe`, `.bat`, `.ps1` ou `.lnk`, é recusada, porque o Explorer a executaria |
| `reveal_file` | abre o Explorer na pasta do arquivo, com ele marcado (`/select,`). Nada é executado. No macOS, `open -R`; no Linux, que não tem um jeito comum de marcar o arquivo, abre a pasta dele |
| `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página |
-| overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito |
+| overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito. Com "Marcar o ícone quando um bot responder" (15.1), o mesmo ponto aparece enquanto algum bot está não lido |
O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou `%LOCALAPPDATA%\Botloft`, com a porta lida do `config.toml` dessa pasta (45710 se ausente). Um daemon de dev com seu próprio `BOTLOFT_HOME` é encontrado sem configuração extra. A CSP libera `ws://127.0.0.1:*` pelo mesmo motivo, `blob:` para a imagem e o PDF da prévia de arquivos (15.1) e `http://127.0.0.1:*` em `frame-src` para as telas (22.2).