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/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/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. 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.