diff --git a/app/src/i18n/en/browser.ts b/app/src/i18n/en/browser.ts
new file mode 100644
index 00000000..833e6f86
--- /dev/null
+++ b/app/src/i18n/en/browser.ts
@@ -0,0 +1,50 @@
+// Each bot's own browser (spec 21.8): the panel beside its chat where the
+// owner watches it live, and the card where it asks to use a new site.
+
+export const browser = {
+ heading: "Browser",
+ panel: (bot: string) => `${bot}'s browser`,
+ show: "Show browser",
+ hide: "Hide browser",
+ browsing: (bot: string) => `${bot} is using the browser`,
+ showInPanel: "Watch in browser",
+ close: "Close",
+ expand: "Make wider",
+ shrink: "Make narrower",
+ openOutside: "Open in my browser",
+ openFailed: "Could not open the page",
+ live: "Live",
+ loading: "Loading…",
+ address: "Address",
+ screen: (bot: string) => `What ${bot} sees`,
+ emptyTitle: (bot: string) => `${bot} hasn't opened the browser yet`,
+ emptyBody:
+ "When it searches or uses a site, you'll see it here live: every page it opens, every click.",
+ starting: "Opening the browser…",
+ closed: "Browser closed",
+ closedBody: "It opens again when the bot needs it. Logins stay.",
+ failedTitle: "The browser couldn't open",
+ failedBody:
+ "Botloft uses Microsoft Edge, which comes with Windows. Check that it's installed, then ask the bot to try again.",
+ details: "Details",
+ tabs: (count: number) => (count === 1 ? "1 tab" : `${count} tabs`),
+ did: {
+ open: (site: string) => `Opened ${site}`,
+ click: (what: string) => `Clicked ${what}`,
+ clickSomewhere: "Clicked",
+ type: (what: string) => `Typed in ${what}`,
+ typeSomewhere: "Typed",
+ select: (what: string) => `Chose in ${what}`,
+ press: (key: string) => `Pressed ${key}`,
+ scroll: "Scrolled",
+ back: "Went back",
+ },
+ site: {
+ wants: (bot: string) => `${bot} wants to use the browser on`,
+ asks: (bot: string, site: string) => `${bot} asks to use ${site}`,
+ why: "Once you allow a site, it won't ask again for it.",
+ allowed: (site: string) => `You allowed ${site}`,
+ denied: (site: string) => `You declined ${site}`,
+ expired: (site: string) => `No answer about ${site}`,
+ },
+};
diff --git a/app/src/i18n/en/index.ts b/app/src/i18n/en/index.ts
index c63bbaa0..71f17f73 100644
--- a/app/src/i18n/en/index.ts
+++ b/app/src/i18n/en/index.ts
@@ -2,6 +2,7 @@
import { account } from "./account";
import { bots } from "./bots";
+import { browser } from "./browser";
import { chat } from "./chat";
import { common } from "./common";
import { crews } from "./crews";
@@ -19,6 +20,7 @@ export const en = {
updates,
account,
bots,
+ browser,
chat,
crews,
files,
diff --git a/app/src/i18n/es/browser.ts b/app/src/i18n/es/browser.ts
new file mode 100644
index 00000000..c3dd0576
--- /dev/null
+++ b/app/src/i18n/es/browser.ts
@@ -0,0 +1,52 @@
+// El navegador de cada bot (spec 21.8): el panel junto al chat donde el
+// dueño lo mira en vivo, y la tarjeta en la que pide usar un sitio nuevo.
+
+import type { Messages } from "../en";
+
+export const browser: Messages["browser"] = {
+ heading: "Navegador",
+ panel: (bot) => `Navegador de ${bot}`,
+ show: "Mostrar navegador",
+ hide: "Ocultar navegador",
+ browsing: (bot) => `${bot} está usando el navegador`,
+ showInPanel: "Ver en el navegador",
+ close: "Cerrar",
+ expand: "Ensanchar",
+ shrink: "Estrechar",
+ openOutside: "Abrir en mi navegador",
+ openFailed: "No se pudo abrir la página",
+ live: "En vivo",
+ loading: "Cargando…",
+ address: "Dirección",
+ screen: (bot) => `Lo que ve ${bot}`,
+ emptyTitle: (bot) => `${bot} todavía no abrió el navegador`,
+ emptyBody:
+ "Cuando busque o use un sitio, lo verás aquí en vivo: cada página que abre, cada clic.",
+ starting: "Abriendo el navegador…",
+ closed: "Navegador cerrado",
+ closedBody: "Se abre de nuevo cuando el bot lo necesite. Las sesiones iniciadas siguen.",
+ failedTitle: "El navegador no se abrió",
+ failedBody:
+ "Botloft usa Microsoft Edge, que viene con Windows. Comprueba que esté instalado y pide al bot que lo intente de nuevo.",
+ details: "Detalles",
+ tabs: (count) => (count === 1 ? "1 pestaña" : `${count} pestañas`),
+ did: {
+ open: (site) => `Abrió ${site}`,
+ click: (what) => `Hizo clic en ${what}`,
+ clickSomewhere: "Hizo clic",
+ type: (what) => `Escribió en ${what}`,
+ typeSomewhere: "Escribió",
+ select: (what) => `Eligió en ${what}`,
+ press: (key) => `Pulsó ${key}`,
+ scroll: "Desplazó la página",
+ back: "Volvió atrás",
+ },
+ site: {
+ wants: (bot) => `${bot} quiere usar el navegador en`,
+ asks: (bot, site) => `${bot} pide usar ${site}`,
+ why: "Cuando permites un sitio, no vuelve a preguntar por él.",
+ allowed: (site) => `Permitiste ${site}`,
+ denied: (site) => `Rechazaste ${site}`,
+ expired: (site) => `Sin respuesta sobre ${site}`,
+ },
+};
diff --git a/app/src/i18n/es/index.ts b/app/src/i18n/es/index.ts
index 7bcab64d..2a7af3ae 100644
--- a/app/src/i18n/es/index.ts
+++ b/app/src/i18n/es/index.ts
@@ -3,6 +3,7 @@
import type { Messages } from "../en";
import { account } from "./account";
import { bots } from "./bots";
+import { browser } from "./browser";
import { chat } from "./chat";
import { common } from "./common";
import { crews } from "./crews";
@@ -20,6 +21,7 @@ export const es: Messages = {
updates,
account,
bots,
+ browser,
chat,
crews,
files,
diff --git a/app/src/i18n/pt-BR/browser.ts b/app/src/i18n/pt-BR/browser.ts
new file mode 100644
index 00000000..23a1ad94
--- /dev/null
+++ b/app/src/i18n/pt-BR/browser.ts
@@ -0,0 +1,52 @@
+// O navegador de cada bot (spec 21.8): o painel ao lado do chat onde o dono
+// assiste ao vivo, e o cartão em que o bot pede para usar um site novo.
+
+import type { Messages } from "../en";
+
+export const browser: Messages["browser"] = {
+ heading: "Navegador",
+ panel: (bot) => `Navegador de ${bot}`,
+ show: "Mostrar navegador",
+ hide: "Ocultar navegador",
+ browsing: (bot) => `${bot} está usando o navegador`,
+ showInPanel: "Ver no navegador",
+ close: "Fechar",
+ expand: "Alargar",
+ shrink: "Estreitar",
+ openOutside: "Abrir no meu navegador",
+ openFailed: "Não foi possível abrir a página",
+ live: "Ao vivo",
+ loading: "Carregando…",
+ address: "Endereço",
+ screen: (bot) => `O que ${bot} vê`,
+ emptyTitle: (bot) => `${bot} ainda não abriu o navegador`,
+ emptyBody:
+ "Quando ele pesquisar ou usar um site, você vê aqui ao vivo: cada página que ele abre, cada clique.",
+ starting: "Abrindo o navegador…",
+ closed: "Navegador fechado",
+ closedBody: "Ele abre de novo quando o bot precisar. Os logins continuam.",
+ failedTitle: "O navegador não abriu",
+ failedBody:
+ "O Botloft usa o Microsoft Edge, que vem com o Windows. Confira se ele está instalado e peça ao bot para tentar de novo.",
+ details: "Detalhes",
+ tabs: (count) => (count === 1 ? "1 aba" : `${count} abas`),
+ did: {
+ open: (site) => `Abriu ${site}`,
+ click: (what) => `Clicou em ${what}`,
+ clickSomewhere: "Clicou",
+ type: (what) => `Escreveu em ${what}`,
+ typeSomewhere: "Escreveu",
+ select: (what) => `Escolheu em ${what}`,
+ press: (key) => `Apertou ${key}`,
+ scroll: "Rolou a página",
+ back: "Voltou",
+ },
+ site: {
+ wants: (bot) => `${bot} quer usar o navegador em`,
+ asks: (bot, site) => `${bot} pede para usar ${site}`,
+ why: "Depois que você permite um site, ele não pergunta de novo.",
+ allowed: (site) => `Você permitiu ${site}`,
+ denied: (site) => `Você recusou ${site}`,
+ expired: (site) => `Sem resposta sobre ${site}`,
+ },
+};
diff --git a/app/src/i18n/pt-BR/index.ts b/app/src/i18n/pt-BR/index.ts
index 0a50f8eb..589a0d6f 100644
--- a/app/src/i18n/pt-BR/index.ts
+++ b/app/src/i18n/pt-BR/index.ts
@@ -3,6 +3,7 @@
import type { Messages } from "../en";
import { account } from "./account";
import { bots } from "./bots";
+import { browser } from "./browser";
import { chat } from "./chat";
import { common } from "./common";
import { crews } from "./crews";
@@ -20,6 +21,7 @@ export const ptBR: Messages = {
updates,
account,
bots,
+ browser,
chat,
crews,
files,
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index cce2421a..f6709d8e 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -4,6 +4,7 @@
import type { BotloftApi } from "./api";
import { botHandlers } from "./fakeBots";
+import { FakeBrowser } from "./fakeBrowser";
import { FakeChat } from "./fakeChat";
import { FakeConversation } from "./fakeConversation";
import { crewHandlers } from "./fakeCrews";
@@ -60,6 +61,7 @@ export class FakeBotloft implements BotloftApi {
readonly conversation = new FakeConversation(this);
readonly routines = new FakeRoutines(this);
readonly files = new FakeFiles(this);
+ readonly browser = new FakeBrowser(this);
/** Clock for created and updated times. */
now = Date.now();
closed = false;
@@ -211,6 +213,7 @@ export class FakeBotloft implements BotloftApi {
...this.chat.handlers(),
...this.conversation.handlers(),
...this.files.handlers(),
+ ...this.browser.handlers(),
...this.routines.handlers(),
};
}
diff --git a/app/src/lib/fakeBrowser.ts b/app/src/lib/fakeBrowser.ts
new file mode 100644
index 00000000..e88b451a
--- /dev/null
+++ b/app/src/lib/fakeBrowser.ts
@@ -0,0 +1,106 @@
+// The fake daemon's browsers (spec 21.7): each bot's state, the live frames
+// of the one being watched and what the bot does, for the browser panel.
+
+import type { FakeBotloft, Handlers } from "./fake";
+import type {
+ BotId,
+ BrowserAction,
+ BrowserActionKind,
+ BrowserFrame,
+ BrowserState,
+} from "./protocol.gen";
+
+export class FakeBrowser {
+ private readonly states = new Map
();
+ private readonly frames = new Map();
+ /** The bot whose browser the app watches, if any. */
+ watching: BotId | null = null;
+ /** Every watch and unwatch, in order. */
+ readonly watches: (BotId | null)[] = [];
+
+ constructor(private readonly fake: FakeBotloft) {}
+
+ state(botId: BotId): BrowserState {
+ return (
+ this.states.get(botId) ?? {
+ botId,
+ status: "closed",
+ url: null,
+ title: null,
+ loading: false,
+ tabs: 0,
+ error: null,
+ updatedAt: this.fake.now,
+ }
+ );
+ }
+
+ /** Changes the bot's browser and tells the app. */
+ set(botId: BotId, change: Partial): BrowserState {
+ this.fake.bot(botId);
+ const next = { ...this.state(botId), ...change, updatedAt: this.fake.now };
+ this.states.set(botId, next);
+ this.fake.emit({ name: "browser.changed", params: next });
+ return next;
+ }
+
+ /** The bot opened a page. */
+ open(botId: BotId, url: string, title: string): BrowserState {
+ return this.set(botId, { status: "open", url, title, loading: false, tabs: 1, error: null });
+ }
+
+ close(botId: BotId): BrowserState {
+ this.frames.delete(botId);
+ return this.set(botId, {
+ status: "closed",
+ url: null,
+ title: null,
+ loading: false,
+ tabs: 0,
+ });
+ }
+
+ /** A new picture of the page; sent only while the app watches that bot. */
+ frame(botId: BotId, data: string, width = 1280, height = 800): void {
+ const frame: BrowserFrame = { botId, data, width, height };
+ this.frames.set(botId, frame);
+ if (this.watching === botId) {
+ this.fake.emit({ name: "browser.frame", params: frame });
+ }
+ }
+
+ /** Something the bot did, for the cursor. */
+ act(
+ botId: BotId,
+ kind: BrowserActionKind,
+ options: { x?: number; y?: number; label?: string } = {},
+ ): BrowserAction {
+ const action: BrowserAction = {
+ botId,
+ kind,
+ x: options.x ?? null,
+ y: options.y ?? null,
+ label: options.label ?? null,
+ at: this.fake.now,
+ };
+ this.fake.emit({ name: "browser.action", params: action });
+ return action;
+ }
+
+ handlers(): Pick {
+ return {
+ "browser.list": () => [...this.states.values()].filter((state) => state.status !== "closed"),
+ "browser.watch": ({ botId }) => {
+ this.fake.bot(botId, false);
+ this.watching = botId;
+ this.watches.push(botId);
+ return { state: this.state(botId), frame: this.frames.get(botId) ?? null };
+ },
+ "browser.unwatch": () => {
+ this.watching = null;
+ this.watches.push(null);
+ return null;
+ },
+ };
+ }
+}
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 0ac2262b..085e8ee3 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -779,6 +779,73 @@ export type RoutineIdParams = { routineId: RoutineId, };
export type RoutinesRunsParams = { routineId: RoutineId, before?: RoutineRunId, limit?: number, };
+/**
+ * Where a bot's browser is.
+ */
+export type BrowserStatus = "closed" | "starting" | "open" | "failed";
+
+export type BrowserState = { botId: BotId, status: BrowserStatus,
+/**
+ * The active tab's address.
+ */
+url: string | null, title: string | null,
+/**
+ * The active tab is loading a page.
+ */
+loading: boolean,
+/**
+ * Open tabs.
+ */
+tabs: number,
+/**
+ * Why it could not start, when `failed`.
+ */
+error: string | null,
+/**
+ * Unix time in milliseconds.
+ */
+updatedAt: number, };
+
+/**
+ * One picture of the active tab, sent only to the connection watching it.
+ */
+export type BrowserFrame = { botId: BotId,
+/**
+ * A JPEG, base64.
+ */
+data: string,
+/**
+ * Size of the page it shows, in CSS pixels: the space of
+ * `BrowserAction` points.
+ */
+width: number, height: number, };
+
+export type BrowserActionKind = "open" | "click" | "type" | "select" | "press" | "scroll" | "back";
+
+/**
+ * Something the bot did in its browser, for the cursor in the app.
+ */
+export type BrowserAction = { botId: BotId, kind: BrowserActionKind,
+/**
+ * Where on the page, in CSS pixels, when the action has a point.
+ */
+x: number | null, y: number | null,
+/**
+ * The element's name, the key or the site; never the text typed.
+ */
+label: string | null,
+/**
+ * Unix time in milliseconds.
+ */
+at: number, };
+
+/**
+ * What `browser.watch` answers: the state and the latest frame.
+ */
+export type BrowserView = { state: BrowserState, frame: BrowserFrame | null, };
+
+export type BrowserWatchParams = { botId: BotId, };
+
/** Params and result of every request method. */
export interface RpcMethods {
"session.hello": { params: HelloParams; result: HelloResult };
@@ -816,6 +883,9 @@ export interface RpcMethods {
"routines.runNow": { params: RoutineIdParams; result: RoutineRun };
"routines.archive": { params: RoutineIdParams; result: Routine };
"routines.runs": { params: RoutinesRunsParams; result: Array };
+ "browser.list": { params: undefined; result: Array };
+ "browser.watch": { params: BrowserWatchParams; result: BrowserView };
+ "browser.unwatch": { params: undefined; result: null };
}
/** Params of every server notification. */
@@ -830,6 +900,9 @@ export interface RpcNotifications {
"task.changed": Task;
"routine.changed": Routine;
"routine.run": RoutineRun;
+ "browser.changed": BrowserState;
+ "browser.action": BrowserAction;
+ "browser.frame": BrowserFrame;
}
export const RpcErrorCode = {
diff --git a/app/src/motion.css b/app/src/motion.css
index b72ad6b5..065edfae 100644
--- a/app/src/motion.css
+++ b/app/src/motion.css
@@ -235,6 +235,80 @@
}
}
+/* The bot's browser, live (features/browser): its cursor glides to each
+ point it acts on, a ring spreads where it clicks once the cursor gets
+ there, and a dot pulses while the page is live. */
+.browser-cursor {
+ position: absolute;
+ display: flex;
+ align-items: flex-start;
+ pointer-events: none;
+ translate: -4px -3px;
+ transition:
+ left 520ms var(--ease-out),
+ top 520ms var(--ease-out);
+}
+
+.browser-cursor > span {
+ margin-top: 14px;
+}
+
+.browser-ripple {
+ position: absolute;
+ width: 46px;
+ height: 46px;
+ margin: -23px 0 0 -23px;
+ border: 3px solid;
+ border-radius: 9999px;
+ pointer-events: none;
+ animation: ripple 700ms var(--ease-out) 420ms both;
+}
+
+.browser-typing {
+ letter-spacing: 1px;
+ animation: caret 0.9s steps(2, jump-none) infinite;
+}
+
+.live-dot {
+ position: relative;
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 9999px;
+ background: var(--danger);
+}
+
+.live-dot::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ background: inherit;
+ animation: live 1.6s ease-out infinite;
+}
+
+@keyframes ripple {
+ from {
+ opacity: 0.95;
+ transform: scale(0.2);
+ }
+ to {
+ opacity: 0;
+ transform: scale(1.5);
+ }
+}
+
+@keyframes live {
+ from {
+ opacity: 0.7;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(2.6);
+ }
+}
+
/* Switching bots or crews: the main pane fades out and the new one rises
in; the rest of the window stays put (ui/motion.ts). */
::view-transition-old(root),
diff --git a/app/src/store/app.ts b/app/src/store/app.ts
index 28a8defd..a3f38eea 100644
--- a/app/src/store/app.ts
+++ b/app/src/store/app.ts
@@ -7,6 +7,7 @@ import type { BotloftApi, ConnectionState, ServerEvent } from "../lib/api";
import type {
Bot,
BotId,
+ BrowserState,
Crew,
CrewId,
Delivery,
@@ -35,6 +36,8 @@ export interface AppState {
tasks: Record;
/** Every routine that is not archived (spec 20), then every change. */
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. */
seenAt: Record;
selectedCrewId: CrewId | null;
@@ -46,6 +49,7 @@ export interface AppState {
putBot(bot: Bot): void;
putDelivery(delivery: Delivery): void;
putRoutine(routine: Routine): void;
+ putBrowser(browser: BrowserState): void;
}
export type AppStore = StoreApi;
@@ -87,6 +91,7 @@ export function createAppStore(api: BotloftApi): AppStore {
deliveries: {},
tasks: {},
routines: {},
+ browsers: {},
seenAt: loadSeen(),
selectedCrewId: null,
selectedBotId: null,
@@ -107,6 +112,7 @@ export function createAppStore(api: BotloftApi): AppStore {
putCrew: (crew) => set((state) => withCrew(state, crew)),
putBot: (bot) => set((state) => withBot(state, bot)),
putRoutine: (routine) => set((state) => withRoutine(state, routine)),
+ putBrowser: (browser) => set((state) => withBrowser(state, browser)),
putDelivery: (delivery) =>
set((state) => ({ deliveries: { ...state.deliveries, [delivery.messageId]: delivery } })),
}));
@@ -165,6 +171,8 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial {
+ const { [browser.botId]: _, ...rest } = state.browsers;
+ return {
+ browsers: browser.status === "closed" ? rest : { ...rest, [browser.botId]: browser },
+ };
+}
+
/** Adds or replaces a routine; an archived one leaves the store. */
function withRoutine(state: AppState, routine: Routine): Partial {
const { [routine.id]: _, ...rest } = state.routines;
diff --git a/app/src/store/sync.ts b/app/src/store/sync.ts
index dfc315ab..21201dcd 100644
--- a/app/src/store/sync.ts
+++ b/app/src/store/sync.ts
@@ -54,6 +54,13 @@ export function syncStore(store: AppStore, api: BotloftApi): () => void {
alive && store.setState({ routines: Object.fromEntries(list.map((r) => [r.id, r])) }),
)
.catch(() => {});
+ api
+ .call("browser.list")
+ .then(
+ (list) =>
+ alive && store.setState({ browsers: Object.fromEntries(list.map((b) => [b.botId, b])) }),
+ )
+ .catch(() => {});
api
.call("tasks.list", {})
.then(
diff --git a/app/src/ui/SidePanel.tsx b/app/src/ui/SidePanel.tsx
index 157a2be9..6a6383a3 100644
--- a/app/src/ui/SidePanel.tsx
+++ b/app/src/ui/SidePanel.tsx
@@ -34,6 +34,7 @@ export function SidePanel({
label,
name,
defaultWidth,
+ expanded = false,
children,
}: {
label: string;
@@ -41,6 +42,8 @@ export function SidePanel({
name: string;
/** Width in px until the owner drags it. */
defaultWidth: number;
+ /** Takes all the room the main area can spare, until turned off. */
+ expanded?: boolean;
children: ReactNode;
}) {
const t = useT();
@@ -98,11 +101,16 @@ export function SidePanel({