Skip to content

Commit e7eb35f

Browse files
authored
Merge pull request #160 from httpsphl/fix/dock-switch
fix(app): smooth switching in the bot's computer, and a page you can see
2 parents 3e15106 + 8184efd commit e7eb35f

16 files changed

Lines changed: 156 additions & 70 deletions

File tree

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { Callout } from "../../ui/Callout";
1616
import { EmptyState } from "../../ui/EmptyState";
1717
import { SidePanel } from "../../ui/SidePanel";
1818
import { BotAvatar } from "../bots/BotAvatar";
19-
import { ComputerDock } from "../terminal/ComputerDock";
19+
import { ComputerFooter } from "../terminal/ComputerDock";
2020
import { AddressBar } from "./AddressBar";
2121
import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars";
2222
import { HandsLayer } from "./HandsLayer";
@@ -34,12 +34,10 @@ const PAD = 12;
3434
* The bot's color around the page, in px: on the sides and top, and below,
3535
* where the pill that says who is in control hangs over its edge.
3636
*/
37-
const STAGE = 14;
38-
const STAGE_BOTTOM = 30;
37+
const STAGE = 10;
38+
const STAGE_BOTTOM = 26;
3939
/** Kept under the page, for the pill and what the bot just did. */
4040
const UNDER = 56;
41-
/** The dock of the bot's computer, on the desk under the page. */
42-
const DOCK = 56;
4341

