Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.

- [Scroll-to element primitive](docs/scroll-to.md) across CLI, Extension and DSH Plugin,
with ancestor-clipped visible bounds, iframe support and cooperative cancellation
- Extension: virtual agent cursor that glides to the target and ripples on click before the
real input is dispatched, plus a current-action line on the control pill naming the tool
and its target
- User takeover across CLI, Extension and daemon: "Take over" / "Return to agent" buttons with
an optional note for the agent, agent input blocked while the user holds control, and
`bsk session status` / `bsk session wait-control` for the agent to observe and wait out the hold

## [0.2.1] - 2026-09-09

Expand Down
201 changes: 190 additions & 11 deletions apps/extension/src/content/ControlOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,83 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import type { OverlayMode } from "@/lib/overlay-bridge";
import logoUrl from "../../assets/logo.png";

/** The tool the agent is running right now, as narrated by the pill. */
export interface ControlAction {
/** Wire tool name, e.g. `tool.click`. */
tool: string;
/** `@ref`, selector, url or key, already truncated by the background. */
target?: string;
}

export interface ControlOverlayProps {
visible: boolean;
/**
* Authoritative control mode. `paused` renders the "you are in control"
* pill: no blocker, no glow, a note field and a return button.
*/
mode: OverlayMode;
interrupting: boolean;
automationBypass: boolean;
/** Current agent action, or null while idle. */
currentAction?: ControlAction | null;
onInterrupt: () => void;
onReturnControl: (note: string) => void;
}

/**
* Map a wire tool name to its `controlOverlay.action.*` suffix. Anything we do
* not narrate falls back to the generic "working" copy.
*/
const ACTION_KEY_BY_TOOL: Record<string, string> = {
"tool.click": "click",
"tool.dblclick": "click",
"tool.hover": "hover",
"tool.fill": "fill",
"tool.press": "press",
"tool.navigate": "navigate",
"tool.navigate_back": "navigate",
"tool.navigate_forward": "navigate",
"tool.scroll": "scroll",
"tool.scroll_to": "scroll",
"tool.wheel": "scroll",
"tool.select": "select",
"tool.upload": "upload",
"tool.download": "download",
"tool.evaluate": "evaluate",
"tool.reload": "reload",
};

/**
* Cap on the note the user can hand back with the page. The daemon cuts
* anything longer, and the note lives in its interrupt registry until a waiter
* consumes it, so keep the field well inside that budget.
*/
export const NOTE_MAX_CHARS = 1000;

const PILL_FONT =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';

function actionKey(tool: string): string {
return ACTION_KEY_BY_TOOL[tool] ?? "working";
}

export function ControlOverlay({
visible,
mode,
interrupting,
automationBypass,
currentAction,
onInterrupt,
onReturnControl,
}: ControlOverlayProps) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
const [note, setNote] = useState("");
const blockerRef = useRef<HTMLDivElement>(null);
const paused = mode === "paused";

useEffect(() => {
if (visible) {
Expand All @@ -28,6 +87,11 @@ export function ControlOverlay({
setShow(false);
}, [visible]);

// A fresh hold starts with an empty note rather than the previous one.
useEffect(() => {
if (!paused) setNote("");
}, [paused]);

useEffect(() => {
const blocker = blockerRef.current;
if (!blocker) return;
Expand All @@ -45,7 +109,8 @@ export function ControlOverlay({
}, [automationBypass]);

useEffect(() => {
if (!visible || automationBypass) return;
// The paused pill never blocks the page — the user is operating it.
if (!visible || paused || automationBypass) return;
const stopScroll = (event: WheelEvent | TouchEvent) => {
event.preventDefault();
event.stopPropagation();
Expand All @@ -56,11 +121,99 @@ export function ControlOverlay({
window.removeEventListener("wheel", stopScroll, { capture: true });
window.removeEventListener("touchmove", stopScroll, { capture: true });
};
}, [visible, automationBypass]);
}, [visible, paused, automationBypass]);

if (!visible) return null;

const pointerEvents = automationBypass ? "none" : "auto";
const action = currentAction ?? null;

if (paused) {
return (
<div
data-slot="control-overlay-pill"
data-mode="paused"
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 8,
backgroundColor: "#fff",
borderRadius: 9999,
padding: "10px 10px 10px 20px",
boxShadow: "0 8px 32px rgba(124,45,18,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
fontFamily: PILL_FONT,
}}
>
<img
src={logoUrl}
alt="browser-skill"
style={{ width: 24, height: 24, borderRadius: 4, flexShrink: 0 }}
/>
<span
style={{
fontSize: 15,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{t("controlOverlay.pausedStatus")}
</span>
<input
type="text"
data-slot="control-overlay-note"
value={note}
maxLength={NOTE_MAX_CHARS}
placeholder={t("controlOverlay.notePlaceholder")}
onChange={(event) => setNote(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
onReturnControl(note);
}}
style={{
width: 200,
border: "1px solid #e5e7eb",
borderRadius: 9999,
padding: "8px 14px",
fontSize: 14,
color: "#333",
outline: "none",
fontFamily: PILL_FONT,
}}
/>
<button
type="button"
data-slot="control-overlay-return"
onClick={() => onReturnControl(note)}
style={{
pointerEvents: "auto",
border: "none",
borderRadius: 9999,
padding: "8px 20px",
fontSize: 15,
fontWeight: 600,
color: "#fff",
backgroundColor: "#16a34a",
cursor: "pointer",
whiteSpace: "nowrap",
lineHeight: 1,
}}
>
{t("controlOverlay.returnControl")}
</button>
</div>
);
}

return (
<>
Expand Down Expand Up @@ -114,6 +267,7 @@ export function ControlOverlay({

<div
data-slot="control-overlay-pill"
data-mode="control"
style={{
position: "fixed",
bottom: 32,
Expand All @@ -130,8 +284,7 @@ export function ControlOverlay({
boxShadow: "0 8px 32px rgba(124,45,18,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
fontFamily: PILL_FONT,
}}
>
<img
Expand All @@ -141,14 +294,40 @@ export function ControlOverlay({
/>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
display: "flex",
flexDirection: "column",
gap: 1,
minWidth: 0,
}}
>
{t("controlOverlay.status")}
<span
style={{
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{t("controlOverlay.status")}
</span>
<span
data-slot="control-overlay-action"
style={{
fontSize: 13,
color: "#6b7280",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
userSelect: "none",
}}
>
{action
? `${t(`controlOverlay.action.${actionKey(action.tool)}` as "controlOverlay.action.working")}${
action.target ? ` ${action.target}` : ""
}`
: t("controlOverlay.action.idle")}
</span>
</span>
<button
type="button"
Expand All @@ -175,7 +354,7 @@ export function ControlOverlay({
}}
>
<RiStopCircleLine size={18} color="#fff" />
{interrupting ? t("controlOverlay.interrupting") : t("controlOverlay.interrupt")}
{interrupting ? t("controlOverlay.takingOver") : t("controlOverlay.takeOver")}
</button>
</div>
</>
Expand Down
Loading