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
10 changes: 10 additions & 0 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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];
Expand Down
15 changes: 11 additions & 4 deletions app/src/features/crews/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -94,12 +95,15 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
const [menuAt, setMenuAt] = useState<Point | null>(null);
const closeMenu = useCallback(() => setMenuAt(null), []);
const activity = useApp((state) => activityOf(state, bot.id));
const unread = useApp((state) => hasUnreadReply(state, bot.id));
return (
<li className={bot.createdAt > APP_OPENED ? "animate-rise" : undefined}>
<button
type="button"
aria-current={selected ? "page" : undefined}
aria-label={`${bot.name}, ${stateView(bot, crew.paused).label}`}
aria-label={[bot.name, stateView(bot, crew.paused).label, unread && t.crews.sidebar.unread]
.filter(Boolean)
.join(", ")}
onClick={() => selectBot(bot.id)}
onContextMenu={(event) => {
event.preventDefault();
Expand All @@ -111,12 +115,14 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
<span className="min-w-0 flex-1">
<span className="flex items-baseline gap-2">
<span className="flex min-w-0 flex-1 items-center gap-1">
<span className="truncate font-medium text-sm">{bot.name}</span>
<span className={`truncate text-sm ${unread ? "font-semibold" : "font-medium"}`}>
{bot.name}
</span>
{isChief(bot, crew) && <ChiefBadge crew={crew} compact />}
</span>
{activity && (
<time
className="shrink-0 text-muted text-xs"
className={`shrink-0 text-xs ${unread ? "font-semibold text-accent" : "text-muted"}`}
dateTime={new Date(activity.at).toISOString()}
>
{when(activity.at)}
Expand All @@ -125,9 +131,10 @@ const Conversation = memo(function Conversation({ bot, crew }: { bot: Bot; crew:
</span>
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs">
<BotStateBadge bot={bot} crewPaused={crew.paused} compact />
<span className="min-w-0 truncate text-muted">
<span className={`min-w-0 flex-1 truncate ${unread ? "text-ink" : "text-muted"}`}>
{activity ? activityText(activity, t) : bot.role || t.crews.sidebar.noMessages}
</span>
{unread && <span aria-hidden className="size-2.5 shrink-0 rounded-full bg-accent" />}
</span>
</span>
</button>
Expand Down
76 changes: 76 additions & 0 deletions app/src/features/crews/unread.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
1 change: 1 addition & 0 deletions app/src/i18n/en/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/es/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions app/src/i18n/pt-BR/crews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 @@ -42,6 +42,7 @@ export function botHandlers(fake: FakeBotloft): Pick<Handlers, BotMethods> {
generation: crew.paused ? null : 1,
workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`,
lastActivity: null,
lastReplyAt: null,
createdAt: fake.now,
archivedAt: null,
};
Expand Down
3 changes: 3 additions & 0 deletions app/src/lib/fakeChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } });
Expand Down
6 changes: 6 additions & 0 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
3 changes: 2 additions & 1 deletion app/src/shell/attention.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions app/src/shell/sidebarToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<span className="relative grid">
Expand All @@ -28,11 +33,11 @@ export function SidebarToggle() {
>
<Icon aria-hidden size={15} />
</button>
{!open && waiting && (
{!open && (waiting || unread) && (
<span
aria-hidden
className="live-dot pointer-events-none absolute top-0.5 right-0.5"
style={{ background: "var(--warn)" }}
className={`pointer-events-none absolute top-0.5 right-0.5 ${waiting ? "live-dot" : "size-[7px] rounded-full"}`}
style={{ background: waiting ? "var(--warn)" : "var(--accent)" }}
/>
)}
</span>
Expand Down
70 changes: 35 additions & 35 deletions app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -51,8 +52,15 @@ export interface AppState {
routines: Record<RoutineId, Routine>;
/** The bots' browsers that are not closed (spec 21.7), then every change. */
browsers: Record<BotId, BrowserState>;
/** 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<BotId, number>;
/** Replies before this count as seen (`loadSeenSince`). */
seenSince: number;
/** When each bot last finished a reply (`Bot.lastReplyAt`), then every new one. */
replyAt: Record<BotId, number>;
/**
* 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).
Expand All @@ -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;
Expand Down Expand Up @@ -123,6 +133,8 @@ export function createAppStore(api: BotloftApi): AppStore {
routines: {},
browsers: {},
seenAt: loadSeen(),
seenSince: loadSeenSince(),
replyAt: {},
panels: {},
selectedCrewId: null,
selectedBotId: null,
Expand All @@ -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)),
Expand Down Expand Up @@ -175,6 +192,7 @@ function withBot(state: AppState, bot: Bot): Partial<AppState> {
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;
Expand All @@ -195,6 +213,7 @@ function withoutBots(state: AppState, botIds: BotId[]): Partial<AppState> {
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)),
Expand Down Expand Up @@ -251,7 +270,11 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial<AppStat
if (!state.bots[item.botId] || !activity) {
return null;
}
return { activity: { ...state.activity, [item.botId]: activity } };
const reply = item.body.kind === "reply" ? item.updatedAt : null;
return {
activity: { ...state.activity, [item.botId]: activity },
replyAt: withReply(state.replyAt, item.botId, reply),
};
}
case "delivery.changed":
return { deliveries: { ...state.deliveries, [event.params.messageId]: event.params } };
Expand Down Expand Up @@ -289,24 +312,13 @@ function withRoutine(state: AppState, routine: Routine): Partial<AppState> {
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<BotId, number> {
try {
const parsed: unknown = JSON.parse(localStorage.getItem(SEEN_KEY) ?? "{}");
return typeof parsed === "object" && parsed !== null ? (parsed as Record<BotId, number>) : {};
} catch {
return {};
}
}

function saveSeen(seen: Record<BotId, number>): 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, number>,
botId: BotId,
at: number | null,
): Record<BotId, number> {
return at !== null && at > (replyAt[botId] ?? 0) ? { ...replyAt, [botId]: at } : replyAt;
}

/** The routines of `botId`, oldest first. */
Expand All @@ -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)
);
});
}
Loading
Loading