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: 2 additions & 5 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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];
Expand Down
35 changes: 35 additions & 0 deletions app/src/features/crews/unread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions app/src/lib/fakeBots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`,
lastActivity: null,
lastReplyAt: null,
readThrough: null,
createdAt: fake.now,
archivedAt: null,
};
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
2 changes: 1 addition & 1 deletion app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand Down
12 changes: 10 additions & 2 deletions app/src/store/seen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -79,12 +80,17 @@ export function withReply(
export function seenActions(
get: StoreApi<AppState>["getState"],
set: StoreApi<AppState>["setState"],
api: BotloftApi,
): Pick<AppState, "markSeen" | "markUnread"> {
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);
Expand Down Expand Up @@ -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. */
Expand All @@ -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)
);
});
}
3 changes: 3 additions & 0 deletions crates/botloft-core/src/protocol/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>,
/// 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<i64>,
/// Unix time in milliseconds.
pub created_at: i64,
/// Unix time in milliseconds; `null` while the bot is active.
Expand Down
3 changes: 3 additions & 0 deletions crates/botloft-store/migrations/0029_read_through.sql
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions crates/botloft-store/src/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<i64>> {
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<Option<i64>> {
Expand Down
13 changes: 13 additions & 0 deletions crates/botloft-store/src/chat/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
1 change: 1 addition & 0 deletions crates/botloft-store/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions crates/botloftd/src/mobile/talk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions crates/botloftd/src/service/bots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down
55 changes: 55 additions & 0 deletions crates/botloftd/tests/chat_read.rs
Original file line number Diff line number Diff line change
@@ -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));
}
4 changes: 2 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 · <nome>" 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`.

Expand Down Expand Up @@ -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.

Expand Down
Loading