From 6ec85928b836ca9cd1e33934b25fe653a193316a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:06:29 -0300 Subject: [PATCH] fix(app): the unread mark no longer comes back after a restart The app kept what the owner had seen only in the WebView's localStorage, and its leveldb went back days (the writes of 8 to 10 October were in a log that no longer counted), so replies read long ago were marked again. The daemon now keeps until when each bot was read (chat.read, a new bots.read_through column and Bot.readThrough), and the mark takes the largest of the two. --- app/src/features/bots/BotView.tsx | 7 +-- app/src/features/crews/unread.test.tsx | 35 ++++++++++++ app/src/lib/fakeBots.ts | 1 + app/src/lib/protocol.gen.ts | 5 ++ app/src/store/app.ts | 2 +- app/src/store/seen.ts | 12 +++- crates/botloft-core/src/protocol/model.rs | 3 + .../migrations/0029_read_through.sql | 3 + crates/botloft-store/src/chat.rs | 21 +++++++ crates/botloft-store/src/chat/tests.rs | 13 +++++ crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/mobile/talk.rs | 4 ++ crates/botloftd/src/service/bots.rs | 5 ++ crates/botloftd/tests/chat_read.rs | 55 +++++++++++++++++++ docs/spec.md | 4 +- 15 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 crates/botloft-store/migrations/0029_read_through.sql create mode 100644 crates/botloftd/tests/chat_read.rs diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 3f847f4b..44e52f97 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -4,7 +4,7 @@ import { useT } from "../../i18n"; import type { Bot, BotFile, Crew } from "../../lib/protocol.gen"; import { useWindowVisible } from "../../shell/visibility"; import { routinesOf } from "../../store/app"; -import { useApi, useApp } from "../../store/context"; +import { useApp } from "../../store/context"; import { PanelClosing, PanelRestored } from "../../ui/panelMotion"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { useStable } from "../../ui/useStable"; @@ -85,14 +85,11 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const visible = useWindowVisible(); const replyAt = useApp((state) => state.replyAt[bot.id]); const markSeen = useApp((state) => state.markSeen); - const api = useApi(); useEffect(() => { if (visible && replyAt !== undefined) { markSeen(bot.id); - // A phone connected to this computer stops marking it new (spec 28.12). - api.call("chat.read", { botId: bot.id }).catch(() => {}); } - }, [bot.id, visible, replyAt, markSeen, api]); + }, [bot.id, visible, replyAt, markSeen]); // A browser at rest is open, but the bot is not using it (spec 21.2). const browsing = useApp((state) => { const browser = state.browsers[bot.id]; diff --git a/app/src/features/crews/unread.test.tsx b/app/src/features/crews/unread.test.tsx index 4731f7da..d1d29678 100644 --- a/app/src/features/crews/unread.test.tsx +++ b/app/src/features/crews/unread.test.tsx @@ -65,6 +65,41 @@ describe("unread replies in the conversation list", () => { expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread"); }); + test("what the daemon says was read stays read when the app forgot what it saw", 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"); + // Both replied at 5000; the owner read only Writer's, and the app's own + // storage (cleared in beforeEach) knows nothing of it. + fake.bot(scout.id).lastReplyAt = 5000; + fake.bot(writer.id).lastReplyAt = 5000; + fake.bot(writer.id).readThrough = 5000; + renderApp(fake); + await crewOpened("Ops"); + expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle, unread"); + expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle"); + }); + + test("seeing a chat tells the daemon, which keeps it", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + act(() => { + fake.chat.reply(scout.id, "Found three sources."); + }); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + expect( + fake.calls.filter((call) => call.method === "chat.read").map((call) => call.params), + ).toContainEqual({ botId: scout.id }); + }); + test("replies from before the first start with unread marks count as seen", async () => { localStorage.removeItem("botloft.seenSince"); const fake = new FakeBotloft(); diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index ce9647fc..7c803c8d 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -46,6 +46,7 @@ export function botHandlers(fake: FakeBotloft): Pick { workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`, lastActivity: null, lastReplyAt: null, + readThrough: null, createdAt: fake.now, archivedAt: null, }; diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index cd0bbbbb..2e1350ae 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -286,6 +286,11 @@ lastActivity: Activity | null, * than the owner's last look (spec 15.1). */ lastReplyAt: number | null, +/** + * Until when the owner read the bot's replies, kept by the daemon so + * the mark survives the app losing its storage; `null` if never. + */ +readThrough: number | null, /** * Unix time in milliseconds. */ diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 0d1ac08c..c3be66fa 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -194,7 +194,7 @@ export function createAppStore(api: BotloftApi): AppStore { set({ page, selectedCrewId: null, selectedBotId: null }); } }, - ...seenActions(get, set), + ...seenActions(get, set, api), setPanel: (botId, panel) => set((state) => ({ panels: { ...state.panels, [botId]: panel } })), putCrew: (crew) => set((state) => withCrew(state, crew)), putBot: (bot) => set((state) => withBot(state, bot)), diff --git a/app/src/store/seen.ts b/app/src/store/seen.ts index 7eb7caf3..4c762575 100644 --- a/app/src/store/seen.ts +++ b/app/src/store/seen.ts @@ -4,6 +4,7 @@ // after it marks the app's icon (`unseenFailures`). import type { StoreApi } from "zustand/vanilla"; +import type { BotloftApi } from "../lib/api"; import type { BotId, CrewId, Routine } from "../lib/protocol.gen"; import type { AppState } from "./app"; @@ -79,12 +80,17 @@ export function withReply( export function seenActions( get: StoreApi["getState"], set: StoreApi["setState"], + api: BotloftApi, ): Pick { return { markSeen: (botId) => { const { seenAt, replyAt, markedUnread } = get(); const next = { ...seenAt, [botId]: Math.max(Date.now(), replyAt[botId] ?? 0) }; saveSeen(next); + // The daemon keeps it too: this app's own storage has gone back to an + // older state before, and the mark came back. A phone connected to + // this computer also stops marking the chat new (spec 28.12). + api.call("chat.read", { botId }).catch(() => {}); const { [botId]: wasMarked, ...marked } = markedUnread; if (wasMarked) { saveMarked(marked); @@ -120,7 +126,8 @@ export function isUnread(state: AppState, botId: BotId): boolean { return true; } const at = state.replyAt[botId]; - return at !== undefined && at > Math.max(state.seenAt[botId] ?? 0, state.seenSince); + const read = state.bots[botId]?.readThrough ?? 0; + return at !== undefined && at > Math.max(state.seenAt[botId] ?? 0, state.seenSince, read); } /** How many bots of `crewId` are unread. */ @@ -141,7 +148,8 @@ export function unseenFailures(state: AppState): Routine[] { return ( last?.status === "failed" && routine.botId !== state.selectedBotId && - (last.finishedAt ?? 0) > (state.seenAt[routine.botId] ?? 0) + (last.finishedAt ?? 0) > + Math.max(state.seenAt[routine.botId] ?? 0, state.bots[routine.botId]?.readThrough ?? 0) ); }); } diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 732b2b4f..d08b7526 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -177,6 +177,9 @@ pub struct Bot { /// before its first. The app marks the bot unread when this is newer /// than the owner's last look (spec 15.1). pub last_reply_at: Option, + /// Until when the owner read the bot's replies, kept by the daemon so + /// the mark survives the app losing its storage; `null` if never. + pub read_through: Option, /// Unix time in milliseconds. pub created_at: i64, /// Unix time in milliseconds; `null` while the bot is active. diff --git a/crates/botloft-store/migrations/0029_read_through.sql b/crates/botloft-store/migrations/0029_read_through.sql new file mode 100644 index 00000000..aee8ee5f --- /dev/null +++ b/crates/botloft-store/migrations/0029_read_through.sql @@ -0,0 +1,3 @@ +-- Until when the owner read each bot's replies (Unix ms): the unread mark +-- of the conversation list survives the app losing its own storage. +ALTER TABLE bots ADD COLUMN read_through INTEGER; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 1b4a6db6..b3dd7a06 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -178,6 +178,27 @@ impl Store { Ok(item.and_then(|item| activity(&item.body, item.updated_at))) } + /// The owner read the bot's replies up to `at`: kept, so the unread mark + /// does not come back when the app forgets what it saw. Never goes back. + pub fn set_read_through(&self, bot: &BotId, at: i64) -> Result<()> { + self.conn.prepare_cached( + "UPDATE bots SET read_through = ?2 WHERE id = ?1 AND (read_through IS NULL OR read_through < ?2)", + )? + .execute(params![bot.as_str(), at])?; + Ok(()) + } + + /// Until when the owner read the bot's replies; `None` if never. + pub fn read_through(&self, bot: &BotId) -> Result> { + let mut stmt = self + .conn + .prepare_cached("SELECT read_through FROM bots WHERE id = ?1")?; + Ok(stmt + .query_row([bot.as_str()], |row| row.get(0)) + .optional()? + .flatten()) + } + /// When the bot last finished a reply, for the unread mark in the /// conversation list (spec 15.1); `None` before its first. pub fn last_reply_at(&self, bot: &BotId) -> Result> { diff --git a/crates/botloft-store/src/chat/tests.rs b/crates/botloft-store/src/chat/tests.rs index d169d885..8fac6a27 100644 --- a/crates/botloft-store/src/chat/tests.rs +++ b/crates/botloft-store/src/chat/tests.rs @@ -174,3 +174,16 @@ fn the_last_reply_is_found_behind_newer_items() { .expect("insert"); assert_eq!(fx.store.last_reply_at(bot).expect("reply"), Some(9)); } + +#[test] +fn what_the_owner_read_is_kept_and_never_goes_back() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + assert_eq!(fx.store.read_through(bot).expect("none"), None); + fx.store.set_read_through(bot, 50).expect("set"); + assert_eq!(fx.store.read_through(bot).expect("read"), Some(50)); + fx.store.set_read_through(bot, 20).expect("older"); + assert_eq!(fx.store.read_through(bot).expect("still"), Some(50)); + fx.store.set_read_through(bot, 90).expect("newer"); + assert_eq!(fx.store.read_through(bot).expect("newer"), Some(90)); +} diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 8bab9947..3baf080f 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -38,6 +38,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ 28, include_str!("../migrations/0028_bot_allowed_commands.sql"), ), + (29, include_str!("../migrations/0029_read_through.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/mobile/talk.rs b/crates/botloftd/src/mobile/talk.rs index f7224be3..a388d2ab 100644 --- a/crates/botloftd/src/mobile/talk.rs +++ b/crates/botloftd/src/mobile/talk.rs @@ -66,6 +66,10 @@ impl Mobile { let Some(upto) = line.last_reply_at else { return; }; + // Kept first: the app's own memory of it can be lost. + if let Err(err) = daemon.store().set_read_through(bot, upto) { + tracing::warn!(bot = %bot, "could not keep what the owner read: {err}"); + } { let mut talk = Talk::lock(&self.talk); if talk.read.get(bot).is_some_and(|known| *known >= upto) { diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 8a66c241..0098779c 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -284,6 +284,10 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B tracing::warn!(bot = %record.id, "could not read the last reply: {err}"); None }); + let read_through = store.read_through(&record.id).unwrap_or_else(|err| { + tracing::warn!(bot = %record.id, "could not read what the owner read: {err}"); + None + }); let context = daemon.contexts.get(&record.id); Bot { id: record.id, @@ -309,6 +313,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B workspace: workspace.to_string_lossy().into_owned(), last_activity, last_reply_at, + read_through, created_at: record.created_at, archived_at: record.archived_at, } diff --git a/crates/botloftd/tests/chat_read.rs b/crates/botloftd/tests/chat_read.rs new file mode 100644 index 00000000..fae25042 --- /dev/null +++ b/crates/botloftd/tests/chat_read.rs @@ -0,0 +1,55 @@ +//! What the owner read (spec 15.1): the daemon keeps it, so the unread mark +//! of the conversation list does not come back when the app loses its own +//! storage. + +mod common; + +use botloft_core::ids::{BotId, ChatItemId}; +use botloft_core::protocol::{ChatBody, ChatItem, ReplyItem}; +use common::TestDaemon; +use common::bots::ready_bot; +use serde_json::{Value, json}; + +#[tokio::test] +async fn reading_a_chat_is_kept_in_the_bot() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (bot, process, _) = ready_bot(&t, &mut app, &crew, "Lead").await; + drop(process); + let id: BotId = bot["id"].as_str().expect("id").parse().expect("bot id"); + assert_eq!(bot["readThrough"], Value::Null, "nothing read yet"); + + // A reply at 5_000, then the owner reads the chat. + t.daemon + .store() + .insert_chat_item(&ChatItem { + id: ChatItemId::generate(), + bot_id: id.clone(), + body: ChatBody::Reply(ReplyItem { + text: "Done.".into(), + }), + created_at: 5_000, + updated_at: 5_000, + }) + .expect("reply"); + app.call("chat.read", json!({ "botId": id })) + .await + .expect("read"); + + let bots = app + .call("bots.list", json!({ "crewId": crew["id"] })) + .await + .expect("bots"); + let lead = bots + .as_array() + .expect("list") + .iter() + .find(|entry| entry["id"] == bot["id"]) + .expect("bot"); + assert_eq!(lead["readThrough"], json!(5_000)); + assert_eq!(lead["lastReplyAt"], json!(5_000)); +} diff --git a/docs/spec.md b/docs/spec.md index ef6dce62..d36ad54d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1316,7 +1316,7 @@ Sem jargão (15.2): o dono não vê "cron", "overlap" nem "timezone" no caminho - No chat, a message da rotina aparece só com a etiqueta "Rotina · " e uma seta; o texto que a rotina manda fica dobrado até o dono abrir. - Na página da crew, uma aba com as rotinas de todos os bots dela. - No trilho de ícones (15.1), a página **Rotinas**, com as rotinas de todas as crews; sem nenhuma, ela explica como ter uma. Em cima, "Próximas": as ligadas que rodam por horário, da mais próxima para a mais distante, cinco de início e "Mostrar mais" para o resto. Cada uma mostra o mascote do bot, o nome, quando roda de novo ("Amanhã às 09:00") e a frase de quando; uma rotina por intervalo diz "Vigiando" no lugar da hora, porque está sempre de olho; uma que está rodando diz "Rodando agora". Uma rotina por sinal não entra, porque espera o sinal. Um clique abre a rotina para editar. Embaixo, um grupo por bot que tem rotina, na ordem das crews e dos bots: o mascote, o nome do bot e o da crew, uma seta para dobrar o grupo e **+** para uma rotina nova desse bot, com a lista de 20.9 (interruptor, rodar agora, editar e apagar). -- Uma execução `failed` entra na marca da barra de tarefas (15.2) até o dono abrir o bot. O app guarda quando o dono abriu cada bot (`localStorage`, `botloft.seen`); um bot aberto não marca. +- Uma execução `failed` entra na marca da barra de tarefas (15.2) até o dono abrir o bot. O app guarda quando o dono abriu cada bot (`localStorage`, `botloft.seen`, e o daemon via `chat.read`: o `localStorage` do WebView já voltou a um estado de dias antes e as marcas de não lido reapareceram, por isso vale o maior entre os dois, `Bot.readThrough`); um bot aberto não marca. - O store carrega `routines.list` a cada conexão e segue `routine.changed` e `routine.run`. - Textos nos três idiomas (15.6); a frase de "quando" é montada pelo app a partir do `schedule`. @@ -2546,7 +2546,7 @@ Status: **CH1 a CH3 feitos**, no 0.14.0; o CH4 foi visto no Android e falta o iP **Limites do relay (28.4).** O limite de 60 quadros por minuto passa a valer só para o **celular**; o do **computador** sobe para 600, porque ele fala por todos os bots. As respostas do daemon (`history`, `chats`) não contam como o celular falando. -**O que o dono leu no computador.** O app chama `chat.read {botId}` quando o dono vê a conversa de um bot; o daemon guarda (só na memória) até que resposta ele leu, avisa os celulares ligados com `read {botId, upto}` e põe `readAt` na linha de `chats`. O celular trata como vista a resposta com `lastReplyAt <= readAt`. Ao contrário não existe: ler no celular não mexe no computador. +**O que o dono leu no computador.** O app chama `chat.read {botId}` quando o dono vê a conversa de um bot; o daemon guarda até que resposta ele leu (`bots.read_through`, no banco, e `Bot.readThrough`), avisa os celulares ligados com `read {botId, upto}` e põe `readAt` na linha de `chats`. O celular trata como vista a resposta com `lastReplyAt <= readAt`. Ao contrário não existe: ler no celular não mexe no computador. **Achar o caminho (teste real, 2026-10-08).** A lista agrupa os bots por **equipe** (título com a contagem), com o **mascote** de cada bot como no computador (ele se mexe com o que o bot faz) e, havendo mais de uma equipe, **chips** no topo para ver uma só (com a bolinha da equipe que tem resposta nova). O **gesto de voltar** do celular e o botão do app fecham a conversa (e "Este celular") em vez de sair do app: abrir empurra um passo no histórico do navegador. O cabeçalho (com **Voltar**) e o campo de escrever ficam **fixos** na tela, sem precisar rolar a conversa até o topo.