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
2 changes: 2 additions & 0 deletions app/src/dev/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { FakeBotloft } from "../lib/fake";
import { FakeHost } from "../lib/fakeHost";
import type { Host } from "../lib/host";
import { PROTOCOL_VERSION } from "../lib/protocol.gen";
import { seedBrowser } from "./seedBrowser";
import { seedChats } from "./seedChats";
import { seedChief } from "./seedChief";
import { seedRoutines } from "./seedRoutines";
Expand Down Expand Up @@ -111,6 +112,7 @@ function seed(fake: FakeBotloft): void {
planner: planner.id,
});
seedRoutines(fake, { scout: scout.id, analyst: analyst.id });
seedBrowser(fake, scout.id);
const ops = fake.addCrew("Ops");
const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays");
const watcher = fake.addBot(ops.id, "Watcher", "Keeps an eye on the error log");
Expand Down
130 changes: 130 additions & 0 deletions app/src/dev/seedBrowser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// 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.

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

const W = 1280;
const H = 800;

interface Scene {
url: string;
title: string;
query: string;
results: boolean;
}

function draw(scene: Scene): string {
const canvas = document.createElement("canvas");
canvas.width = W;
canvas.height = H;
const g = canvas.getContext("2d");
if (!g) {
return "";
}
g.fillStyle = "#ffffff";
g.fillRect(0, 0, W, H);
g.fillStyle = "#1f2937";
g.font = "600 34px system-ui, sans-serif";
g.fillText(scene.results ? "Results" : "Find a recipe", 120, 150);
g.strokeStyle = "#9ca3af";
g.lineWidth = 2;
g.beginPath();
g.roundRect(120, 190, 760, 56, 28);
g.stroke();
g.fillStyle = scene.query ? "#111827" : "#9ca3af";
g.font = "22px system-ui, sans-serif";
g.fillText(scene.query || "Search recipes", 150, 226);
g.fillStyle = "#2563eb";
g.beginPath();
g.roundRect(900, 190, 150, 56, 28);
g.fill();
g.fillStyle = "#ffffff";
g.fillText("Search", 938, 226);
if (scene.results) {
for (let row = 0; row < 4; 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.fillStyle = "#e5e7eb";
g.fillRect(120, y + 18, 820, 14);
g.fillRect(120, y + 42, 560, 14);
}
}
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
}

