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
44 changes: 44 additions & 0 deletions app/src/browser-stage.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* The bot's browser sits on a desk of the bot's color (spec 21.8), so the
panel reads as that bot's computer. The color is mixed with the panel,
so it is soft in the light theme and deep in the dark one. */

.bot-stage {
--stage: var(--bot, var(--accent));
border-radius: 18px;
background:
radial-gradient(
120% 90% at 15% 0%,
color-mix(in oklab, var(--stage) 55%, white 18%) 0%,
transparent 65%
),
linear-gradient(
160deg,
color-mix(in oklab, var(--stage) 78%, var(--panel)) 0%,
color-mix(in oklab, var(--stage) 52%, var(--panel)) 100%
);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--stage) 40%, transparent);
transition: filter 500ms ease;
}

/* The browser closed: the desk fades with the last picture. */
.bot-stage[data-dim] {
filter: saturate(0.25) brightness(0.92);
}

/* The page is a window on the desk. A filter, so the ring of the owner's
hands (a box shadow) stays. */
.bot-stage figure {
filter: drop-shadow(0 1px 2px rgb(0 0 0 / 0.18)) drop-shadow(0 10px 24px rgb(0 0 0 / 0.28));
}

/* The pill drops onto the desk's edge when the control changes hands. */
.control-pill {
animation: control-pill-in 320ms cubic-bezier(0.2, 0.9, 0.3, 1.2);
}

@keyframes control-pill-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.96);
}
}
111 changes: 68 additions & 43 deletions app/src/features/browser/BrowserPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// or open it in a window of its own to sign in (spec 21.11).

