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
21 changes: 7 additions & 14 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Callout } from "../../ui/Callout";
import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { ComputerDock } from "../terminal/ComputerDock";
import { ComputerFooter } from "../terminal/ComputerDock";
import { AddressBar } from "./AddressBar";
import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars";
import { HandsLayer } from "./HandsLayer";
Expand All @@ -34,12 +34,10 @@ const PAD = 12;
* The bot's color around the page, in px: on the sides and top, and below,
* where the pill that says who is in control hangs over its edge.
*/
const STAGE = 14;
const STAGE_BOTTOM = 30;
const STAGE = 10;
const STAGE_BOTTOM = 26;
/** Kept under the page, for the pill and what the bot just did. */
const UNDER = 56;
/** The dock of the bot's computer, on the desk under the page. */
const DOCK = 56;

export function BrowserPanel({
bot,
Expand Down Expand Up @@ -70,7 +68,7 @@ export function BrowserPanel({
const [inside, body] = useRoom();
const room = inside && {
width: inside.width - 2 * PAD - 2 * STAGE,
height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER - DOCK,
height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER,
};
useFitPage(bot, room, watched);
// A new tab is blank: the owner says where it goes.
Expand All @@ -92,7 +90,7 @@ export function BrowserPanel({
}, [take, watched, status, hands.held, hands.take]);

return (
<SidePanel label={t.panel(bot.name)} name="browser" defaultWidth={560} expanded={expanded}>
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600} expanded={expanded}>
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="flex items-center gap-2 font-semibold text-sm">
{t.heading}
Expand Down Expand Up @@ -146,12 +144,7 @@ export function BrowserPanel({
)}
</Callout>
) : status === "closed" && !frame ? (
!windowed && (
<>
<Empty bot={bot} />
<ComputerDock className="mt-auto" />
</>
)
!windowed && <Empty bot={bot} />
) : (
<>
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
Expand Down Expand Up @@ -199,7 +192,6 @@ export function BrowserPanel({
)}
</div>
</div>
<ComputerDock className="mt-3" />
</div>
{(hands.held || (live && !ask)) && (
<ControlPill key={String(hands.held)} bot={bot} hands={hands} lesson={lesson} />
Expand Down Expand Up @@ -229,6 +221,7 @@ export function BrowserPanel({
</>
)}
</div>
<ComputerFooter />
{lesson.done && <LessonDialog bot={bot} lesson={lesson} />}
</SidePanel>
);
Expand Down
10 changes: 5 additions & 5 deletions app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,25 +74,25 @@ describe("browser panel", () => {
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: 508 by 624
// The panel under its address row, as the app measures it: 516 by 688
// 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: 508, height: 624 }]));
// As wide as ever, and as tall as the room's shape asks.
expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1572 });
await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 516, height: 688 }]));
// A narrow room keeps the desktop layout's width, in the room's shape.
expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1066 });

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: 1572, label: "More" });
fake.browser.act(scout.id, "click", { x: 400, y: 1066, label: "More" });
});
const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
expect(cursor.style.top).toBe("100%");
Expand Down
35 changes: 31 additions & 4 deletions app/src/features/browser/useBrowserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// asks for its live frames and hears what the bot does, and stops asking
// when the panel closes. The browser's state itself lives in the store.

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

Expand All @@ -15,17 +15,44 @@ export interface BrowserView {
watched: boolean;
}

/**
* The newest picture of each bot's page, so the panel opening again (from
* the dock) shows it at once while the watch starts again.
*/
const lastFrames = new WeakMap<object, Map<string, BrowserFrame>>();

function framesOf(api: object): Map<string, BrowserFrame> {
let frames = lastFrames.get(api);
if (!frames) {
frames = new Map();
lastFrames.set(api, frames);
}
return frames;
}

export function useBrowserView(bot: Bot, watching: boolean): BrowserView {
const api = useApi();
const connected = useApp((state) => state.connection.kind === "open");
const putBrowser = useApp((state) => state.putBrowser);
const [frame, setFrame] = useState<BrowserFrame | null>(null);
const frames = framesOf(api);
const [frame, setShown] = useState<BrowserFrame | null>(() => frames.get(bot.id) ?? null);
const setFrame = useCallback(
(next: BrowserFrame | null | ((current: BrowserFrame | null) => BrowserFrame | null)) =>
setShown((current) => {
const value = typeof next === "function" ? next(current) : next;
if (value) {
frames.set(value.botId, value);
}
return value;
}),
[frames],
);
const [action, setAction] = useState<BrowserAction | null>(null);
const [watched, setWatched] = useState(false);

// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's page is not this bot's
useEffect(() => {
setFrame(null);
setFrame(frames.get(bot.id) ?? null);
setAction(null);
}, [bot.id]);

Expand Down Expand Up @@ -59,7 +86,7 @@ export function useBrowserView(bot: Bot, watching: boolean): BrowserView {
unsubscribe();
api.call("browser.unwatch").catch(() => {});
};
}, [api, bot.id, watching, connected, putBrowser]);
}, [api, bot.id, watching, connected, putBrowser, setFrame]);

