Skip to content

Commit 4105fc1

Browse files
committed
feat(app): tabs, reload and a typed address in the browser panel
The panel shows a tab for each page the bot has open, with its title and site, in place of the count. They only show while the browser is the bot's; with it in the owner's hands a click switches tabs and + opens a new one, with the cursor in the address row, where Enter takes the tab to what the owner typed. The bar that says the owner is in control tells that the bot goes on in the tab left open. The address row has a reload button, which works at any time. FakeBotloft has the tabs and the four new methods, and the preview's Scout has three tabs and waits while the owner has its browser.
1 parent 7f08e05 commit 4105fc1

14 files changed

Lines changed: 648 additions & 110 deletions

File tree

‎app/src/dev/seedBrowser.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// Dev only: Scout searching in its browser in the fake preview, so the
2-
// browser panel has something live to show. The "page" is drawn on a
3-
// canvas, the size the panel asks for, and sent as JPEG frames, like the
4-
// daemon's screencast.
2+
// browser panel has something live to show: three tabs, the search in the
3+
// active one. The "page" is drawn on a canvas, the size the panel asks for,
4+
// and sent as JPEG frames, like the daemon's screencast.
55

66
import type { FakeBotloft } from "../lib/fake";
7-
import type { PageSize } from "../lib/fakeBrowser";
7+
import type { PageSize } from "../lib/fakePages";
88
import type { BotId } from "../lib/protocol.gen";
9+
import { activeTab, drawPage } from "./seedPage";
10+
11+
const SITE = "https://recipes.example/";
912

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

@@ -58,10 +61,10 @@ function draw(scene: Scene, size: PageSize): string {
5861
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
5962
}
6063

