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
91 changes: 50 additions & 41 deletions app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useT } from "../../i18n";
import type { Bot, Crew } from "../../lib/protocol.gen";
import { routinesOf } from "../../store/app";
import { useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { PanelClosing } from "../../ui/panelMotion";
import { SidePanel } from "../../ui/SidePanel";
import { type Tab, Tabs, tabId } from "../../ui/Tabs";
import { BrowserPanel } from "../browser/BrowserPanel";
Expand All @@ -22,6 +23,7 @@ import { ShowScreen } from "../screens/showScreen";
import { useScreens } from "../screens/useScreens";
import { BotHeader } from "./BotHeader";
import { stateView } from "./BotStateBadge";
import { useFollowBot } from "./useFollowBot";

type Pane = "chat" | "routines";
/** What the panel beside the chat shows. */
Expand All @@ -34,6 +36,19 @@ type Side = "details" | "files" | "browser" | "screens" | null;
export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
const t = useT();
const [side, setSide] = useState<Side>(null);
// The panel on screen: the one open, or the last one while it slides
// closed (spec 15.1).
const [leaving, setLeaving] = useState<Side>(null);
useEffect(() => {
if (side !== null) {
setLeaving(side);
}
}, [side]);
const beside = side ?? leaving;
const closing = useMemo(
() => (side === null && leaving !== null ? { closed: () => setLeaving(null) } : null),
[side, leaving],
);
const [pane, setPane] = useState<Pane>("chat");
const files = useBotFiles(bot);
// What the owner has seen: files newer than this are new to them.
Expand Down Expand Up @@ -90,25 +105,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
setScreenPath(path);
void screens.refresh();
};
// The design area opens by itself when the bot starts drawing a screen,
// once per screen, if nothing else is open beside the chat (spec 22.5).
const drawn = useRef(new Set<string>());
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts with its own screens
useEffect(() => {
drawn.current = new Set();
setScreenPath(null);
}, [bot.id]);
// biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it
useEffect(() => {
const path = screens.writing?.toLowerCase();
if (path && !drawn.current.has(path)) {
drawn.current.add(path);
if (side === null) {
setSide("screens");
setScreenPath(null);
}
}
}, [screens.writing]);
// biome-ignore lint/correctness/useExhaustiveDependencies: another bot's screen is not this bot's
useEffect(() => setScreenPath(null), [bot.id]);
const showBrowser = (options?: { take?: boolean }) => {
if (filesOpen) {
seen();
Expand All @@ -118,6 +116,13 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
setTakeBrowser((count) => count + 1);
}
};
// The panel follows what the bot starts doing (spec 15.1).
useFollowBot({
bot,
side,
writing: screens.writing,
open: (panel) => (panel === "browser" ? showBrowser() : showScreen(null)),
});
const view = stateView(bot, crew.paused, t);
const tabs: Tab<Pane>[] = [
{ id: "chat", label: t.routines.chatTab },
Expand Down Expand Up @@ -161,27 +166,31 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
<BotRoutines bot={bot} />
)}
</div>
{side === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{browserOpen && <BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />}
{screensOpen && (
<ScreensPanel
bot={bot}
data={screens}
path={screenPath}
onPath={setScreenPath}
onClose={() => setSide(null)}
/>
)}
{filesOpen && (
<FilesPanel
bot={bot}
data={files}
since={since}
path={shown}
onPath={setShown}
onClose={toggleFiles}
/>
)}
<PanelClosing.Provider value={closing}>
{beside === "details" && <Details bot={bot} onClose={() => setSide(null)} />}
{beside === "browser" && (
<BrowserPanel bot={bot} take={takeBrowser} onClose={() => setSide(null)} />
)}
{beside === "screens" && (
<ScreensPanel
bot={bot}
data={screens}
path={screenPath}
onPath={setScreenPath}
onClose={() => setSide(null)}
/>
)}
{beside === "files" && (
<FilesPanel
bot={bot}
data={files}
since={since}
path={shown}
onPath={setShown}
onClose={toggleFiles}
/>
)}
</PanelClosing.Provider>
</div>
</section>
);
Expand Down
65 changes: 65 additions & 0 deletions app/src/features/bots/useFollowBot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// The panel beside the chat follows what the bot starts doing (spec 15.1),
// so the owner sees it happen: its browser starting or a request for a
// hand opens the browser (spec 21.8), and a screen it starts writing opens
// the design area (spec 22.5), whatever panel was open. Each opens once per
// start; closing it leaves the button's dot. The browser in the owner's
// hands is never taken away.