return { frame, action, watched };
}
8 changes: 3 additions & 5 deletions app/src/features/files/FilesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { ComputerDock } from "../terminal/ComputerDock";
import { ComputerFooter } from "../terminal/ComputerDock";
import { FilePreview } from "./FilePreview";
import { fileKind, kindIcon } from "./kinds";
import type { BotFiles } from "./useBotFiles";
Expand Down Expand Up @@ -42,7 +42,7 @@ export function FilesPanel({
(described?.path === path ? described : undefined);

return (
<SidePanel label={t.panel(bot.name)} name="files" defaultWidth={416}>
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600}>
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="font-semibold text-sm">{t.heading}</h2>
<div className="flex items-center gap-0.5">
Expand All @@ -63,9 +63,7 @@ export function FilesPanel({
) : (
<List bot={bot} data={data} since={since} onOpen={onPath} />
)}
<div className="shrink-0 border-line border-t py-2">
<ComputerDock />
</div>
<ComputerFooter />
</SidePanel>
);
}
Expand Down
20 changes: 19 additions & 1 deletion app/src/features/terminal/ComputerDock.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// The dock of a bot's computer (spec 15.1): its browser, its terminal and
// its files, one click apart, at the foot of each of those panels.
// its files, one click apart, at the foot of each of those panels. The
// three share one width (`computer`), so moving between them only changes
// what is inside.

import { Folder, Globe, type LucideIcon, SquareTerminal } from "lucide-react";
import { createContext, useContext } from "react";
Expand All @@ -17,6 +19,22 @@ const PLACES: { place: Place; icon: LucideIcon }[] = [
{ place: "files", icon: Folder },
];

/**
* The dock as the foot of a panel: the same place in the browser, the
* terminal and the files, so it stays still while the panel above changes.
*/
export function ComputerFooter() {
const dock = useContext(Dock);
if (!dock) {
return null;
}
return (
<div data-steady className="shrink-0 border-line border-t py-2">
<ComputerDock />
</div>
);
}

export function ComputerDock({ className = "" }: { className?: string }) {
const t = useT().terminal.dock;
const dock = useContext(Dock);
Expand Down
24 changes: 19 additions & 5 deletions app/src/features/terminal/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { LoaderCircle, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useT } from "../../i18n";
import type { Bot, ToolItem } from "../../lib/protocol.gen";
import { useApi } from "../../store/context";
import { Button } from "../../ui/Button";
import { SidePanel } from "../../ui/SidePanel";
import { commandOf, isCommand } from "../chat/command";
import { useChat } from "../chat/useChat";
import { ComputerDock } from "./ComputerDock";
import { ComputerFooter } from "./ComputerDock";

function Command({ tool }: { tool: ToolItem }) {
const t = useT().terminal;
Expand Down Expand Up @@ -45,14 +46,27 @@ function Command({ tool }: { tool: ToolItem }) {
);
}

/**
* The commands each terminal showed last, so coming back to it (from the
* dock) shows them at once while the chat loads again.
*/
const lastShown = new WeakMap<object, Map<string, { id: string; tool: ToolItem }[]>>();

export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
const t = useT().terminal;
const chat = useChat(bot.id);
const commands = chat.items.flatMap((item) =>
const api = useApi();
const shown = lastShown.get(api) ?? new Map<string, { id: string; tool: ToolItem }[]>();
lastShown.set(api, shown);
const loaded = chat.items.flatMap((item) =>
item.body.kind === "tool" && isCommand(item.body.name)
? [{ id: item.id, tool: item.body }]
: [],
);
const commands = chat.loading && loaded.length === 0 ? (shown.get(bot.id) ?? []) : loaded;
if (!chat.loading) {
shown.set(bot.id, loaded);
}
const screen = useRef<HTMLDivElement>(null);
const last = commands.at(-1);
// Follows the newest command, as a terminal does.
Expand All @@ -65,14 +79,14 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
}, [last?.id, last?.tool.status]);