61-
/** Scout searches for a recipe, over and over. */
64+
/** Scout searches for a recipe, over and over, while the browser is its. */
6265
export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
6366
const home: Scene = {
64-
url: "https://recipes.example/",
67+
url: SITE,
6568
title: "Recipes",
6669
query: "",
6770
results: false,
@@ -114,9 +117,19 @@ export function seedBrowser(fake: FakeBotloft, scout: BotId): void {
114117
);
115118
let scene = home;
116119
let step = 0;
117-
fake.browser.open(scout, home.url, home.title);
118-
fake.browser.paint(scout, (size) => draw(scene, size));
120+
// Two pages it opened on the way, then the one it works in.
121+
fake.browser.open(scout, "https://bakers.example/forum/hydration", "Hydration tips · Bakers");
122+
fake.browser.openTab(scout, "https://mill.example/flour/rye", "Rye flour · The Mill");
123+
fake.browser.openTab(scout, home.url, home.title);
124+
fake.browser.paint(scout, (size) => {
125+
const tab = activeTab(fake, scout);
126+
return tab?.url.startsWith(SITE) ? draw(scene, size) : drawPage(tab, size);
127+
});
119128
setInterval(() => {
129+
// In the owner's hands, the bot waits.
130+
if (fake.browser.state(scout).control === "owner") {
131+
return;
132+
}
120133
const next = steps[step % steps.length];
121134
step += 1;
122135
if (next) {

‎app/src/dev/seedHands.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
// the owner's clicks and keys, so taking the browser can be tried end to end.
44

55
import type { FakeBotloft } from "../lib/fake";
6-
import type { PageSize } from "../lib/fakeBrowser";
6+
import type { PageSize } from "../lib/fakePages";
77
import type { BrowserInput, CrewId } from "../lib/protocol.gen";
8+
import { activeTab, drawPage } from "./seedPage";
9+
10+
const SITE = "https://flour.example/";
811

912
const FIELDS = { user: 300, password: 390 } as const;
1013
type Field = keyof typeof FIELDS;
@@ -89,20 +92,23 @@ export function seedHands(fake: FakeBotloft, crewId: CrewId): void {
8992
const clerk = fake.addBot(crewId, "Clerk", "Places the bakery's orders");
9093
fake.setBotState(clerk.id, "needs_approval", 2);
9194
const form: Form = { user: "", password: "", field: null, signedIn: false };
92-
const login = "https://flour.example/login";
95+
const login = `${SITE}login`;
96+
const onSite = () => activeTab(fake, clerk.id)?.url.startsWith(SITE) ?? false;
9397
fake.browser.open(clerk.id, login, "Sign in · Flour Co.");
94-
fake.browser.paint(clerk.id, (size) => draw(form, size));
98+
fake.browser.paint(clerk.id, (size) =>
99+
onSite() ? draw(form, size) : drawPage(activeTab(fake, clerk.id), size),
100+
);
95101
fake.chat.tool(clerk.id, "mcp__botloft__browser_open", { summary: login, status: "done" });
96102
const task = "Sign in to Flour Co. with the bakery's account";
97103
fake.chat.tool(clerk.id, "mcp__botloft__browser_ask_owner", { summary: task, status: "running" });
98104
fake.browser.ask(clerk.id, task);
99105
fake.browser.onInput(clerk.id, (input) => {
100-
if (form.signedIn) {
106+
if (form.signedIn || !onSite()) {
101107
return;
102108
}
103109
if (handle(form, input)) {
104110
form.signedIn = true;
105-
fake.browser.open(clerk.id, "https://flour.example/orders", "Orders · Flour Co.");
111+
fake.browser.open(clerk.id, `${SITE}orders`, "Orders · Flour Co.");
106112
}
107113
fake.browser.repaint(clerk.id);
108114
});

‎app/src/dev/seedPage.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Dev only: any page of the fake preview's browsers that no seed draws
2+
// itself, such as a tab in the background or an address the owner typed.
3+
// A heading with the tab's title, its site, and lines of grey "text".
4+
5+
import type { FakeBotloft } from "../lib/fake";
6+
import type { PageSize } from "../lib/fakePages";
7+
import type { BotId, BrowserTab } from "../lib/protocol.gen";
8+
9+
/** The bot's active tab, if its browser has any. */
10+
export function activeTab(fake: FakeBotloft, botId: BotId): BrowserTab | null {
11+
return fake.browser.state(botId).tabs.find((tab) => tab.active) ?? null;
12+
}
13+
14+
export function drawPage(tab: BrowserTab | null, size: PageSize): string {
15+
const canvas = document.createElement("canvas");
16+
canvas.width = size.width;
17+
canvas.height = size.height;
18+
const g = canvas.getContext("2d");
19+
if (!g) {
20+
return "";
21+
}
22+
g.fillStyle = "#ffffff";
23+
g.fillRect(0, 0, size.width, size.height);
24+
if (tab && tab.url !== "about:blank") {
25+
g.fillStyle = "#111827";
26+
g.font = "600 40px Georgia, serif";
27+
g.fillText(tab.title || tab.url, 120, 160);
28+
g.fillStyle = "#6b7280";
29+
g.font = "20px system-ui, sans-serif";
30+
g.fillText(tab.url, 120, 204);
31+
g.fillStyle = "#e5e7eb";
32+
for (let y = 270; y < size.height - 80; y += 34) {
33+
g.fillRect(120, y, y % 5 === 0 ? 640 : 960, 14);
34+
}
35+
}
36+
return canvas.toDataURL("image/jpeg", 0.7).split(",")[1] ?? "";
37+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// The address row of a bot's browser (spec 21.8): reloading the page, where
2+
// it is, and opening it in the owner's own browser. With the browser in
3+
// the owner's hands the address is theirs to type (spec 21.10).
4+
5+
import { ExternalLink, Globe, LoaderCircle, Lock, RotateCw } from "lucide-react";
6+
import { type RefObject, useEffect, useState } from "react";
7+
import { useT } from "../../i18n";
8+
import type { Bot, BrowserState } from "../../lib/protocol.gen";
9+
import { useApi, useHost } from "../../store/context";
10+
import { Button } from "../../ui/Button";
11+
import { attempt } from "../../ui/toast";
12+
import type { Hands } from "./useHands";
13+
14+
export function AddressBar({
15+
bot,
16+
state,
17+
hands,
18+
field,
19+
}: {
20+
bot: Pick<Bot, "id">;
21+
state: BrowserState;
22+
hands: Hands;
23+
/** The address field, for whoever sends the owner to it. */
24+
field: RefObject<HTMLInputElement | null>;
25+
}) {
26+
const t = useT().browser;
27+
const api = useApi();
28+
const host = useHost();
29+
const url = state.url && state.url !== "about:blank" ? state.url : null;
30+
const web = url !== null && /^https?:\/\//i.test(url);
31+
const Icon = state.loading ? LoaderCircle : url?.startsWith("https://") ? Lock : Globe;
32+
const held = hands.held;
33+
const tab = state.tabs.find((tab) => tab.active)?.id;
34+
// What the owner is typing; `null` shows where the page is.
35+
const [draft, setDraft] = useState<string | null>(null);
36+
// biome-ignore lint/correctness/useExhaustiveDependencies: another tab, or the bot's turn again, drops what was typed
37+
useEffect(() => setDraft(null), [tab, held]);
38+
39+
const go = () => {
40+
const typed = draft?.trim();
41+
setDraft(null);
42+
field.current?.blur();
43+
if (typed) {
44+
void hands.open(typed);
45+
}
46+
};
47+
48+
return (
49+
<div className="flex h-11 shrink-0 items-center gap-1 border-line border-b px-1.5">
50+
<Button
51+
variant="ghost"
52+
size="sm"
53+
icon={RotateCw}
54+
label={t.reload}
55+
disabled={state.status !== "open"}
56+
onClick={() => attempt(t.reloadFailed, () => api.call("browser.reload", { botId: bot.id }))}
57+
/>
58+
<div
59+
className={`flex min-w-0 flex-1 items-center gap-2 rounded-full bg-sunken px-3 py-1.5 ${
60+
held ? "focus-within:ring-2 focus-within:ring-accent/35" : ""
61+
}`}
62+
>
63+
<Icon
64+
aria-label={state.loading ? t.loading : undefined}
65+
aria-hidden={!state.loading}
66+
size={13}
67+
className={`shrink-0 ${state.loading ? "animate-spin text-work" : "text-muted"}`}
68+
/>
69+
<input
70+
ref={field}
71+
type="text"
72+
readOnly={!held}
73+
aria-label={t.address}
74+
title={url ?? undefined}
75+
placeholder={held ? t.addressHint : undefined}
76+
autoComplete="off"
77+
spellCheck={false}
78+
value={draft ?? url ?? (held ? "" : (state.title ?? ""))}
79+
onChange={(event) => setDraft(event.target.value)}
80+
onFocus={(event) => held && event.currentTarget.select()}
81+
onKeyDown={(event) => {
82+
if (!held) {
83+
return;
84+
}
85+
if (event.key === "Enter") {
86+
event.preventDefault();
87+
go();
88+
} else if (event.key === "Escape") {
89+
setDraft(null);
90+
event.currentTarget.blur();
91+
}
92+
}}
93+
className="min-w-0 flex-1 truncate bg-transparent font-mono text-ink-soft text-xs outline-none"
94+
/>
95+
</div>
96+
{web && url && (
97+
<Button
98+
variant="ghost"
99+
size="sm"
100+
icon={ExternalLink}
101+
label={t.openOutside}
102+
onClick={() => attempt(t.openFailed, () => host.openUrl(url))}
103+
/>
104+
)}
105+
</div>
106+
);
107+
}

‎app/src/features/browser/BrowserPanel.tsx‎

Lines changed: 31 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
1-
// The bot's own browser beside its chat (spec 21.8): the page it is on,
2-
// live, with its cursor and a line about what it just did. Watching starts
3-
// when the panel opens and stops when it closes. The owner can take it into
4-
// their own hands and give it back (spec 21.10).
1+
// The bot's own browser beside its chat (spec 21.8): its tabs, the address
2+
// and the page it is on, live, with its cursor and a line about what it
3+
// just did. Watching starts when the panel opens and stops when it closes.
4+
// The owner can take it into their own hands and give it back (spec 21.10).
55

6-
import { ExternalLink, Globe, LoaderCircle, Lock, Maximize2, Minimize2, X } from "lucide-react";
6+
import { Globe, LoaderCircle, Maximize2, Minimize2, X } from "lucide-react";
77
import { useEffect, useRef, useState } from "react";
88
import { useT } from "../../i18n";
9-
import type { Bot, BrowserState } from "../../lib/protocol.gen";
10-
import { useApp, useHost } from "../../store/context";
9+
import type { Bot } from "../../lib/protocol.gen";
10+
import { useApp } from "../../store/context";
1111
import { Button } from "../../ui/Button";
1212
import { Callout } from "../../ui/Callout";
1313
import { SidePanel } from "../../ui/SidePanel";
14-
import { attempt } from "../../ui/toast";
1514
import { BotAvatar } from "../bots/BotAvatar";
15+
import { AddressBar } from "./AddressBar";
1616
import { AskCallout, HeldBar, TakeBar } from "./HandsBars";
1717
import { HandsLayer } from "./HandsLayer";
1818
import { LiveView, useCaption } from "./LiveView";
19+
import { TabStrip } from "./TabStrip";
1920
import { useBrowserView } from "./useBrowserView";
2021
import { useHands } from "./useHands";
2122
import { useFitPage, useRoom } from "./useRoom";
@@ -53,6 +54,13 @@ export function BrowserPanel({
5354
height: inside.height - 2 * PAD - UNDER,
5455
};
5556
useFitPage(bot, room, watched);
57+
// A new tab is blank: the owner says where it goes.
58+
const address = useRef<HTMLInputElement>(null);
59+
const addTab = async () => {
60+
if (await hands.newTab()) {
61+
address.current?.focus();
62+
}
63+
};
5664
// Taking needs the watch first: the daemon ties the hands to it.
5765
const taken = useRef(0);
5866
useEffect(() => {
@@ -88,7 +96,12 @@ export function BrowserPanel({
8896
<Button variant="ghost" size="sm" icon={X} label={t.close} onClick={onClose} />
8997
</div>
9098
</header>
91-
{state && (status === "open" || status === "starting") && <AddressBar state={state} />}
99+
{state && (status === "open" || status === "starting") && (
100+
<>
101+
{state.tabs.length > 0 && <TabStrip state={state} hands={hands} onAdd={addTab} />}
102+
<AddressBar bot={bot} state={state} hands={hands} field={address} />
103+
</>
104+
)}
92105
<div
93106
ref={body}
94107
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
@@ -111,7 +124,15 @@ export function BrowserPanel({
111124
) : (
112125
<>
113126
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
114-
{hands.held && <HeldBar bot={bot} task={ask} focused={focused} hands={hands} />}
127+
{hands.held && (
128+
<HeldBar
129+
bot={bot}
130+
task={ask}
131+
tabs={state?.tabs.length ?? 0}
132+
focused={focused}
133+
hands={hands}
134+
/>
135+
)}
115136
<div className="min-h-40 flex-1" style={{ containerType: "size" }}>
116137
<div className="relative">
117138
<LiveView
@@ -160,44 +181,6 @@ export function BrowserPanel({
160181
);
161182
}
162183

163-
function AddressBar({ state }: { state: BrowserState }) {
164-
const t = useT().browser;
165-
const host = useHost();
166-
const url = state.url && state.url !== "about:blank" ? state.url : null;
167-
const web = url !== null && /^https?:\/\//i.test(url);
168-
const Icon = state.loading ? LoaderCircle : url?.startsWith("https://") ? Lock : Globe;
169-
return (
170-
<div className="flex h-11 shrink-0 items-center gap-2 border-line border-b px-3">
171-
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-full bg-sunken px-3 py-1.5">
172-
<Icon
173-
aria-label={state.loading ? t.loading : undefined}
174-
aria-hidden={!state.loading}
175-
size={13}
176-
className={`shrink-0 ${state.loading ? "animate-spin text-work" : "text-muted"}`}
177-
/>
178-
<input
179-
readOnly
180-
type="text"
181-
aria-label={t.address}
182-
title={url ?? undefined}
183-
value={url ?? state.title ?? ""}
184-
className="min-w-0 flex-1 truncate bg-transparent font-mono text-ink-soft text-xs outline-none"
185-
/>
186-
</div>
187-
{state.tabs > 1 && <span className="shrink-0 text-muted text-xs">{t.tabs(state.tabs)}</span>}
188-
{web && url && (
189-
<Button
190-
variant="ghost"
191-
size="sm"
192-
icon={ExternalLink}
193-
label={t.openOutside}
194-
onClick={() => attempt(t.openFailed, () => host.openUrl(url))}
195-
/>
196-
)}
197-
</div>
198-
);
199-
}
200-
201184
function Overlay({ busy, title, body }: { busy: boolean; title: string; body: string | null }) {
202185
return (
203186
<div className="absolute inset-0 grid place-items-center rounded-xl">

‎app/src/features/browser/HandsBars.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,14 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands
3535
export function HeldBar({
3636
bot,
3737
task,
38+
tabs,
3839
focused,
3940
hands,
4041
}: {
4142
bot: Bot;
4243
task: string | null;
44+
/** How many tabs are open: with more than one, where the bot goes on. */
45+
tabs: number;
4346
focused: boolean;
4447
hands: Hands;
4548
}) {
@@ -57,6 +60,7 @@ export function HeldBar({
5760
<p className="line-clamp-2 text-ink-soft text-xs" data-selectable>
5861
{task ? t.help.asks(bot.name, task) : t.hands.waits(bot.name)}
5962
</p>
63+
{tabs > 1 && <p className="text-muted text-xs">{t.hands.leaves(bot.name)}</p>}
6064
</div>
6165
</div>
6266
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2 pl-6.5">

0 commit comments

Comments
 (0)