import { Globe, LoaderCircle, Maximize2, Minimize2, Moon, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { type CSSProperties, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
Expand All @@ -17,7 +17,7 @@ import { EmptyState } from "../../ui/EmptyState";
import { SidePanel } from "../../ui/SidePanel";
import { BotAvatar } from "../bots/BotAvatar";
import { AddressBar } from "./AddressBar";
import { AskCallout, HeldBar, TakeBar } from "./HandsBars";
import { AskCallout, ControlPill, HeldNotes, TakeNotes } from "./HandsBars";
import { HandsLayer } from "./HandsLayer";
import { LiveView, useCaption } from "./LiveView";
import { TabStrip } from "./TabStrip";
Expand All @@ -28,8 +28,14 @@ import { WindowBar } from "./WindowBar";

/** Around the page in the panel, in px. */
const PAD = 12;
/** Kept under the page, for what the bot just did and the button to take it. */
const UNDER = 80;
/**
* 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;
/** Kept under the page, for the pill and what the bot just did. */
const UNDER = 56;

export function BrowserPanel({
bot,
Expand Down Expand Up @@ -58,8 +64,8 @@ export function BrowserPanel({
// Notices that come and go are not counted: they never resize the page.
const [inside, body] = useRoom();
const room = inside && {
width: inside.width - 2 * PAD,
height: inside.height - 2 * PAD - UNDER,
width: inside.width - 2 * PAD - 2 * STAGE,
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 Down Expand Up @@ -118,7 +124,7 @@ export function BrowserPanel({
)}
<div
ref={body}
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
className="@container flex min-h-0 flex-1 flex-col overflow-y-auto"
style={{ padding: PAD }}
>
{windowed && <WindowBar bot={bot} />}
Expand All @@ -139,47 +145,57 @@ export function BrowserPanel({
) : (
<>
{live && ask && !hands.held && <AskCallout bot={bot} task={ask} hands={hands} />}
{hands.held && (
<HeldBar
bot={bot}
task={ask}
tabs={state?.tabs.length ?? 0}
focused={focused}
hands={hands}
/>
)}
<div className="min-h-40 flex-1" style={{ containerType: "size" }}>
<div className="relative">
<LiveView
bot={bot}
frame={frame}
action={action}
dim={status === "closed"}
held={hands.held}
>
{hands.held && frame && (
<HandsLayer
label={t.hands.screen(bot.name)}
keysLabel={t.hands.typing}
width={frame.width}
height={frame.height}
send={hands.send}
onFocus={setFocused}
<div
className="bot-stage flex min-h-40 flex-1 flex-col"
data-dim={status === "closed" || undefined}
style={
{
"--bot": bot.color,
padding: `${STAGE}px ${STAGE}px ${STAGE_BOTTOM}px`,
} as CSSProperties
}
>
<div
className="grid min-h-0 flex-1 place-items-center"
style={{ containerType: "size" }}
>
<div className="relative">
<LiveView
bot={bot}
frame={frame}
action={action}
dim={status === "closed"}
held={hands.held}
>
{hands.held && frame && (
<HandsLayer
label={t.hands.screen(bot.name)}
keysLabel={t.hands.typing}
width={frame.width}
height={frame.height}
send={hands.send}
onFocus={setFocused}
/>
)}
</LiveView>
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
title={
windowed ? t.window.title : status === "closed" ? t.closed : t.starting
}
body={status === "closed" && !windowed ? t.closedBody : null}
/>
)}
</LiveView>
{(status !== "open" || !frame) && (
<Overlay
busy={status === "starting" || status === "open"}
title={windowed ? t.window.title : status === "closed" ? t.closed : t.starting}
body={status === "closed" && !windowed ? t.closedBody : null}
/>
)}
</div>
</div>
</div>
{(hands.held || (live && !ask)) && (
<ControlPill key={String(hands.held)} bot={bot} hands={hands} />
)}
{status === "open" && !hands.held && (
// Its place is kept while empty, so the page never moves for it.
<p aria-live="polite" className="mt-3 h-5 text-ink-soft text-sm">
<p aria-live="polite" className="mt-2 flex h-5 justify-center text-ink-soft text-sm">
{caption && (
<span key={action?.at} className="flex animate-rise items-center gap-2">
<BotAvatar color={bot.color} size={18} mood="working" />
Expand All @@ -188,7 +204,16 @@ export function BrowserPanel({
)}
</p>
)}
{live && !hands.held && !ask && <TakeBar bot={bot} hands={hands} />}
{hands.held && (
<HeldNotes
bot={bot}
task={ask}
tabs={state?.tabs.length ?? 0}
focused={focused}
hands={hands}
/>
)}
{live && !hands.held && !ask && <TakeNotes bot={bot} hands={hands} />}
</>
)}
</div>
Expand Down
156 changes: 97 additions & 59 deletions app/src/features/browser/HandsBars.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
// The owner's controls in the browser panel (spec 21.10): taking the
// browser, the bot's request for a hand, and the bar that says the owner
// has it, with the button that gives it back. Each offers a window of its
// own too, for sites that refuse to sign in here (spec 21.11).
// The owner's controls in the browser panel (spec 21.10): the bot's request
// for a hand, and the pill under the live screen that says who is in
// control, with the button that takes the browser or gives it back. Under
// it, what the owner should know, and a window of its own for sites that
// refuse to sign in here (spec 21.11).

import { Check, Hand, Keyboard, MousePointerClick } from "lucide-react";
import { Check, ChevronDown, Hand, Keyboard, MousePointerClick } from "lucide-react";
import { useState } from "react";
import { useT } from "../../i18n";
import type { Bot } from "../../lib/protocol.gen";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { BotAvatar, moodOf } from "../bots/BotAvatar";
import type { Hands } from "./useHands";
import { WindowButton } from "./WindowBar";

Expand Down Expand Up @@ -36,8 +39,84 @@ export function AskCallout({ bot, task, hands }: { bot: Bot; task: string; hands
);
}

/** The owner has the browser. */
export function HeldBar({
/**
* Who is in control, hanging from the bottom of the live screen: the bot,
* with the button to take it, or the owner, with the one to give it back.
*/
export function ControlPill({ bot, hands }: { bot: Bot; hands: Hands }) {
const t = useT().browser.hands;
const held = hands.held;
return (
<div
role="status"
className={`control-pill relative z-10 mx-auto -mt-5 flex max-w-full items-center gap-2 rounded-full border bg-panel py-1 pr-1 pl-2 shadow-lift ${
held ? "border-accent" : "border-line"
}`}
>
{held ? (
<Hand aria-hidden size={16} className="ml-1 shrink-0 text-accent" />
) : (
<BotAvatar color={bot.color} size={22} mood={moodOf(bot)} />
)}
<span className="min-w-0 shrink-[2] truncate font-medium text-sm @max-xs:sr-only">
{held ? t.holding : t.botHas(bot.name)}
</span>
<Button
variant={held ? "primary" : "secondary"}
size="sm"
icon={held ? Check : Hand}
disabled={hands.busy}
onClick={() => void (held ? hands.release() : hands.take())}
className="min-w-0 shrink! rounded-full! px-3!"
>
<span className="truncate">{held ? t.giveBack(bot.name) : t.take}</span>
</Button>
</div>
);
}

/**
* The window button, and why to take the browser or use a window, folded:
* an owner who knows it does not read it every time.
*/
function WindowAndWhy({ hands, why }: { hands: Hands; why: string }) {
const t = useT().browser.hands;
const [open, setOpen] = useState(false);
return (
<>
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1">
<WindowButton hands={hands} />
<button
type="button"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="flex h-7 items-center gap-1 rounded-lg px-2 text-muted text-xs transition-colors hover:bg-sunken hover:text-ink"
>
{t.howItWorks}
<ChevronDown
aria-hidden
size={12}
className={`transition-transform ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
{open && <p className="max-w-md animate-rise text-muted text-xs">{why}</p>}
</>
);
}

/** Under the pill, while the bot has the browser: how to take it. */
export function TakeNotes({ bot, hands }: { bot: Bot; hands: Hands }) {
const t = useT().browser;
return (
<div className="mt-2 flex flex-col items-center gap-2 text-center">
<WindowAndWhy hands={hands} why={`${t.hands.takeWhy(bot.name)} ${t.window.why(bot.name)}`} />
</div>
);
}

/** Under the pill, while the owner has the browser. */
export function HeldNotes({
bot,
task,
tabs,
Expand All @@ -54,59 +133,18 @@ export function HeldBar({
const t = useT().browser;
const Hint = focused ? Keyboard : MousePointerClick;
return (
<div
role="status"
className="mb-3 animate-rise rounded-xl border border-accent/45 bg-accent/8 px-3.5 py-3"
>
<div className="flex items-start gap-2.5">
<Hand aria-hidden size={16} className="mt-0.5 shrink-0 text-accent" />
<div className="min-w-0 flex-1 text-sm">
<p className="font-semibold">{t.hands.holding}</p>
<p className="line-clamp-2 text-ink-soft text-xs" data-selectable>
{task ? t.help.asks(bot.name, task) : t.hands.waits(bot.name)}
</p>
{tabs > 1 && <p className="text-muted text-xs">{t.hands.leaves(bot.name)}</p>}
</div>
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2 pl-6.5">
<Button
variant="primary"
size="sm"
icon={Check}
disabled={hands.busy}
onClick={() => void hands.release()}
>
{t.hands.giveBack(bot.name)}
</Button>
<WindowButton hands={hands} />
<span className="flex items-center gap-1.5 text-muted text-xs">
<Hint aria-hidden size={12} className="shrink-0" />
{focused ? t.hands.typing : t.hands.clickToType}
</span>
</div>
<p className="mt-2 pl-6.5 text-muted text-xs">{t.window.hint}</p>
</div>
);
}

/** Under the live screen: the owner may take the browser at any time. */
export function TakeBar({ bot, hands }: { bot: Bot; hands: Hands }) {
const t = useT().browser;
return (
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
<Button
variant="secondary"
size="sm"
icon={Hand}
disabled={hands.busy}
onClick={() => void hands.take()}
>
{t.hands.take}
</Button>
<WindowButton hands={hands} />
<p className="min-w-0 basis-full text-muted text-xs">
{t.hands.takeWhy(bot.name)} {t.window.why(bot.name)}
<div className="mt-2 flex flex-col items-center gap-1.5 text-center text-xs">
<p className="flex items-center gap-1.5 font-medium text-ink-soft">
<Hint aria-hidden size={12} className="shrink-0" />
{focused ? t.hands.typing : t.hands.clickToType}
</p>
<p className="line-clamp-2 max-w-md text-ink-soft" data-selectable>
{task ? t.help.asks(bot.name, task) : t.hands.waits(bot.name)}
</p>
{tabs > 1 && <p className="text-muted">{t.hands.leaves(bot.name)}</p>}
<div className="mt-1 flex flex-col items-center gap-2">
<WindowAndWhy hands={hands} why={t.window.hint} />
</div>
</div>
);
}
Loading
Loading