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
49 changes: 30 additions & 19 deletions app/src/dev/seedBrowser.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// 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.
// browser panel has something live to show: three tabs, the search in the
// active one. The "page" is drawn on a 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/fakePages";
import type { BotId } from "../lib/protocol.gen";
import { activeTab, drawPage } from "./seedPage";

const W = 1280;
const H = 800;
const SITE = "https://recipes.example/";

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

interface Scene {
url: string;
Expand All @@ -15,16 +19,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 +47,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 All @@ -60,10 +61,10 @@ function draw(scene: Scene): string {
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
}

/** Scout searches for a recipe, over and over. */
/** Scout searches for a recipe, over and over, while the browser is its. */
export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
const home: Scene = {
url: "https://recipes.example/",
url: SITE,
title: "Recipes",
query: "",
results: false,
Expand Down Expand Up @@ -116,15 +117,25 @@ 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));
// Two pages it opened on the way, then the one it works in.
fake.browser.open(scout, "https://bakers.example/forum/hydration", "Hydration tips · Bakers");
fake.browser.openTab(scout, "https://mill.example/flour/rye", "Rye flour · The Mill");
fake.browser.openTab(scout, home.url, home.title);
fake.browser.paint(scout, (size) => {
const tab = activeTab(fake, scout);
return tab?.url.startsWith(SITE) ? draw(scene, size) : drawPage(tab, size);
});
setInterval(() => {
// In the owner's hands, the bot waits.
if (fake.browser.state(scout).control === "owner") {
return;
}
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);
}
27 changes: 16 additions & 11 deletions app/src/dev/seedHands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
// 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/fakePages";
import type { BrowserInput, CrewId } from "../lib/protocol.gen";
import { activeTab, drawPage } from "./seedPage";

const SITE = "https://flour.example/";

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

Expand All @@ -17,16 +19,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 @@ -90,21 +92,24 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
const clerk = fake.addBot(crewId, "Clerk", "Places the bakery's orders");
fake.setBotState(clerk.id, "needs_approval", 2);
const form: Form = { user: "", password: "", field: null, signedIn: false };
const login = "https://flour.example/login";
const login = `${SITE}login`;
const onSite = () => activeTab(fake, clerk.id)?.url.startsWith(SITE) ?? false;
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
fake.browser.frame(clerk.id, draw(form));
fake.browser.paint(clerk.id, (size) =>
onSite() ? draw(form, size) : drawPage(activeTab(fake, clerk.id), 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" });
fake.browser.ask(clerk.id, task);
fake.browser.onInput(clerk.id, (input) => {
if (form.signedIn) {
if (form.signedIn || !onSite()) {
return;
}
if (handle(form, input)) {
form.signedIn = true;
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
fake.browser.open(clerk.id, `${SITE}orders`, "Orders · Flour Co.");
}
fake.browser.frame(clerk.id, draw(form));
fake.browser.repaint(clerk.id);
});
}
37 changes: 37 additions & 0 deletions app/src/dev/seedPage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Dev only: any page of the fake preview's browsers that no seed draws
// itself, such as a tab in the background or an address the owner typed.
// A heading with the tab's title, its site, and lines of grey "text".

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

/** The bot's active tab, if its browser has any. */
export function activeTab(fake: FakeBotloft, botId: BotId): BrowserTab | null {
return fake.browser.state(botId).tabs.find((tab) => tab.active) ?? null;
}

export function drawPage(tab: BrowserTab | null, size: PageSize): string {
const canvas = document.createElement("canvas");
canvas.width = size.width;
canvas.height = size.height;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#ffffff";
g.fillRect(0, 0, size.width, size.height);
if (tab && tab.url !== "about:blank") {
g.fillStyle = "#111827";
g.font = "600 40px Georgia, serif";
g.fillText(tab.title || tab.url, 120, 160);
g.fillStyle = "#6b7280";
g.font = "20px system-ui, sans-serif";
g.fillText(tab.url, 120, 204);
g.fillStyle = "#e5e7eb";
for (let y = 270; y < size.height - 80; y += 34) {
g.fillRect(120, y, y % 5 === 0 ? 640 : 960, 14);
}
}
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
}
107 changes: 107 additions & 0 deletions app/src/features/browser/AddressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// The address row of a bot's browser (spec 21.8): reloading the page, where
// it is, and opening it in the owner's own browser. With the browser in
// the owner's hands the address is theirs to type (spec 21.10).

import { ExternalLink, Globe, LoaderCircle, Lock, RotateCw } from "lucide-react";
import { type RefObject, useEffect, useState } from "react";
import { useT } from "../../i18n";
import type { Bot, BrowserState } from "../../lib/protocol.gen";
import { useApi, useHost } from "../../store/context";
import { Button } from "../../ui/Button";
import { attempt } from "../../ui/toast";
import type { Hands } from "./useHands";

export function AddressBar({
bot,
state,
hands,
field,
}: {
bot: Pick<Bot, "id">;
state: BrowserState;
hands: Hands;
/** The address field, for whoever sends the owner to it. */
field: RefObject<HTMLInputElement | null>;
}) {
const t = useT().browser;
const api = useApi();
const host = useHost();
const url = state.url && state.url !== "about:blank" ? state.url : null;
const web = url !== null && /^https?:\/\//i.test(url);
const Icon = state.loading ? LoaderCircle : url?.startsWith("https://") ? Lock : Globe;
const held = hands.held;
const tab = state.tabs.find((tab) => tab.active)?.id;
// What the owner is typing; `null` shows where the page is.
const [draft, setDraft] = useState<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: another tab, or the bot's turn again, drops what was typed
useEffect(() => setDraft(null), [tab, held]);

const go = () => {
const typed = draft?.trim();
setDraft(null);
field.current?.blur();
if (typed) {
void hands.open(typed);
}
};

return (
<div className="flex h-11 shrink-0 items-center gap-1 border-line border-b px-1.5">
<Button
variant="ghost"
size="sm"
icon={RotateCw}
label={t.reload}
disabled={state.status !== "open"}
onClick={() => attempt(t.reloadFailed, () => api.call("browser.reload", { botId: bot.id }))}
/>
<div
className={`flex min-w-0 flex-1 items-center gap-2 rounded-full bg-sunken px-3 py-1.5 ${
held ? "focus-within:ring-2 focus-within:ring-accent/35" : ""
}`}
>
<Icon
aria-label={state.loading ? t.loading : undefined}
aria-hidden={!state.loading}
size={13}
className={`shrink-0 ${state.loading ? "animate-spin text-work" : "text-muted"}`}
/>
<input
ref={field}
type="text"
readOnly={!held}
aria-label={t.address}
title={url ?? undefined}
placeholder={held ? t.addressHint : undefined}
autoComplete="off"
spellCheck={false}
value={draft ?? url ?? (held ? "" : (state.title ?? ""))}
onChange={(event) => setDraft(event.target.value)}
onFocus={(event) => held && event.currentTarget.select()}
onKeyDown={(event) => {
if (!held) {
return;
}
if (event.key === "Enter") {
event.preventDefault();
go();
} else if (event.key === "Escape") {
setDraft(null);
event.currentTarget.blur();
}
}}
className="min-w-0 flex-1 truncate bg-transparent font-mono text-ink-soft text-xs outline-none"
/>
</div>
{web && url && (
<Button
variant="ghost"
size="sm"
icon={ExternalLink}
label={t.openOutside}
onClick={() => attempt(t.openFailed, () => host.openUrl(url))}
/>
)}
</div>
);
}
Loading
Loading