4442
export function BrowserPanel({
4543
bot,
@@ -70,7 +68,7 @@ export function BrowserPanel({
7068
const [inside, body] = useRoom();
7169
const room = inside && {
7270
width: inside.width - 2 * PAD - 2 * STAGE,
73-
height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER - DOCK,
71+
height: inside.height - 2 * PAD - STAGE - STAGE_BOTTOM - UNDER,
7472
};
7573
useFitPage(bot, room, watched);
7674
// A new tab is blank: the owner says where it goes.
@@ -92,7 +90,7 @@ export function BrowserPanel({
9290
}, [take, watched, status, hands.held, hands.take]);
9391

9492
return (
95-
<SidePanel label={t.panel(bot.name)} name="browser" defaultWidth={560} expanded={expanded}>
93+
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600} expanded={expanded}>
9694
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
9795
<h2 className="flex items-center gap-2 font-semibold text-sm">
9896
{t.heading}
@@ -146,12 +144,7 @@ export function BrowserPanel({
146144
)}
147145
</Callout>
148146
) : status === "closed" && !frame ? (
149-
!windowed && (
150-
<>
151-
<Empty bot={bot} />
152-
<ComputerDock className="mt-auto" />
153-
</>
154-
)
147+
!windowed && <Empty bot={bot} />
155148
) : (
156149
<>
157150
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
@@ -199,7 +192,6 @@ export function BrowserPanel({
199192
)}
200193
</div>
201194
</div>
202-
<ComputerDock className="mt-3" />
203195
</div>
204196
{(hands.held || (live && !ask)) && (
205197
<ControlPill key={String(hands.held)} bot={bot} hands={hands} lesson={lesson} />
@@ -229,6 +221,7 @@ export function BrowserPanel({
229221
</>
230222
)}
231223
</div>
224+
<ComputerFooter />
232225
{lesson.done && <LessonDialog bot={bot} lesson={lesson} />}
233226
</SidePanel>
234227
);

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,25 +74,25 @@ describe("browser panel", () => {
7474
test("gives the page the shape of the room the panel has, and shows it as large as fits", async () => {
7575
const { fake, scout } = crew();
7676
fake.browser.open(scout.id, "https://example.com/", "Example");
77-
// The panel under its address row, as the app measures it: 508 by 624
77+
// The panel under its address row, as the app measures it: 516 by 688
7878
// for the page, once what goes around and under it is set aside.
7979
vi.spyOn(Element.prototype, "clientWidth", "get").mockReturnValue(560);
8080
vi.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(804);
8181
await openScout(fake);
8282
fireEvent.click(toggle());
8383
const resized = () =>
8484
fake.calls.filter((call) => call.method === "browser.resize").map((call) => call.params);
85-
await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 508, height: 624 }]));
86-
// As wide as ever, and as tall as the room's shape asks.
87-
expect(fake.browser.size(scout.id)).toEqual({ width: 1280, height: 1572 });
85+
await waitFor(() => expect(resized()).toEqual([{ botId: scout.id, width: 516, height: 688 }]));
86+
// A narrow room keeps the desktop layout's width, in the room's shape.
87+
expect(fake.browser.size(scout.id)).toEqual({ width: 800, height: 1066 });
8888

8989
act(() => {
9090
fake.browser.frame(scout.id, "AAAA");
9191
});
9292
// The bot's cursor lands where it acted on the taller page.
9393
const screenshot = within(panel()).getByRole("figure", { name: "What Scout sees" });
9494
act(() => {
95-
fake.browser.act(scout.id, "click", { x: 640, y: 1572, label: "More" });
95+
fake.browser.act(scout.id, "click", { x: 400, y: 1066, label: "More" });
9696
});
9797
const cursor = screenshot.querySelector(".bot-cursor") as HTMLElement;
9898
expect(cursor.style.top).toBe("100%");

‎app/src/features/browser/useBrowserView.ts‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// asks for its live frames and hears what the bot does, and stops asking
33
// when the panel closes. The browser's state itself lives in the store.
44

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

@@ -15,17 +15,44 @@ export interface BrowserView {
1515
watched: boolean;
1616
}
1717

18+
/**
19+
* The newest picture of each bot's page, so the panel opening again (from
20+
* the dock) shows it at once while the watch starts again.
21+
*/
22+
const lastFrames = new WeakMap<object, Map<string, BrowserFrame>>();
23+
24+
function framesOf(api: object): Map<string, BrowserFrame> {
25+
let frames = lastFrames.get(api);
26+
if (!frames) {
27+
frames = new Map();
28+
lastFrames.set(api, frames);
29+
}
30+
return frames;
31+
}
32+
1833
export function useBrowserView(bot: Bot, watching: boolean): BrowserView {
1934
const api = useApi();
2035
const connected = useApp((state) => state.connection.kind === "open");
2136
const putBrowser = useApp((state) => state.putBrowser);
22-
const [frame, setFrame] = useState<BrowserFrame | null>(null);
37+
const frames = framesOf(api);
38+
const [frame, setShown] = useState<BrowserFrame | null>(() => frames.get(bot.id) ?? null);
39+
const setFrame = useCallback(
40+
(next: BrowserFrame | null | ((current: BrowserFrame | null) => BrowserFrame | null)) =>
41+
setShown((current) => {
42+
const value = typeof next === "function" ? next(current) : next;
43+
if (value) {
44+
frames.set(value.botId, value);
45+
}
46+
return value;
47+
}),
48+
[frames],
49+
);
2350
const [action, setAction] = useState<BrowserAction | null>(null);
2451
const [watched, setWatched] = useState(false);
2552

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

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

6491
return { frame, action, watched };
6592
}

‎app/src/features/files/FilesPanel.tsx‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { Button } from "../../ui/Button";
1111
import { Callout } from "../../ui/Callout";
1212
import { EmptyState } from "../../ui/EmptyState";
1313
import { SidePanel } from "../../ui/SidePanel";
14-
import { ComputerDock } from "../terminal/ComputerDock";
14+
import { ComputerFooter } from "../terminal/ComputerDock";
1515
import { FilePreview } from "./FilePreview";
1616
import { fileKind, kindIcon } from "./kinds";
1717
import type { BotFiles } from "./useBotFiles";
@@ -42,7 +42,7 @@ export function FilesPanel({
4242
(described?.path === path ? described : undefined);
4343

4444
return (
45-
<SidePanel label={t.panel(bot.name)} name="files" defaultWidth={416}>
45+
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600}>
4646
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
4747
<h2 className="font-semibold text-sm">{t.heading}</h2>
4848
<div className="flex items-center gap-0.5">
@@ -63,9 +63,7 @@ export function FilesPanel({
6363
) : (
6464
<List bot={bot} data={data} since={since} onOpen={onPath} />
6565
)}
66-
<div className="shrink-0 border-line border-t py-2">
67-
<ComputerDock />
68-
</div>
66+
<ComputerFooter />
6967
</SidePanel>
7068
);
7169
}

‎app/src/features/terminal/ComputerDock.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// The dock of a bot's computer (spec 15.1): its browser, its terminal and
2-
// its files, one click apart, at the foot of each of those panels.
2+
// its files, one click apart, at the foot of each of those panels. The
3+
// three share one width (`computer`), so moving between them only changes
4+
// what is inside.
35

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

22+
/**
23+
* The dock as the foot of a panel: the same place in the browser, the
24+
* terminal and the files, so it stays still while the panel above changes.
25+
*/
26+
export function ComputerFooter() {
27+
const dock = useContext(Dock);
28+
if (!dock) {
29+
return null;
30+
}
31+
return (
32+
<div data-steady className="shrink-0 border-line border-t py-2">
33+
<ComputerDock />
34+
</div>
35+
);
36+
}
37+
2038
export function ComputerDock({ className = "" }: { className?: string }) {
2139
const t = useT().terminal.dock;
2240
const dock = useContext(Dock);

‎app/src/features/terminal/TerminalPanel.tsx‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { LoaderCircle, X } from "lucide-react";
77
import { useEffect, useRef } from "react";
88
import { useT } from "../../i18n";
99
import type { Bot, ToolItem } from "../../lib/protocol.gen";
10+
import { useApi } from "../../store/context";
1011
import { Button } from "../../ui/Button";
1112
import { SidePanel } from "../../ui/SidePanel";
1213
import { commandOf, isCommand } from "../chat/command";
1314
import { useChat } from "../chat/useChat";
14-
import { ComputerDock } from "./ComputerDock";
15+
import { ComputerFooter } from "./ComputerDock";
1516

1617
function Command({ tool }: { tool: ToolItem }) {
1718
const t = useT().terminal;
@@ -45,14 +46,27 @@ function Command({ tool }: { tool: ToolItem }) {
4546
);
4647
}
4748

49+
/**
50+
* The commands each terminal showed last, so coming back to it (from the
51+
* dock) shows them at once while the chat loads again.
52+
*/
53+
const lastShown = new WeakMap<object, Map<string, { id: string; tool: ToolItem }[]>>();
54+
4855
export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
4956
const t = useT().terminal;
5057
const chat = useChat(bot.id);
51-
const commands = chat.items.flatMap((item) =>
58+
const api = useApi();
59+
const shown = lastShown.get(api) ?? new Map<string, { id: string; tool: ToolItem }[]>();
60+
lastShown.set(api, shown);
61+
const loaded = chat.items.flatMap((item) =>
5262
item.body.kind === "tool" && isCommand(item.body.name)
5363
? [{ id: item.id, tool: item.body }]
5464
: [],
5565
);
66+
const commands = chat.loading && loaded.length === 0 ? (shown.get(bot.id) ?? []) : loaded;
67+
if (!chat.loading) {
68+
shown.set(bot.id, loaded);
69+
}
5670
const screen = useRef<HTMLDivElement>(null);
5771
const last = commands.at(-1);
5872
// Follows the newest command, as a terminal does.
@@ -65,14 +79,14 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
6579
}, [last?.id, last?.tool.status]);
6680

6781
return (
68-
<SidePanel label={t.panel(bot.name)} name="terminal" defaultWidth={560}>
82+
<SidePanel label={t.panel(bot.name)} name="computer" defaultWidth={600}>
6983
<header className="flex h-11 shrink-0 items-center justify-between border-line border-b pr-1.5 pl-4">
7084
<h2 className="font-semibold text-sm">{t.heading}</h2>
7185
<Button variant="ghost" size="sm" icon={X} label={t.close} onClick={onClose} />
7286
</header>
7387
<div className="flex min-h-0 flex-1 flex-col p-3">
7488
<div
75-
className="bot-stage flex min-h-0 flex-1 flex-col gap-3 p-3.5"
89+
className="bot-stage flex min-h-0 flex-1 flex-col p-2.5"
7690
style={{ "--bot": bot.color } as React.CSSProperties}
7791
>
7892
<div className="terminal-window flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl">
@@ -102,9 +116,9 @@ export function TerminalPanel({ bot, onClose }: { bot: Bot; onClose(): void }) {
102116
)}
103117
</div>
104118
</div>
105-
<ComputerDock />
106119
</div>
107120
</div>
121+
<ComputerFooter />
108122
</SidePanel>
109123
);
110124
}

‎app/src/features/terminal/terminal.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ describe("the bot's terminal", () => {
6363
expect(dock("Terminal").getAttribute("aria-current")).toBe("page");
6464

6565
fireEvent.click(dock("Browser"));
66-
expect(panel("Scout's browser")).toBeDefined();
66+
// It replaces the terminal in place: its content fades up, nothing slides in.
67+
expect(panel("Scout's browser").className).toContain("panel-swapped");
6768
fireEvent.click(dock("Files"));
6869
expect(panel("Files from Scout")).toBeDefined();
6970
expect(dock("Files").getAttribute("aria-current")).toBe("page");

‎app/src/lib/fakePages.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ export interface PageSize {
1010

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

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

1920
/**

‎app/src/panels.css‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@
2121
animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both;
2222
}
2323

24+
/* One panel replacing another (the dock, or a header button while one is
25+
open): it does not slide in again, its content fades up in place. The
26+
dock at its foot stays still. */
27+
@keyframes panel-swap {
28+
from {
29+
opacity: 0;
30+
transform: translateY(4px);
31+
}
32+
}
33+
34+
.panel-swapped > :not([data-steady]):not([role="separator"]) {
35+
animation: panel-swap 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
36+
}
37+
2438
/* Out of the flow, on the right edge of the chat and its panel. */
2539
.panel-closing {
2640
position: absolute;

‎app/src/terminal.css‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,8 @@
3838
color: var(--terminal-danger);
3939
}
4040

41-
/* The dock sits on the desk, frosted, as a dock does. */
41+
/* The dock at the foot of the panel, the same in the three places. */
4242
.computer-dock {
43-
background: color-mix(in oklab, var(--panel) 55%, transparent);
44-
backdrop-filter: blur(8px);
45-
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--panel) 70%, transparent);
43+
background: var(--sunken);
44+
box-shadow: inset 0 0 0 1px var(--line);
4645
}

0 commit comments

Comments
 (0)