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
28 changes: 13 additions & 15 deletions app/src/dev/seedBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// Dev only: Scout searching in its browser in the fake preview, so the
// browser panel has something live to show. The "page" is drawn on a
// canvas and sent as JPEG frames, like the daemon's screencast.
// canvas, the size the panel asks for, and sent as JPEG frames, like the
// daemon's screencast.

import type { FakeBotloft } from "../lib/fake";
import type { PageSize } from "../lib/fakeBrowser";
import type { BotId } from "../lib/protocol.gen";

const W = 1280;
const H = 800;
const RECIPES = ["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"];

interface Scene {
url: string;
Expand All @@ -15,16 +16,16 @@ interface Scene {
results: boolean;
}

function draw(scene: Scene): string {
function draw(scene: Scene, size: PageSize): string {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
canvas.width = size.width;
canvas.height = size.height;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#ffffff";
g.fillRect(0, 0, W, H);
g.fillRect(0, 0, size.width, size.height);
g.fillStyle = "#1f2937";
g.font = "600 34px system-ui, sans-serif";
g.fillText(scene.results ? "Results" : "Find a recipe", 120, 150);
Expand All @@ -43,15 +44,12 @@ function draw(scene: Scene): string {
g.fillStyle = "#ffffff";
g.fillText("Search", 938, 226);
if (scene.results) {
for (let row = 0; row < 4; row++) {
// A taller page shows more of the list.
for (let row = 0; 300 + row * 110 < size.height - 60; row++) {
const y = 300 + row * 110;
g.fillStyle = "#1d4ed8";
g.font = "600 24px system-ui, sans-serif";
g.fillText(
["Classic sourdough loaf", "Starter in 5 days", "No-knead rye", "Focaccia"][row] ?? "",
120,
y,
);
g.fillText(RECIPES[row % RECIPES.length] ?? "", 120, y);
g.fillStyle = "#e5e7eb";
g.fillRect(120, y + 18, 820, 14);
g.fillRect(120, y + 42, 560, 14);
Expand Down Expand Up @@ -117,14 +115,14 @@ export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
let scene = home;
let step = 0;
fake.browser.open(scout, home.url, home.title);
fake.browser.frame(scout, draw(scene));
fake.browser.paint(scout, (size) => draw(scene, size));
setInterval(() => {
const next = steps[step % steps.length];
step += 1;
if (next) {
scene = next(scene);
fake.browser.open(scout, scene.url, scene.title);
fake.browser.frame(scout, draw(scene));
fake.browser.repaint(scout);
}
}, 2200);
}
15 changes: 7 additions & 8 deletions app/src/dev/seedHands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
// the owner's clicks and keys, so taking the browser can be tried end to end.

import type { FakeBotloft } from "../lib/fake";
import type { PageSize } from "../lib/fakeBrowser";
import type { BrowserInput, CrewId } from "../lib/protocol.gen";

const W = 1280;
const H = 800;
const FIELDS = { user: 300, password: 390 } as const;
type Field = keyof typeof FIELDS;

Expand All @@ -17,16 +16,16 @@ interface Form {
signedIn: boolean;
}

function draw(form: Form): string {
function draw(form: Form, size: PageSize): string {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
canvas.width = size.width;
canvas.height = size.height;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#f7f4ee";
g.fillRect(0, 0, W, H);
g.fillRect(0, 0, size.width, size.height);
g.fillStyle = "#3b2a1a";
g.font = "600 36px Georgia, serif";
if (form.signedIn) {
Expand Down Expand Up @@ -92,7 +91,7 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
const form: Form = { user: "", password: "", field: null, signedIn: false };
const login = "https://flour.example/login";
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
fake.browser.frame(clerk.id, draw(form));
fake.browser.paint(clerk.id, (size) => draw(form, size));
fake.chat.tool(clerk.id, "mcp__botloft__browser_open", { summary: login, status: "done" });
const task = "Sign in to Flour Co. with the bakery's account";
fake.chat.tool(clerk.id, "mcp__botloft__browser_ask_owner", { summary: task, status: "running" });
Expand All @@ -105,6 +104,6 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
form.signedIn = true;
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
}
fake.browser.frame(clerk.id, draw(form));
fake.browser.repaint(clerk.id);
});
}
87 changes: 54 additions & 33 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ import { HandsLayer } from "./HandsLayer";
import { LiveView, useCaption } from "./LiveView";
import { useBrowserView } from "./useBrowserView";
import { useHands } from "./useHands";
import { useFitPage, useRoom } from "./useRoom";

/** Around the page in the panel, in px. */
const PAD = 12;
/** Kept under the page, for what the bot just did and the button to take it. */
const UNDER = 80;

export function BrowserPanel({
bot,
Expand All @@ -39,6 +45,14 @@ export function BrowserPanel({
const hands = useHands(bot, state);
const [focused, setFocused] = useState(false);
const ask = state?.ask ?? null;
// The page takes the shape of the room the panel has for it (spec 21.3).
// Notices that come and go are not counted: they never resize the page.
const [inside, body] = useRoom();
const room = inside && {
width: inside.width - 2 * PAD,
height: inside.height - 2 * PAD - UNDER,
};
useFitPage(bot, room, watched);
// Taking needs the watch first: the daemon ties the hands to it.
const taken = useRef(0);
useEffect(() => {
Expand Down Expand Up @@ -75,7 +89,11 @@ export function BrowserPanel({
</div>
</header>
{state && (status === "open" || status === "starting") && <AddressBar state={state} />}
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto p-3">
<div
ref={body}
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
style={{ padding: PAD }}
>
{status === "failed" ? (
<Callout tone="danger" title={t.failedTitle}>
{t.failedBody}
Expand All @@ -94,41 +112,44 @@ export function BrowserPanel({
<>
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
{hands.held && <HeldBar bot={bot} task={ask} focused={focused} hands={hands} />}
<div className="relative">
<LiveView
bot={bot}
frame={frame}
action={action}
dim={status === "closed"}
held={hands.held}
>
{hands.held && frame && (
<HandsLayer
label={t.hands.screen(bot.name)}
keysLabel={t.hands.typing}
width={frame.width}
height={frame.height}
send={hands.send}
onFocus={setFocused}
<div className="min-h-40 flex-1" style={{ containerType: "size" }}>
<div className="relative">
<LiveView
bot={bot}
frame={frame}
action={action}
dim={status === "closed"}
held={hands.held}
>
{hands.held && frame && (
<HandsLayer
label={t.hands.screen(bot.name)}
keysLabel={t.hands.typing}
width={frame.width}
height={frame.height}
send={hands.send}
onFocus={setFocused}
/>
)}
</LiveView>
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
title={status === "closed" ? t.closed : t.starting}
body={status === "closed" ? t.closedBody : null}
/>
)}
</LiveView>
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
title={status === "closed" ? t.closed : t.starting}
body={status === "closed" ? t.closedBody : null}
/>
)}
</div>
</div>
{status === "open" && caption && !hands.held && (
<p
key={action?.at}
aria-live="polite"
className="mt-3 flex animate-rise items-center gap-2 text-ink-soft text-sm"
>
<BotAvatar color={bot.color} size={18} mood="working" />
<span className="truncate">{caption}</span>
{status === "open" && !hands.held && (
// Its place is kept while empty, so the page never moves for it.
<p aria-live="polite" className="mt-3 h-5 text-ink-soft text-sm">
{caption && (
<span key={action?.at} className="flex animate-rise items-center gap-2">
<BotAvatar color={bot.color} size={18} mood="working" />
<span className="truncate">{caption}</span>
</span>
)}
</p>
)}
{live && !hands.held && !ask && <TakeBar bot={bot} hands={hands} />}
Expand Down
13 changes: 9 additions & 4 deletions app/src/features/browser/LiveView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
// The bot's screen, live (spec 21.8): the newest picture of its active
// tab, with its cursor gliding to each point it acts on, a ring where it
// clicks, and a line about what it just did.
// tab, as large as fits the panel, with its cursor gliding to each point it
// acts on, a ring where it clicks, and a line about what it just did.

import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, BrowserAction, BrowserFrame } from "../../lib/protocol.gen";
import { BotCursor } from "../bots/BotCursor";

/** The page until a picture says its size. */
const PAGE = { width: 1280, height: 800 };

/** Where on the screen, in percent, an action happened. */
Expand Down Expand Up @@ -81,10 +82,14 @@ export function LiveView({
return (
<figure
aria-label={t.browser.screen(bot.name)}
className={`relative m-0 w-full overflow-hidden rounded-xl border bg-canvas shadow-sm transition-[border-color,box-shadow] duration-200 ${
className={`relative mx-auto my-0 overflow-hidden rounded-xl border bg-canvas shadow-sm transition-[border-color,box-shadow] duration-200 ${
held ? "border-accent ring-2 ring-accent/35" : "border-line"
}`}
style={{ aspectRatio: `${width} / ${height}` }}
// As large as fits its place (a size container), in the page's shape.
style={{
aspectRatio: `${width} / ${height}`,
width: `min(100cqw, 100cqh * ${width} / ${height})`,
}}
>
{frame && (
<img
Expand Down
38 changes: 36 additions & 2 deletions app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { crewOpened, openBot, renderApp } from "../../test/app";

afterEach(cleanup);
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});

/** A crew "Ops" with @scout, idle. */
function crew() {
Expand Down Expand Up @@ -68,6 +71,37 @@ describe("browser panel", () => {
expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull();
});

test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => {
const { fake, scout } = crew();
fake.browser.open(scout.id, "https://example.com/", "Example");
// The panel under its address row, as the app measures it: 536 by 700
// for the page, once what goes around and under it is set aside.
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560);
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804);
await openScout(fake);
fireEvent.click(toggle());
const resized = () =>
fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params);
await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 536, height: 700 }]));
// As wide as ever, and as tall as the room's shape asks.
expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1671 });

act(() => {
fake.browser.frame(scout.id, "AAAA");
});
// The bot's cursor lands where it acted on the taller page.
const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" });
act(() => {
fake.browser.act(scout.id, "click", { x: 640, y: 1671, label: "More" });
});
const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
expect(cursor.style.top).toBe("100%");

// Nobody watches anymore: the page is back to its own size.
fireEvent.click(within(panel()).getByRole("button", { name: "Close" }));
await waitFor(() => expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 800 }));
});

test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => {
const { fake, scout } = crew();
await openScout(fake);
Expand Down
69 changes: 69 additions & 0 deletions app/src/features/browser/useRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// The room the panel has for the bot's page (spec 21.8): measured as the
// panel changes and told to the daemon, so the page takes its shape (spec
// 21.3).

import { useEffect, useLayoutEffect, useState } from "react";
import type { Bot } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";

export interface Size {
width: number;
height: number;
}

/** How long the room stays the same before the daemon hears about it. */
const SETTLED_MS = 300;

/**
* The room inside an element, following it as it changes: `null` until it
* has any. The second value goes in the element's `ref`.
*/
export function useRoom(): [Size | null, (node: HTMLElement | null) => void] {
const [node, setNode] = useState<HTMLElement | null>(null);
const [room, setRoom] = useState<Size | null>(null);
useLayoutEffect(() => {
if (!node) {
setRoom(null);
return;
}
const measure = () => {
const width = node.clientWidth;
const height = node.clientHeight;
setRoom((was) => {
if (width <= 0 || height <= 0) {
return null;
}
return was?.width === width && was.height === height ? was : { width, height };
});
};
measure();
if (typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, [node]);
return [room, setNode];
}

/**
* Tells the daemon the room the page has, once it stops changing, while
* this connection watches the bot's browser.
*/
export function useFitPage(bot: Pick<Bot, "id">, room: Size | null, watched: boolean): void {
const api = useApi();
const known = room !== null;
const width = Math.max(1, room?.width ?? 0);
const height = Math.max(1, room?.height ?? 0);
useEffect(() => {
if (!watched || !known) {
return;
}
const timer = setTimeout(() => {
// The next change of the room asks again.
api.call("browser.resize", { botId: bot.id, width, height }).catch(() => {});
}, SETTLED_MS);
return () => clearTimeout(timer);
}, [api, bot.id, watched, known, width, height]);
}
Loading
Loading