import { useEffect, useRef } from "react";
import type { Bot } from "../../lib/protocol.gen";
import { useApp } from "../../store/context";

export type Followed = "browser" | "screens";

export function useFollowBot({
bot,
side,
writing,
open,
}: {
bot: Pick<Bot, "id">;
/** The panel open now, if any. */
side: string | null;
/** The screen the bot is writing now, if any. */
writing: string | null;
open(panel: Followed): void;
}): void {
const browser = useApp((state) => state.browsers[bot.id]);
const browsing = browser?.status === "open" || browser?.status === "starting";
const asking = Boolean(browser?.ask);
const held = browser?.control === "owner";
// What was going on when the bot was opened: a browser left open stays
// behind its button, a request for a hand does not.
const before = useRef<{ browsing: boolean; asking: boolean } | null>(null);
const drawn = useRef(new Set<string>());

// biome-ignore lint/correctness/useExhaustiveDependencies: another bot starts afresh
useEffect(() => {
before.current = null;
drawn.current = new Set();
}, [bot.id]);

// biome-ignore lint/correctness/useExhaustiveDependencies: only a change in what the bot does opens it
useEffect(() => {
const was = before.current ?? { browsing, asking: false };
before.current = { browsing, asking };
if ((asking && !was.asking) || (browsing && !was.browsing)) {
if (side !== "browser") {
open("browser");
}
}
}, [bot.id, browsing, asking]);

// biome-ignore lint/correctness/useExhaustiveDependencies: only a new screen being written opens it
useEffect(() => {
const path = writing?.toLowerCase();
if (!path || drawn.current.has(path)) {
return;
}
drawn.current.add(path);
if (side === "screens" || (held && side === "browser")) {
return;
}
open("screens");
}, [bot.id, writing]);
}
25 changes: 20 additions & 5 deletions app/src/features/browser/browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,33 @@ describe("browser panel", () => {
expect(within(panel()).queryByText("Opened elsewhere.com")).toBeNull();
});

