From c9327d5852652853532797594072b95e2382ea14 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:50:21 -0300 Subject: [PATCH 1/2] feat(daemon): each bot says when it last finished a reply `Bot.lastReplyAt` is the newest reply item's time, read apart from `lastActivity` because the conversation line may already be something else (a tool, another bot's message). The app uses it to mark a bot unread. A partial index keeps the lookup per bot cheap; the chat store's tests move to their own file to keep chat.rs short. --- app/src/lib/fakeBots.ts | 1 + app/src/lib/fakeChat.ts | 3 + app/src/lib/protocol.gen.ts | 6 + crates/botloft-core/src/protocol/model.rs | 4 + .../migrations/0013_reply_index.sql | 2 + crates/botloft-store/src/chat.rs | 163 +--------------- crates/botloft-store/src/chat/tests.rs | 176 ++++++++++++++++++ crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/service/bots.rs | 5 + crates/botloftd/tests/chat.rs | 2 + crates/botloftd/tests/rpc_crews_bots.rs | 1 + docs/spec.md | 4 +- 12 files changed, 213 insertions(+), 155 deletions(-) create mode 100644 crates/botloft-store/migrations/0013_reply_index.sql create mode 100644 crates/botloft-store/src/chat/tests.rs diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index fc235685..f7e20ff0 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -42,6 +42,7 @@ export function botHandlers(fake: FakeBotloft): Pick { generation: crew.paused ? null : 1, workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`, lastActivity: null, + lastReplyAt: null, createdAt: fake.now, archivedAt: null, }; diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index dff10a26..63421baf 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -279,6 +279,9 @@ export class FakeChat { const bot = this.fake.bots.get(item.botId); if (bot) { bot.lastActivity = activity; + if (item.body.kind === "reply") { + bot.lastReplyAt = item.updatedAt; + } } } this.fake.emit({ name: "chat.item", params: { item, activity } }); diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f47fce18..1be071f3 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -231,6 +231,12 @@ workspace: string, * The last item of the bot's chat, in one line; `null` for a new bot. */ lastActivity: Activity | null, +/** + * When the bot last finished a reply, in Unix milliseconds; `null` + * before its first. The app marks the bot unread when this is newer + * than the owner's last look (spec 15.1). + */ +lastReplyAt: number | null, /** * Unix time in milliseconds. */ diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 873ad028..ab71c457 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -151,6 +151,10 @@ pub struct Bot { pub workspace: String, /// The last item of the bot's chat, in one line; `null` for a new bot. pub last_activity: Option, + /// When the bot last finished a reply, in Unix milliseconds; `null` + /// 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, /// 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/0013_reply_index.sql b/crates/botloft-store/migrations/0013_reply_index.sql new file mode 100644 index 00000000..4eec2d59 --- /dev/null +++ b/crates/botloft-store/migrations/0013_reply_index.sql @@ -0,0 +1,2 @@ +-- The conversation list asks each bot for its newest reply (spec 15.1). +CREATE INDEX chat_items_reply ON chat_items (bot_id, updated_at) WHERE kind = 'reply'; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 67c767d0..1460b589 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -154,159 +154,16 @@ impl Store { let item = stmt.query_row([bot.as_str()], from_row).optional()?; Ok(item.and_then(|item| activity(&item.body, item.updated_at))) } -} - -#[cfg(test)] -mod tests { - use botloft_core::protocol::{ActivityKind, ReplyItem, ToolItem, ToolStatus, TurnItem}; - - use super::*; - use crate::tests::Fixture; - - fn item(bot: &BotId, body: ChatBody, at: i64) -> ChatItem { - ChatItem { - id: ChatItemId::generate(), - bot_id: bot.clone(), - body, - created_at: at, - updated_at: at, - } - } - - fn reply(text: &str) -> ChatBody { - ChatBody::Reply(ReplyItem { text: text.into() }) - } - - #[test] - fn items_page_back_newest_first() { - let fx = Fixture::new(); - let bot = &fx.bots[0].id; - let other = &fx.bots[1].id; - let ids: Vec<_> = (0..3) - .map(|n| { - let entry = item(bot, reply(&format!("r{n}")), 10 + n); - fx.store.insert_chat_item(&entry).expect("insert"); - entry.id - }) - .collect(); - fx.store - .insert_chat_item(&item(other, reply("elsewhere"), 20)) - .expect("insert"); - let newest = fx.store.chat_history(bot, None, 2).expect("history"); - assert_eq!( - newest.iter().map(|i| i.id.clone()).collect::>(), - [ids[2].clone(), ids[1].clone()] - ); - let older = fx - .store - .chat_history(bot, Some(&ids[1]), 10) - .expect("older"); - assert_eq!(older.len(), 1); - assert_eq!(older[0].body, reply("r0")); - } - #[test] - fn a_tool_item_is_found_and_updated() { - let fx = Fixture::new(); - let bot = &fx.bots[0].id; - let running = ToolItem { - tool_use_id: "toolu_7".into(), - name: "Bash".into(), - summary: "npm test".into(), - explanation: None, - input: "{}".into(), - status: ToolStatus::Running, - output: None, - file: None, - }; - let entry = item(bot, ChatBody::Tool(running.clone()), 5); - fx.store.insert_chat_item(&entry).expect("insert"); - let found = fx - .store - .tool_item(bot, "toolu_7") - .expect("find") - .expect("some"); - assert_eq!(found.id, entry.id); - assert!(fx.store.tool_item(bot, "toolu_8").expect("find").is_none()); - - let done = ChatBody::Tool(ToolItem { - status: ToolStatus::Done, - output: Some("ok".into()), - ..running - }); - let updated = fx - .store - .update_chat_item(&entry.id, &done, 9) - .expect("update") - .expect("some"); - assert_eq!( - (updated.body, updated.created_at, updated.updated_at), - (done, 5, 9) - ); - } - - #[test] - fn written_files_come_newest_first_without_repeats_or_failures() { - let fx = Fixture::new(); - let bot = &fx.bots[0].id; - let write = |file: Option<&str>, status: ToolStatus| { - ChatBody::Tool(ToolItem { - tool_use_id: "t".into(), - name: "Write".into(), - summary: String::new(), - explanation: None, - input: "{}".into(), - status, - output: None, - file: file.map(str::to_owned), - }) - }; - for (n, (file, status)) in [ - (Some(r"C:\work\a.md"), ToolStatus::Done), - (Some(r"C:\work\b.pdf"), ToolStatus::Done), - (Some(r"C:\work\bad.md"), ToolStatus::Failed), - (None, ToolStatus::Done), - (Some(r"C:\work\a.md"), ToolStatus::Done), - ] - .into_iter() - .enumerate() - { - fx.store - .insert_chat_item(&item(bot, write(file, status), n as i64)) - .expect("insert"); - } - let files = fx.store.written_files(bot, 10).expect("files"); - assert_eq!(files, [r"C:\work\a.md", r"C:\work\b.pdf"]); - assert_eq!( - fx.store.written_files(bot, 1).expect("one"), - [r"C:\work\a.md"] - ); - } - - #[test] - fn the_last_activity_skips_turn_ends() { - let fx = Fixture::new(); - let bot = &fx.bots[0].id; - assert_eq!(fx.store.last_activity(bot).expect("none"), None); - fx.store - .insert_chat_item(&item(bot, reply("All done.\nDetails below"), 7)) - .expect("insert"); - let turn = ChatBody::Turn(TurnItem { - duration_ms: 1000, - tokens: None, - error: None, - }); - fx.store - .insert_chat_item(&item(bot, turn, 8)) - .expect("insert"); - assert_eq!( - fx.store.last_activity(bot).expect("activity"), - Some(Activity { - kind: ActivityKind::Reply, - text: "All done. Details below".into(), - tool: None, - at: 7 - }) - ); + /// 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> { + let mut stmt = self.conn.prepare_cached( + "SELECT MAX(updated_at) FROM chat_items WHERE bot_id = ?1 AND kind = 'reply'", + )?; + Ok(stmt.query_row([bot.as_str()], |row| row.get(0))?) } } + +#[cfg(test)] +mod tests; diff --git a/crates/botloft-store/src/chat/tests.rs b/crates/botloft-store/src/chat/tests.rs new file mode 100644 index 00000000..d169d885 --- /dev/null +++ b/crates/botloft-store/src/chat/tests.rs @@ -0,0 +1,176 @@ +use botloft_core::protocol::{ActivityKind, ReplyItem, ToolItem, ToolStatus, TurnItem}; + +use super::*; +use crate::tests::Fixture; + +fn item(bot: &BotId, body: ChatBody, at: i64) -> ChatItem { + ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body, + created_at: at, + updated_at: at, + } +} + +fn reply(text: &str) -> ChatBody { + ChatBody::Reply(ReplyItem { text: text.into() }) +} + +#[test] +fn items_page_back_newest_first() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let other = &fx.bots[1].id; + let ids: Vec<_> = (0..3) + .map(|n| { + let entry = item(bot, reply(&format!("r{n}")), 10 + n); + fx.store.insert_chat_item(&entry).expect("insert"); + entry.id + }) + .collect(); + fx.store + .insert_chat_item(&item(other, reply("elsewhere"), 20)) + .expect("insert"); + let newest = fx.store.chat_history(bot, None, 2).expect("history"); + assert_eq!( + newest.iter().map(|i| i.id.clone()).collect::>(), + [ids[2].clone(), ids[1].clone()] + ); + let older = fx + .store + .chat_history(bot, Some(&ids[1]), 10) + .expect("older"); + assert_eq!(older.len(), 1); + assert_eq!(older[0].body, reply("r0")); +} + +#[test] +fn a_tool_item_is_found_and_updated() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let running = ToolItem { + tool_use_id: "toolu_7".into(), + name: "Bash".into(), + summary: "npm test".into(), + explanation: None, + input: "{}".into(), + status: ToolStatus::Running, + output: None, + file: None, + }; + let entry = item(bot, ChatBody::Tool(running.clone()), 5); + fx.store.insert_chat_item(&entry).expect("insert"); + let found = fx + .store + .tool_item(bot, "toolu_7") + .expect("find") + .expect("some"); + assert_eq!(found.id, entry.id); + assert!(fx.store.tool_item(bot, "toolu_8").expect("find").is_none()); + + let done = ChatBody::Tool(ToolItem { + status: ToolStatus::Done, + output: Some("ok".into()), + ..running + }); + let updated = fx + .store + .update_chat_item(&entry.id, &done, 9) + .expect("update") + .expect("some"); + assert_eq!( + (updated.body, updated.created_at, updated.updated_at), + (done, 5, 9) + ); +} + +#[test] +fn written_files_come_newest_first_without_repeats_or_failures() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let write = |file: Option<&str>, status: ToolStatus| { + ChatBody::Tool(ToolItem { + tool_use_id: "t".into(), + name: "Write".into(), + summary: String::new(), + explanation: None, + input: "{}".into(), + status, + output: None, + file: file.map(str::to_owned), + }) + }; + for (n, (file, status)) in [ + (Some(r"C:\work\a.md"), ToolStatus::Done), + (Some(r"C:\work\b.pdf"), ToolStatus::Done), + (Some(r"C:\work\bad.md"), ToolStatus::Failed), + (None, ToolStatus::Done), + (Some(r"C:\work\a.md"), ToolStatus::Done), + ] + .into_iter() + .enumerate() + { + fx.store + .insert_chat_item(&item(bot, write(file, status), n as i64)) + .expect("insert"); + } + let files = fx.store.written_files(bot, 10).expect("files"); + assert_eq!(files, [r"C:\work\a.md", r"C:\work\b.pdf"]); + assert_eq!( + fx.store.written_files(bot, 1).expect("one"), + [r"C:\work\a.md"] + ); +} + +#[test] +fn the_last_activity_skips_turn_ends() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + assert_eq!(fx.store.last_activity(bot).expect("none"), None); + fx.store + .insert_chat_item(&item(bot, reply("All done.\nDetails below"), 7)) + .expect("insert"); + let turn = ChatBody::Turn(TurnItem { + duration_ms: 1000, + tokens: None, + error: None, + }); + fx.store + .insert_chat_item(&item(bot, turn, 8)) + .expect("insert"); + assert_eq!( + fx.store.last_activity(bot).expect("activity"), + Some(Activity { + kind: ActivityKind::Reply, + text: "All done. Details below".into(), + tool: None, + at: 7 + }) + ); +} + +#[test] +fn the_last_reply_is_found_behind_newer_items() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let other = &fx.bots[1].id; + assert_eq!(fx.store.last_reply_at(bot).expect("none"), None); + for (body, at) in [(reply("First."), 5), (reply("Second."), 9)] { + fx.store + .insert_chat_item(&item(bot, body, at)) + .expect("insert"); + } + let turn = ChatBody::Turn(TurnItem { + duration_ms: 1000, + tokens: None, + error: None, + }); + fx.store + .insert_chat_item(&item(bot, turn, 12)) + .expect("insert"); + fx.store + .insert_chat_item(&item(other, reply("Elsewhere."), 20)) + .expect("insert"); + assert_eq!(fx.store.last_reply_at(bot).expect("reply"), Some(9)); +} diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 3b74786b..76be2cca 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -19,6 +19,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ (10, include_str!("../migrations/0010_bot_effort.sql")), (11, include_str!("../migrations/0011_turn_tokens.sql")), (12, include_str!("../migrations/0012_perf_indexes.sql")), + (13, include_str!("../migrations/0013_reply_index.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index f1708382..e4726526 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -211,6 +211,10 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B tracing::warn!(bot = %record.id, "could not read the last activity: {err}"); None }); + let last_reply_at = store.last_reply_at(&record.id).unwrap_or_else(|err| { + tracing::warn!(bot = %record.id, "could not read the last reply: {err}"); + None + }); let context = daemon.contexts.get(&record.id); Bot { id: record.id, @@ -232,6 +236,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B generation, workspace: workspace.to_string_lossy().into_owned(), last_activity, + last_reply_at, created_at: record.created_at, archived_at: record.archived_at, } diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs index 39b26f4b..db607930 100644 --- a/crates/botloftd/tests/chat.rs +++ b/crates/botloftd/tests/chat.rs @@ -127,6 +127,8 @@ async fn what_the_bot_prints_becomes_the_chat() { assert_eq!(older.as_array().map(Vec::len), Some(2)); let listed = c.app.call("bots.list", json!({})).await.expect("bots"); assert_eq!(listed[0]["lastActivity"]["text"], "All 12 tests pass."); + // The newest reply, the one before the turn ended. + assert_eq!(listed[0]["lastReplyAt"], history[1]["updatedAt"]); } /// Starts a permission request for `tool_use_id` the way Claude Code does, diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 53cd5985..8c17bf40 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -45,6 +45,7 @@ async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() { assert_eq!(bot["state"], "offline"); assert_eq!(bot["color"], "#FF7A59"); assert_eq!(bot["lastActivity"], Value::Null); + assert_eq!(bot["lastReplyAt"], Value::Null); assert_eq!(watcher.notification("bot.changed").await, bot); let ws = daemon.paths.bot_workspace("site-da-loja", "revisao"); diff --git a/docs/spec.md b/docs/spec.md index 1cdab4b2..90a675b8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -627,7 +627,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `usage.tokens` | `since` (ms Unix, não negativo) | `BotTokens[]`: `botId`, `name`, `color`, `crew` (nome da equipe), `archived`, `turns` e `tokens` somados (8.7) | | `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | -`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). +`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). `lastReplyAt` é quando o bot terminou a última resposta (`null` antes da primeira), lido à parte porque a linha da conversa pode já ser outra coisa (uma ferramenta, a mensagem de outro bot); o app marca a conversa como não lida com ele (15.1). ### 11.3 Notificações do servidor @@ -759,7 +759,7 @@ 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. 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). 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. 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. - **Á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. - **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. From 3e2bb7dde282d0601c14e51beb4ed17abd83fe02 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:50:21 -0300 Subject: [PATCH 2/2] feat(app): a bot that replied stays marked unread in the sidebar A bot that only went back to Ready gave no sign that it had said something. Now a reply newer than the owner's last look at that chat marks the row as in a messaging app: name in bold, preview and time highlighted and a dot at the end, until the chat is opened. Every reply counts, from `lastReplyAt` and from each new reply item, even with other items after it. With the chat open and the window in sight, replies are seen as they come. With the sidebar hidden, its button shows a quieter dot. A first start with the marks counts older replies as seen. --- app/src/features/bots/BotView.tsx | 10 ++++ app/src/features/crews/Sidebar.tsx | 15 +++-- app/src/features/crews/unread.test.tsx | 76 ++++++++++++++++++++++++++ app/src/i18n/en/crews.ts | 1 + app/src/i18n/es/crews.ts | 1 + app/src/i18n/pt-BR/crews.ts | 1 + app/src/shell/attention.ts | 3 +- app/src/shell/sidebarToggle.tsx | 13 +++-- app/src/store/app.ts | 70 ++++++++++++------------ app/src/store/seen.ts | 71 ++++++++++++++++++++++++ app/src/store/sync.ts | 7 ++- 11 files changed, 221 insertions(+), 47 deletions(-) create mode 100644 app/src/features/crews/unread.test.tsx create mode 100644 app/src/store/seen.ts diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 25f4e169..0a5899fa 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; +import { useWindowVisible } from "../../shell/visibility"; import { type BotPanel, routinesOf } from "../../store/app"; import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; @@ -98,6 +99,15 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { }); const fresh = filesOpen ? 0 : files.files.filter((file) => file.modifiedAt > seenAt).length; const routines = useApp(useShallow((state) => routinesOf(state, bot.id))); + // While the owner can see the chat, each new reply is seen (spec 15.1). + const visible = useWindowVisible(); + const replyAt = useApp((state) => state.replyAt[bot.id]); + const markSeen = useApp((state) => state.markSeen); + useEffect(() => { + if (visible && replyAt !== undefined) { + markSeen(bot.id); + } + }, [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/Sidebar.tsx b/app/src/features/crews/Sidebar.tsx index ab4a4633..7c2c7a8b 100644 --- a/app/src/features/crews/Sidebar.tsx +++ b/app/src/features/crews/Sidebar.tsx @@ -7,6 +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 { Button } from "../../ui/Button"; import { ContextMenu, menuPoint, type Point } from "../../ui/ContextMenu"; import { APP_OPENED } from "../../ui/motion"; @@ -94,12 +95,15 @@ 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)); return (
  • APP_OPENED ? "animate-rise" : undefined}> diff --git a/app/src/features/crews/unread.test.tsx b/app/src/features/crews/unread.test.tsx new file mode 100644 index 00000000..eee0f796 --- /dev/null +++ b/app/src/features/crews/unread.test.tsx @@ -0,0 +1,76 @@ +import { act, cleanup, screen, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +beforeEach(() => { + // Replies from the start of time count; the app would skip older ones. + localStorage.setItem("botloft.seenSince", "1"); + localStorage.removeItem("botloft.seen"); +}); + +afterEach(() => { + cleanup(); + localStorage.removeItem("botloft.seenSince"); + localStorage.removeItem("botloft.seen"); +}); + +const row = (name: string) => + within(sidebar()).getByRole("button", { name: new RegExp(`^${name},`) }); + +describe("unread replies in the conversation list", () => { + test("a bot that replied stays marked until the owner opens its chat", 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"); + openBot("Writer"); + await screen.findByRole("list", { name: "Messages" }); + + act(() => { + fake.chat.reply(scout.id, "Found three sources."); + // 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"); + + // The open chat's replies are seen as they come, even ones stamped + // after the owner opened it. + act(() => { + fake.now = Date.now() + 60_000; + fake.chat.reply(writer.id, "Draft ready."); + }); + expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle"); + + openBot("Scout"); + expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle"); + expect(row("Writer").getAttribute("aria-label")).toBe("Writer, Idle"); + }); + + test("a reply from before the app opened is marked too", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + fake.setBotState(scout.id, "idle"); + 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"); + }); + + test("replies from before the first start with unread marks count as seen", async () => { + localStorage.removeItem("botloft.seenSince"); + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + fake.setBotState(scout.id, "idle"); + fake.chat.reply(scout.id, "An old reply."); + renderApp(fake); + await crewOpened("Ops"); + expect(row("Scout").getAttribute("aria-label")).toBe("Scout, Idle"); + }); +}); diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index b84f232e..f14b99a1 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -13,6 +13,7 @@ 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", }, dialog: { folder: "Work folder", diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 06d239c4..ea91827a 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -10,6 +10,7 @@ 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", }, dialog: { folder: "Carpeta de trabajo", diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 36ea056a..3cdd1874 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -10,6 +10,7 @@ export const crews: Messages["crews"] = { noMessages: "Nenhuma mensagem ainda", fromOwner: (text: string) => `Você: ${text}`, awaitingApproval: (text: string) => `Aguardando aprovação: ${text}`, + unread: "resposta nova", }, dialog: { folder: "Pasta de trabalho", diff --git a/app/src/shell/attention.ts b/app/src/shell/attention.ts index c2b95779..ddb26d76 100644 --- a/app/src/shell/attention.ts +++ b/app/src/shell/attention.ts @@ -3,8 +3,9 @@ import { useEffect } from "react"; import { actionableDead } from "../features/messages/FailedDeliveries"; -import { type AppState, unseenFailures } from "../store/app"; +import type { AppState } from "../store/app"; import { useApp, useHost } from "../store/context"; +import { unseenFailures } from "../store/seen"; /** * Bots waiting for an answer or a sign-in, messages that gave up, and diff --git a/app/src/shell/sidebarToggle.tsx b/app/src/shell/sidebarToggle.tsx index 80537ac8..2ac8f438 100644 --- a/app/src/shell/sidebarToggle.tsx +++ b/app/src/shell/sidebarToggle.tsx @@ -5,16 +5,21 @@ 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 { attentionCount } from "./attention"; import { prefs, usePref } from "./prefs"; export const toggleSidebar = () => prefs.sidebar.set(!prefs.sidebar.get()); -/** The button in the title bar, with a dot while the list is hidden and something waits. */ +/** + * The button in the title bar. While the list is hidden, a dot says + * something waits for the owner, or, quieter, that a bot replied. + */ 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 Icon = open ? PanelLeftClose : PanelLeftOpen; return ( @@ -28,11 +33,11 @@ export function SidebarToggle() { > - {!open && waiting && ( + {!open && (waiting || unread) && ( )} diff --git a/app/src/store/app.ts b/app/src/store/app.ts index 9662699b..08b4af34 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -21,6 +21,7 @@ import type { TaskId, } from "../lib/protocol.gen"; import { viewTransition } from "../ui/motion"; +import { loadSeen, loadSeenSince, saveSeen } from "./seen"; /** A panel beside a bot's chat (spec 15.1). */ export type BotPanel = "details" | "files" | "browser" | "screens"; @@ -51,8 +52,15 @@ export interface AppState { routines: Record; /** The bots' browsers that are not closed (spec 21.7), then every change. */ browsers: Record; - /** When the owner last had each bot open, for failed routine runs. */ + /** + * When the owner last looked at each bot's chat (`seen.ts`), for unread + * replies and failed routine runs. + */ seenAt: Record; + /** Replies before this count as seen (`loadSeenSince`). */ + seenSince: number; + /** When each bot last finished a reply (`Bot.lastReplyAt`), then every new one. */ + replyAt: 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). @@ -63,6 +71,8 @@ export interface AppState { selectCrew(crewId: CrewId | null): void; selectBot(botId: BotId): void; 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; /** Applies a record a call returned, before its notification arrives. */ putCrew(crew: Crew): void; putBot(bot: Bot): void; @@ -123,6 +133,8 @@ export function createAppStore(api: BotloftApi): AppStore { routines: {}, browsers: {}, seenAt: loadSeen(), + seenSince: loadSeenSince(), + replyAt: {}, panels: {}, selectedCrewId: null, selectedBotId: null, @@ -135,11 +147,16 @@ export function createAppStore(api: BotloftApi): AppStore { selectBot: (botId) => { const bot = get().bots[botId]; if (bot && get().selectedBotId !== botId) { - const seenAt = { ...get().seenAt, [botId]: Date.now() }; - saveSeen(seenAt); - viewTransition(() => set({ selectedCrewId: bot.crewId, selectedBotId: botId, seenAt })); + get().markSeen(botId); + 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 }); + }, setPanel: (botId, panel) => set((state) => ({ panels: { ...state.panels, [botId]: panel } })), putCrew: (crew) => set((state) => withCrew(state, crew)), putBot: (bot) => set((state) => withBot(state, bot)), @@ -175,6 +192,7 @@ function withBot(state: AppState, bot: Bot): Partial { return { bots: { ...state.bots, [bot.id]: bot }, activity: { ...state.activity, [bot.id]: bot.lastActivity }, + replyAt: withReply(state.replyAt, bot.id, bot.lastReplyAt), }; } const { [bot.id]: _gone, ...bots } = state.bots; @@ -195,6 +213,7 @@ function withoutBots(state: AppState, botIds: BotId[]): Partial { return { bots: kept(state.bots, (bot) => !gone.has(bot.id)), activity: Object.fromEntries(Object.entries(state.activity).filter(([id]) => !gone.has(id))), + replyAt: Object.fromEntries(Object.entries(state.replyAt).filter(([id]) => !gone.has(id))), routines: kept(state.routines, (routine) => !gone.has(routine.botId)), browsers: kept(state.browsers, (browser) => !gone.has(browser.botId)), deliveries: kept(state.deliveries, (delivery) => !gone.has(delivery.botId)), @@ -251,7 +270,11 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial { return { routines: routine.archivedAt === null ? { ...rest, [routine.id]: routine } : rest }; } -const SEEN_KEY = "botloft.seen"; - -/** When each bot was last open, remembered across restarts of the app. */ -function loadSeen(): Record { - try { - const parsed: unknown = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); - return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; - } catch { - return {}; - } -} - -function saveSeen(seen: Record): void { - try { - localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); - } catch { - // Storage may be off; the mark just shows again after a restart. - } +/** `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. */ @@ -315,15 +327,3 @@ export function routinesOf(state: AppState, botId: BotId): Routine[] { .filter((routine) => routine.botId === botId) .sort(byCreation); } - -/** Routines whose last run failed after the owner last had their bot open. */ -export function unseenFailures(state: AppState): Routine[] { - return Object.values(state.routines).filter((routine) => { - const last = routine.lastRun; - return ( - last?.status === "failed" && - routine.botId !== state.selectedBotId && - (last.finishedAt ?? 0) > (state.seenAt[routine.botId] ?? 0) - ); - }); -} diff --git a/app/src/store/seen.ts b/app/src/store/seen.ts new file mode 100644 index 00000000..29614ff7 --- /dev/null +++ b/app/src/store/seen.ts @@ -0,0 +1,71 @@ +// What the owner has seen of each bot (spec 15.1): when they last looked at +// its chat, remembered across restarts of the app. A reply newer than that +// 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 { AppState } from "./app"; + +const SEEN_KEY = "botloft.seen"; +const SINCE_KEY = "botloft.seenSince"; + +export function loadSeen(): Record { + try { + const parsed: unknown = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}"); + return typeof parsed === "object" && parsed !== null ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +export function saveSeen(seen: Record): void { + try { + localStorage.setItem(SEEN_KEY, JSON.stringify(seen)); + } catch { + // Storage may be off; the marks just show again after a restart. + } +} + +/** + * When the app first kept unread marks: every reply before it counts as + * seen, so the first start with them does not mark every bot unread. + */ +export function loadSeenSince(): number { + try { + const stored = Number(localStorage.getItem(SINCE_KEY)); + if (stored > 0) { + return stored; + } + const now = Date.now(); + localStorage.setItem(SINCE_KEY, String(now)); + return now; + } catch { + return Date.now(); + } +} + +/** The bot replied since the owner last looked at its chat. */ +export function hasUnreadReply(state: AppState, botId: BotId): boolean { + if (botId === state.selectedBotId) { + return false; + } + 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)); +} + +/** Routines whose last run failed after the owner last looked at their bot. */ +export function unseenFailures(state: AppState): Routine[] { + return Object.values(state.routines).filter((routine) => { + const last = routine.lastRun; + return ( + last?.status === "failed" && + routine.botId !== state.selectedBotId && + (last.finishedAt ?? 0) > (state.seenAt[routine.botId] ?? 0) + ); + }); +} diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts index 1c5e77a9..3821e947 100644 --- a/app/src/store/sync.ts +++ b/app/src/store/sync.ts @@ -3,7 +3,7 @@ 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 } from "./app"; +import { type AppStore, applyEvent, crewList, withReply } from "./app"; /** * How often `system.status` is refreshed; it has no notification. Not while @@ -41,10 +41,11 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void { }); const bots = api.call("bots.list", {}).then((list) => { if (alive) { - store.setState({ + store.setState(({ replyAt }) => ({ bots: Object.fromEntries(list.map((bot) => [bot.id, bot])), activity: Object.fromEntries(list.map((bot) => [bot.id, bot.lastActivity])), - }); + replyAt: list.reduce((at, bot) => withReply(at, bot.id, bot.lastReplyAt), replyAt), + })); } }); // Recent deliveries plus every dead one: the dead need the owner.