return (
<SidePanel label={t.panel(bot.name)} name="terminal" defaultWidth={560}>
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600}>
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
<h2 className="font-semibold text-sm">{t.heading}</h2>
<Button variant="ghost" size="sm" icon={X} label={t.close} onClick={onClose} />
</header>
<div className="flex min-h-0 flex-1 flex-col p-3">
<div
className="bot-stage flex min-h-0 flex-1 flex-col gap-3 p-3.5"
className="bot-stage flex min-h-0 flex-1 flex-col p-2.5"
style={{ "--bot": bot.color } as React.CSSProperties}
>
<div className="terminal-window flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
Expand Down Expand Up @@ -102,9 +116,9 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
)}
</div>
</div>
<ComputerDock />
</div>
</div>
<ComputerFooter />
</SidePanel>
);
}
3 changes: 2 additions & 1 deletion app/src/features/terminal/terminal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ describe("the bot's terminal", () => {
expect(dock("Terminal").getAttribute("aria-current")).toBe("page");

fireEvent.click(dock("Browser"));
expect(panel("Scout's browser")).toBeDefined();
// It replaces the terminal in place: its content fades up, nothing slides in.
expect(panel("Scout's browser").className).toContain("panel-swapped");
fireEvent.click(dock("Files"));
expect(panel("Files from Scout")).toBeDefined();
expect(dock("Files").getAttribute("aria-current")).toBe("page");
Expand Down
7 changes: 4 additions & 3 deletions app/src/lib/fakePages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ export interface PageSize {

export const PAGE: PageSize = { width: 1280, height: 800 };

/** The page for a panel with `width` by `height` of room. */
/** The page for a panel with `width` by `height` of room, as the daemon fits it. */
export function fitting(width: number, height: number): PageSize {
const tall = Math.floor((PAGE.width * height) / width);
return { width: PAGE.width, height: Math.min(2000, Math.max(600, tall)) };
const wide = Math.min(1600, Math.max(800, width));
const tall = Math.floor((wide * height) / Math.max(1, width));
return { width: wide, height: Math.min(2000, Math.max(600, tall)) };
}

/**
Expand Down
14 changes: 14 additions & 0 deletions app/src/panels.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@
animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both;
}

/* One panel replacing another (the dock, or a header button while one is
open): it does not slide in again, its content fades up in place. The
dock at its foot stays still. */
@keyframes panel-swap {
from {
opacity: 0;
transform: translateY(4px);
}
}

.panel-swapped > :not([data-steady]):not([role="separator"]) {
animation: panel-swap 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

/* Out of the flow, on the right edge of the chat and its panel. */
.panel-closing {
position: absolute;
Expand Down
7 changes: 3 additions & 4 deletions app/src/terminal.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@
color: var(--terminal-danger);
}

/* The dock sits on the desk, frosted, as a dock does. */
/* The dock at the foot of the panel, the same in the three places. */
.computer-dock {
background: color-mix(in oklab, var(--panel) 55%, transparent);
backdrop-filter: blur(8px);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--panel) 70%, transparent);
background: var(--sunken);
box-shadow: inset 0 0 0 1px var(--line);
}
6 changes: 5 additions & 1 deletion app/src/ui/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,11 @@ export function SidePanel({
} as CSSProperties
}
className={`relative flex shrink-0 flex-col border-line border-l bg-panel ${
motion === "opening" ? "panel-opening" : motion === "closing" ? "panel-closing" : ""
motion === "opening"
? `panel-opening ${from > 0 ? "panel-swapped" : ""}`
: motion === "closing"
? "panel-closing"
: ""
}`}
onAnimationEnd={ended}
>
Expand Down
2 changes: 1 addition & 1 deletion crates/botloftd/src/browser/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ mod tests {
assert!(!fits(&mouse(f64::NAN, 10.0)));
assert!(!fits(&mouse(10.0, 900.0)));
// A taller page takes points further down.
let tall = Viewport::fitting(640, 700);
let tall = Viewport::fitting(640, 1000);
assert!(super::fits(&mouse(10.0, 900.0), tall));
let long = BrowserInput::Text {
text: "x".repeat(TEXT_MAX + 1),
Expand Down
4 changes: 2 additions & 2 deletions crates/botloftd/src/browser/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tracing::debug;
use super::cdp::Cdp;
use super::events::pump;
use super::launch::{self, BrowserProcess};
use super::viewport::{MAX_HEIGHT, Viewport, WIDTH};
use super::viewport::{MAX_HEIGHT, MAX_WIDTH, Viewport};
use super::{BrowserError, agent};

/// Most tabs a browser keeps; past this, the one active longest ago closes.
Expand Down Expand Up @@ -253,7 +253,7 @@ impl Session {
let result = if on {
// Frames come the size of the page, however tall it is.
let params = json!({
"format": "jpeg", "quality": 60, "maxWidth": WIDTH, "maxHeight": MAX_HEIGHT,
"format": "jpeg", "quality": 60, "maxWidth": MAX_WIDTH, "maxHeight": MAX_HEIGHT,
});
self.cdp
.call(Some(session), "Page.startScreencast", params)
Expand Down
Loading
Loading