test("the button marks a bot that is browsing while the panel is closed", async () => {
test("opens by itself when the bot starts browsing, and the button marks it once closed", async () => {
const { fake, scout } = crew();
await openScout(fake);
fireEvent.click(screen.getByRole("button", { name: "Show files" }));
expect(toggle().getAttribute("aria-label")).toBe("Show browser");
act(() => {
fake.browser.open(scout.id, "https://example.com/", "Example");
});
await waitFor(() =>
expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser"),
);
// It takes the place of the files, so the owner sees the bot browse.
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull();

fireEvent.click(toggle());
expect(toggle().getAttribute("aria-label")).toBe("Hide browser");
expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser");
// Going on browsing does not open it again.
act(() => {
fake.browser.open(scout.id, "https://example.com/more", "More");
});
expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull();
});

test("a browser already open when the bot is opened stays behind its button", async () => {
const { fake, scout } = crew();
fake.browser.open(scout.id, "https://example.com/", "Example");
await openScout(fake);
expect(toggle().getAttribute("aria-label")).toBe("Show browser: Scout is using the browser");
expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull();
});

test("a closed browser keeps its last page, faded; one that failed says why", async () => {
Expand Down
3 changes: 3 additions & 0 deletions app/src/features/browser/hands.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ describe("the owner's hands in the browser", () => {
const { fake, scout } = crew();
fake.browser.ask(scout.id, "Sign in to your GitHub account");
await openScout(fake);
// The bot needs a hand: its browser opens by itself.
expect(await screen.findByRole("complementary", { name: "Scout's browser" })).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Hide browser" }));
expect(
screen.getByRole("button", { name: "Show browser: Scout needs you in the browser" }),
).toBeDefined();
Expand Down
4 changes: 3 additions & 1 deletion app/src/features/messages/messages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ describe("messages", () => {
expect(await within(messages()).findByText("note 1")).toBeDefined();
expect(within(messages()).getAllByRole("listitem")).toHaveLength(60);
expect(screen.queryByRole("button", { name: "Load older messages" })).toBeNull();
});
// Sixty messages drawn twice: about 2 s alone, past the default 5 s
// when the whole suite runs at once.
}, 15_000);

test("a message that was not delivered can be retried from the title bar", async () => {
const { fake, lead, writer } = crew();
Expand Down
32 changes: 30 additions & 2 deletions app/src/features/screens/screens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,45 @@ describe("design area", () => {
expect(within(panel()).getByText("landing.html")).toBeDefined();
});

test("with another panel open, the button marks the drawing instead", async () => {
test("a screen being written takes the place of another panel, once", async () => {
const { fake, scout } = crew();
await openScout(fake);
fireEvent.click(screen.getByRole("button", { name: "Show files" }));
act(() => {
fake.screens.draft(scout.id, "C:\\Work\\a.html", "<p>");
});
expect(await within(panel()).findByRole("list", { name: "Screens" })).toBeDefined();
expect(screen.queryByRole("complementary", { name: "Files from Scout" })).toBeNull();

// Closed, it stays closed while that screen is written; the button marks it.
fireEvent.click(toggle());
act(() => {
fake.screens.draft(scout.id, "C:\\Work\\a.html", "<p>More");
});
await waitFor(() =>
expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"),
);
expect(screen.queryByRole("complementary", { name: "Scout's screens" })).toBeNull();
});

test("the browser in the owner's hands stays when a screen starts", async () => {
const { fake, scout } = crew();
fake.browser.open(scout.id, "https://example.com/", "Example");
await openScout(fake);
fireEvent.click(screen.getByRole("button", { name: /^Show browser/ }));
await waitFor(() => expect(fake.browser.watching).toBe(scout.id));
act(() => {
fake.browser.frame(scout.id, "AAAA");
});
fireEvent.click(await screen.findByRole("button", { name: "Take control" }));
expect(await screen.findByText("You are in control")).toBeDefined();
act(() => {
fake.screens.draft(scout.id, "C:\\Work\\a.html", "<p>");
});
await waitFor(() =>
expect(toggle().getAttribute("aria-label")).toBe("Show screens: Scout is drawing a screen"),
);
expect(screen.getByRole("complementary", { name: "Files from Scout" })).toBeDefined();
expect(screen.getByRole("complementary", { name: "Scout's browser" })).toBeDefined();
});

test("a Write of an HTML file in the chat opens its screen", async () => {
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
@import "./motion.css";
@import "./mascot.css";
@import "./mascot-flame.css";
@import "./panels.css";

/* Colors are tokens so the light and dark themes swap in one place
(spec 15.3). State colors always go with an icon and a label. */
Expand Down
31 changes: 31 additions & 0 deletions app/src/panels.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* Side panels slide (spec 15.1, panelMotion.ts): the panel widens from the
window's right edge, or from the width of the panel it replaces, while
its content, already at its full width, comes in with it, and the chat
narrows smoothly instead of jumping. Closing runs it back to the edge.
Reduced motion makes both instant (motion.css). */

@keyframes panel-open {
from {
width: var(--panel-from, 0px);
min-width: var(--panel-from, 0px);
}
}

@keyframes panel-close {
to {
width: 0;
min-width: 0;
border-left-width: 0;
}
}

.panel-opening {
overflow: hidden;
animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both;
}

.panel-closing {
overflow: hidden;
pointer-events: none;
animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both;
}
Loading
Loading