/** Scout searches for a recipe, over and over. */
export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
const home: Scene = {
url: "https://recipes.example/",
title: "Recipes",
query: "",
results: false,
};
const steps: ((scene: Scene) => Scene)[] = [
(scene) => {
fake.browser.act(scout, "open", { label: "recipes.example" });
return scene;
},
(scene) => {
fake.browser.act(scout, "type", { x: 500, y: 218, label: "Search recipes" });
return { ...scene, query: "sourdough" };
},
(scene) => {
fake.browser.act(scout, "click", { x: 975, y: 218, label: "Search" });
return { ...scene, url: "https://recipes.example/search?q=sourdough", results: true };
},
(scene) => {
fake.browser.act(scout, "click", { x: 260, y: 300, label: "Classic sourdough loaf" });
return scene;
},
() => {
fake.browser.act(scout, "back");
return home;
},
];
// How it reads in the chat: the site the owner allowed, then the calls.
const asked = fake.chat.ask(
scout,
"mcp__botloft__browser",
"recipes.example",
JSON.stringify({ site: "recipes.example", url: home.url }),
);
if (asked.body.kind === "approval") {
void fake.call("approvals.answer", { approvalId: asked.body.approvalId, allow: true });
}
const calls: [string, string][] = [
["browser_open", home.url],
["browser_type", "e4"],
["browser_click", "e5"],
];
for (const [tool, summary] of calls) {
fake.chat.tool(scout, `mcp__botloft__${tool}`, { summary, status: "done" });
}
fake.chat.ask(
scout,
"mcp__botloft__browser",
"wikipedia.org",
JSON.stringify({ site: "wikipedia.org", url: "https://en.wikipedia.org/wiki/Sourdough" }),
);
let scene = home;
let step = 0;
fake.browser.open(scout, home.url, home.title);
fake.browser.frame(scout, draw(scene));
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));
}
}, 2200);
}
30 changes: 30 additions & 0 deletions app/src/features/bots/BotHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Ellipsis,
Files,
FolderOpen,
Globe,
PanelRight,
Pause,
Pencil,
Expand Down Expand Up @@ -36,6 +37,9 @@ export function BotHeader({
filesOpen,
freshFiles,
onToggleFiles,
browserOpen,
browsing,
onToggleBrowser,
}: {
bot: Bot;
crew: Crew;
Expand All @@ -45,6 +49,10 @@ export function BotHeader({
/** Files that showed up since the owner last looked. */
freshFiles: number;
onToggleFiles(): void;
browserOpen: boolean;
/** The bot's browser is open (spec 21.8). */
browsing: boolean;
onToggleBrowser(): void;
}) {
const t = useT();
const words = t.bots.header;
Expand Down Expand Up @@ -107,6 +115,28 @@ export function BotHeader({
<Button icon={RotateCw} disabled={stopped} onClick={() => restart(false)}>
{words.restart}
</Button>
<span className="relative">
<Button
variant={browserOpen ? "secondary" : "ghost"}
icon={Globe}
label={
browserOpen
? t.browser.hide
: browsing
? `${t.browser.show}: ${t.browser.browsing(bot.name)}`
: t.browser.show
}
aria-pressed={browserOpen}
onClick={onToggleBrowser}
/>
{browsing && !browserOpen && (
<span
aria-hidden
className="live-dot pointer-events-none absolute top-0.5 right-0.5"
style={{ background: "var(--work)" }}
/>
)}
</span>
<span className="relative">
<Button
variant={filesOpen ? "secondary" : "ghost"}
Expand Down
23 changes: 21 additions & 2 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { SidePanel } from "../../ui/SidePanel";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { BrowserPanel } from "../browser/BrowserPanel";
import { ShowBrowser } from "../browser/showBrowser";
import { ChatView } from "../chat/ChatView";
import { FilesPanel } from "../files/FilesPanel";
import { ShowFile } from "../files/showFile";
Expand All @@ -20,7 +22,7 @@ import { stateView } from "./BotStateBadge";

type Pane = "chat" | "routines";
/** What the panel beside the chat shows. */
type Side = "details" | "files" | null;
type Side = "details" | "files" | "browser" | null;

/**
* A bot's conversation and its routines, with its details in a side panel
Expand Down Expand Up @@ -66,6 +68,17 @@ 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)));
const browsing = useApp((state) => {
const status = state.browsers[bot.id]?.status;
return status === "open" || status === "starting";
});
const browserOpen = side === "browser";
const showBrowser = () => {
if (filesOpen) {
seen();
}
setSide("browser");
};
const view = stateView(bot, crew.paused, t);
const tabs: Tab<Pane>[] = [
{ id: "chat", label: t.routines.chatTab },
Expand All @@ -85,20 +98,26 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
filesOpen={filesOpen}
freshFiles={fresh}
onToggleFiles={toggleFiles}
browserOpen={browserOpen}
browsing={browsing}
onToggleBrowser={() => (browserOpen ? setSide(null) : showBrowser())}
/>
<Notices bot={bot} crew={crew} view={view} />
<Tabs<Pane> label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
<div className="flex min-h-0 flex-1">
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
{pane === "chat" ? (
<ShowFile.Provider value={showFile}>
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
<ShowBrowser.Provider value={showBrowser}>
<ChatView bot={bot} stopped={bot.paused || crew.paused} />
</ShowBrowser.Provider>
</ShowFile.Provider>
) : (
<BotRoutines bot={bot} />
)}
</div>
{side === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{browserOpen && <BrowserPanel bot={bot} onClose={() => setSide(null)} />}
{filesOpen && (
<FilesPanel
bot={bot}
Expand Down
Loading
Loading