Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -0,0 +1,280 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiCloseLine } from "@remixicon/react";
import { useEffect, useRef, useState, type TransitionEvent } from "react";
import logoUrl from "../../assets/logo.png";
export interface BorrowRequestData {
id: string;
isActiveTab: boolean;
tabTitle: string;
timeoutMs: number;
onAllow: () => void;
onDeny: () => void;
}
interface Props {
requests: BorrowRequestData[];
}
const EXIT_ANIMATION_MS = 150;
export function BorrowConfirmationOverlay({ requests }: Props) {
const activeRequest = requests.find((r) => r.isActiveTab);
const inactiveRequests = requests.filter((r) => !r.isActiveTab);
return (
<>
{activeRequest && (
<BorrowBackdrop>
<BorrowRequestItem key={activeRequest.id} request={activeRequest} isModal={true} />
</BorrowBackdrop>
)}
{inactiveRequests.length > 0 && (
<BorrowToastStack>
{inactiveRequests.map((req) => (
<BorrowRequestItem key={req.id} request={req} isModal={false} />
))}
</BorrowToastStack>
)}
</>
);
}
function BorrowBackdrop({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="borrow-confirmation-modal-backdrop"
className="fixed inset-0 z-[2147483646] flex items-center justify-center bg-black/40 backdrop-blur-sm"
>
{children}
</div>
);
}
function BorrowToastStack({ children }: { children: React.ReactNode }) {
return (
<div
data-slot="borrow-confirmation-toast-stack"
className="fixed top-4 right-4 z-[2147483647] flex flex-col gap-3"
>
{children}
</div>
);
}
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
const { t } = useTranslation("extension");
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
const [exiting, setExiting] = useState(false);
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
const allowedRef = useRef(false);
const onAllowRef = useRef(request.onAllow);
const onDenyRef = useRef(request.onDeny);
onAllowRef.current = request.onAllow;
onDenyRef.current = request.onDeny;
useEffect(() => {
if (secondsLeft <= 0) {
if (!allowedRef.current) {
setAwaitingAutoAllow(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
function triggerAllow() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
setExiting(true);
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
}
function handleAllow() {
triggerAllow();
}
function handleDeny() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
setExiting(true);
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
}
const progress = secondsLeft / totalSeconds;
const truncatedTitle =
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
const cardClass = `transition-opacity duration-150 ease-out ${exiting ? "opacity-0" : "opacity-100"}`;
if (isModal) {
return (
<div
data-slot="borrow-confirmation-modal"
className={`flex w-[400px] flex-col gap-4 rounded-2xl bg-white px-7 pb-6 pt-7 shadow-[0_25px_50px_rgba(124,45,18,0.1)] ${cardClass}`}
>
<div className="flex items-center gap-2.5">
<img src={logoUrl} alt="browser-skill" className="size-6 rounded" />
<span className="text-base font-semibold text-[#111]">
{t("borrowConfirmation.title")}
</span>
</div>
<p className="m-0 text-sm leading-relaxed text-[#555]">{t("borrowConfirmation.body")}</p>
<div className="flex items-center justify-center gap-3 py-2">
<CountdownRing
progress={progress}
seconds={secondsLeft}
onProgressTransitionEnd={(propertyName) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (propertyName !== "stroke-dashoffset") return;
triggerAllow();
}}
/>
<span className="text-[13px] text-gray-500">
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
</span>
</div>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
</div>
);
}
return (
<div
data-slot="borrow-confirmation-toast"
className={`flex w-80 flex-col rounded-xl border border-[#ffedd5] bg-[#FFFBF7] p-4 pb-3.5 shadow-[0_10px_40px_rgba(124,45,18,0.1)] ${cardClass}`}
>
<BorrowToastHeader onDeny={handleDeny} />
<p className="mb-2.5 text-[13px] leading-relaxed text-[#555]">
{t("borrowConfirmation.targetTab")}
<br />
<span className="font-medium text-[#333]">&quot;{truncatedTitle}&quot;</span>
</p>
<BorrowProgressBar
progress={progress}
onProgressTransitionEnd={(event) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (event.propertyName !== "width") return;
triggerAllow();
}}
/>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
</div>
);
}
function BorrowToastHeader({ onDeny }: { onDeny: () => void }) {
const { t } = useTranslation("extension");
return (
<div className="mb-2 flex items-center gap-2">
<img src={logoUrl} alt="browser-skill" className="size-[18px] rounded" />
<span className="flex-1 text-sm font-semibold text-[#111]">
{t("borrowConfirmation.titleInactive")}
</span>
<button
type="button"
data-slot="borrow-confirmation-deny-button"
onClick={onDeny}
className="cursor-pointer border-0 bg-transparent p-0.5 text-gray-500"
aria-label={t("borrowConfirmation.deny")}
>
<RiCloseLine size={16} />
</button>
</div>
);
}
function BorrowProgressBar({
progress,
onProgressTransitionEnd,
}: {
progress: number;
onProgressTransitionEnd?: (event: TransitionEvent<HTMLDivElement>) => void;
}) {
return (
<div className="mb-3 h-1 overflow-hidden rounded-sm bg-gray-100">
<div
className="h-full rounded-sm bg-orange-500 transition-[width] duration-1000 ease-linear"
style={{ width: `${progress * 100}%` }}
onTransitionEnd={onProgressTransitionEnd}
/>
</div>
);
}
function ActionButtons({
onDeny,
onAllow,
gapClass = "gap-2.5",
}: {
onDeny: () => void;
onAllow: () => void;
gapClass?: string;
}) {
const { t } = useTranslation("extension");
return (
<div className={`flex justify-end ${gapClass}`}>
<button
type="button"
data-slot="borrow-confirmation-deny-button"
onClick={onDeny}
className="cursor-pointer rounded-lg border border-gray-200 bg-transparent px-4 py-2 text-[13px] font-medium text-gray-600"
>
{t("borrowConfirmation.deny")}
</button>
<button
type="button"
data-slot="borrow-confirmation-allow-button"
onClick={onAllow}
className="cursor-pointer rounded-lg border-0 bg-orange-500 px-4 py-2 text-[13px] font-semibold text-white"
>
{t("borrowConfirmation.allow")}
</button>
</div>
);
}
function CountdownRing({
progress,
seconds,
onProgressTransitionEnd,
}: {
progress: number;
seconds: number;
onProgressTransitionEnd?: (propertyName: string) => void;
}) {
const r = 28;
const circ = 2 * Math.PI * r;
const offset = circ * (1 - progress);
return (
<svg width={72} height={72} className="-rotate-90" aria-hidden>
<circle cx={36} cy={36} r={r} fill="none" stroke="#f3f4f6" strokeWidth={5} />
<circle
cx={36}
cy={36}
r={r}
fill="none"
stroke="#f97316"
strokeWidth={5}
strokeDasharray={circ}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-1000 ease-linear"
onTransitionEnd={(event) => onProgressTransitionEnd?.(event.propertyName)}
/>
<text
x={36}
y={36}
textAnchor="middle"
dominantBaseline="central"
className="fill-[#111] text-xl font-bold"
style={{ transform: "rotate(90deg)", transformOrigin: "36px 36px" }}
>
{seconds}
</text>
</svg>
);
}
@@ -0,0 +1,149 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useState } from "react";
import logoUrl from "../../assets/logo.png";
export interface ControlOverlayProps {
visible: boolean;
interrupting: boolean;
automationBypass: boolean;
onInterrupt: () => void;
}
export function ControlOverlay({
visible,
interrupting,
automationBypass,
onInterrupt,
}: ControlOverlayProps) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
useEffect(() => {
if (visible) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [visible]);
if (!visible) return null;
const pointerEvents = automationBypass ? "none" : "auto";
return (
<>
<style>{`
@keyframes bsk-breathe {
0%, 100% {
box-shadow: inset 0 0 20px 4px rgba(249,115,22,0.25);
}
50% {
box-shadow: inset 0 0 40px 8px rgba(249,115,22,0.5);
}
}
`}</style>
<div
data-slot="control-overlay"
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents: "none",
animation: "bsk-breathe 3s ease-in-out infinite",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
onPointerDown={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
onClick={(event) => {
if (automationBypass) return;
event.preventDefault();
event.stopPropagation();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
pointerEvents,
background: "transparent",
opacity: show ? 1 : 0,
transition: "opacity 300ms ease-out",
}}
/>
<div
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents,
display: "flex",
alignItems: "center",
gap: 12,
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:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
}}
>
<img
src={logoUrl}
alt="browser-skill"
style={{ width: 24, height: 24, borderRadius: 4, flexShrink: 0 }}
/>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
}}
>
{t("controlOverlay.status")}
</span>
<button
type="button"
data-slot="control-overlay-stop-all"
disabled={interrupting}
onClick={onInterrupt}
style={{
pointerEvents,
display: "flex",
alignItems: "center",
gap: 6,
border: "none",
borderRadius: 9999,
padding: "8px 20px 8px 16px",
fontSize: 15,
fontWeight: 600,
color: "#fff",
backgroundColor: interrupting ? "#9ca3af" : "#f97316",
cursor: interrupting ? "default" : "pointer",
opacity: interrupting ? 0.7 : 1,
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
whiteSpace: "nowrap",
lineHeight: 1,
}}
>
<RiStopCircleLine size={18} color="#fff" />
{interrupting ? t("controlOverlay.interrupting") : t("controlOverlay.interrupt")}
</button>
</div>
</>
);
}
@@ -0,0 +1,714 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiArrowDownSLine } from "@remixicon/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import logoUrl from "../../assets/logo.png";
export interface HelpRequestData {
id: string;
prompt: string;
/** Custom overlay title; falls back to i18n when omitted. */
title?: string;
/** CSS selectors to scroll to + flash-highlight. */
selectors: string[];
onContinue: (note: string) => void;
onCancel: () => void;
}
interface Props {
request: HelpRequestData | null;
}
interface Box {
top: number;
left: number;
width: number;
height: number;
}
interface PanelPos {
top?: number;
bottom?: number;
left: number;
placement: PanelPlacement;
}
type PanelPlacement = "above" | "below";
const PANEL_GAP = 12;
const VIEWPORT_MARGIN = 16;
const PANEL_WIDTH = 420;
const FALLBACK_PANEL_H = 180;
const FALLBACK_PANEL_H_COLLAPSED = 44;
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function clampDragPos(
top: number,
left: number,
panelW: number,
panelH: number,
): { top: number; left: number } {
const maxLeft = window.innerWidth - panelW - VIEWPORT_MARGIN;
const maxTop = window.innerHeight - panelH - VIEWPORT_MARGIN;
return {
top: clamp(top, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxTop)),
left: clamp(left, VIEWPORT_MARGIN, Math.max(VIEWPORT_MARGIN, maxLeft)),
};
}
function boxesEqual(a: Box[], b: Box[]): boolean {
return (
a.length === b.length &&
a.every(
(box, i) =>
box.top === b[i]?.top &&
box.left === b[i]?.left &&
box.width === b[i]?.width &&
box.height === b[i]?.height,
)
);
}
function panelPosEqual(a: PanelPos | null, b: PanelPos | null): boolean {
return (
a?.top === b?.top &&
a?.bottom === b?.bottom &&
a?.left === b?.left &&
a?.placement === b?.placement
);
}
/** Union of multiple viewport boxes into one anchor rect. */
function unionBox(boxes: Box[]): Box | null {
if (boxes.length === 0) return null;
let top = Number.POSITIVE_INFINITY;
let left = Number.POSITIVE_INFINITY;
let right = Number.NEGATIVE_INFINITY;
let bottom = Number.NEGATIVE_INFINITY;
for (const b of boxes) {
top = Math.min(top, b.top);
left = Math.min(left, b.left);
right = Math.max(right, b.left + b.width);
bottom = Math.max(bottom, b.top + b.height);
}
return { top, left, width: right - left, height: bottom - top };
}
function choosePlacement(anchor: Box, panelH: number): PanelPlacement {
const viewportH = window.innerHeight;
const anchorBottom = anchor.top + anchor.height;
const spaceBelow = viewportH - anchorBottom;
const spaceAbove = anchor.top;
const minSpace = panelH + PANEL_GAP + VIEWPORT_MARGIN;
if (spaceBelow >= minSpace) {
return "below";
}
if (spaceAbove >= minSpace) {
return "above";
}
return spaceBelow >= spaceAbove ? "below" : "above";
}
/** Place the panel near the anchor on a stable side, clamped to the viewport. */
function placePanel(
anchor: Box,
panelW: number,
panelH: number,
placement: PanelPlacement,
): PanelPos {
const viewportW = window.innerWidth;
const viewportH = window.innerHeight;
const anchorBottom = anchor.top + anchor.height;
const anchorCenterX = anchor.left + anchor.width / 2;
const minCenterX = VIEWPORT_MARGIN + panelW / 2;
const maxCenterX = viewportW - VIEWPORT_MARGIN - panelW / 2;
const left =
minCenterX <= maxCenterX ? clamp(anchorCenterX, minCenterX, maxCenterX) : viewportW / 2;
if (placement === "below") {
const top = clamp(
anchorBottom + PANEL_GAP,
VIEWPORT_MARGIN,
viewportH - panelH - VIEWPORT_MARGIN,
);
return { top, left, placement };
}
let bottom = viewportH - anchor.top + PANEL_GAP;
const maxBottom = Math.max(VIEWPORT_MARGIN, viewportH - panelH - VIEWPORT_MARGIN);
bottom = clamp(bottom, VIEWPORT_MARGIN, maxBottom);
return { bottom, left, placement };
}
/** Returns null for invalid selectors instead of throwing. */
function querySelectorSafe(selector: string): Element | null {
try {
return document.querySelector(selector);
} catch {
return null;
}
}
/** Resolve selectors to their first matching element's viewport rect. */
function measure(selectors: string[]): Box[] {
const boxes: Box[] = [];
for (const sel of selectors) {
const el = querySelectorSafe(sel);
if (!el) continue;
const r = el.getBoundingClientRect();
boxes.push({ top: r.top, left: r.left, width: r.width, height: r.height });
}
return boxes;
}
export function HelpRequestOverlay({ request }: Props) {
const { t } = useTranslation("extension");
const [note, setNote] = useState("");
const [collapsed, setCollapsed] = useState(false);
const [boxes, setBoxes] = useState<Box[]>([]);
const [panelPos, setPanelPos] = useState<PanelPos | null>(null);
const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null);
const [dragging, setDragging] = useState(false);
const bannerRef = useRef<HTMLDivElement>(null);
const lastScrolledIdRef = useRef<string | null>(null);
const placementRef = useRef<{ requestId: string; placement: PanelPlacement } | null>(null);
const userMovedRef = useRef(false);
const dragStartRef = useRef<{
pointerX: number;
pointerY: number;
originLeft: number;
originTop: number;
panelW: number;
panelH: number;
} | null>(null);
// Reset the note and collapse state whenever a new request appears.
useEffect(() => {
setNote("");
setCollapsed(false);
placementRef.current = null;
setDragPos(null);
setDragging(false);
userMovedRef.current = false;
dragStartRef.current = null;
}, [request?.id]);
// Scroll the first matched target into view once per distinct request id.
useLayoutEffect(() => {
if (!request) return;
if (lastScrolledIdRef.current === request.id) return;
for (const sel of request.selectors) {
const el = querySelectorSafe(sel);
if (el) {
lastScrolledIdRef.current = request.id;
el.scrollIntoView({ block: "center", behavior: "smooth" });
break;
}
}
}, [request]);
// Keep highlight boxes aligned with the page as it scrolls / resizes.
useEffect(() => {
if (!request) {
setBoxes([]);
return;
}
const update = () => {
const nextBoxes = measure(request.selectors);
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
const interval = window.setInterval(update, 500);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
window.clearInterval(interval);
};
}, [request]);
const onHeaderPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return;
const banner = bannerRef.current;
if (!banner) return;
const rect = banner.getBoundingClientRect();
dragStartRef.current = {
pointerX: e.clientX,
pointerY: e.clientY,
originLeft: rect.left,
originTop: rect.top,
panelW: banner.offsetWidth || PANEL_WIDTH,
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
};
userMovedRef.current = true;
setDragPos({ top: rect.top, left: rect.left });
setDragging(true);
e.preventDefault();
}, [collapsed]);
useEffect(() => {
if (!dragging) return;
const onMove = (e: PointerEvent) => {
const start = dragStartRef.current;
if (!start) return;
const dx = e.clientX - start.pointerX;
const dy = e.clientY - start.pointerY;
setDragPos(
clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH),
);
};
const onUp = () => {
setDragging(false);
dragStartRef.current = null;
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
}, [dragging]);
// Anchor the panel near matched targets; re-run when highlights or collapse change.
useLayoutEffect(() => {
if (!request) {
setPanelPos(null);
return;
}
if (userMovedRef.current) return;
const measured = boxes.length > 0 ? boxes : measure(request.selectors);
const anchor = unionBox(measured);
const banner = bannerRef.current;
if (!anchor || !banner) {
setPanelPos(null);
return;
}
const rect = banner.getBoundingClientRect();
let panelW = banner.offsetWidth || rect.width;
let panelH = banner.offsetHeight || rect.height;
if (panelW === 0) {
panelW = Math.min(PANEL_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2);
}
if (panelH === 0) {
panelH = collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H;
}
if (placementRef.current?.requestId !== request.id) {
placementRef.current = {
requestId: request.id,
placement: choosePlacement(anchor, panelH),
};
}
const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement);
setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos));
}, [request, boxes, collapsed]);
if (!request) return null;
return (
<>
<style>{`
@keyframes bsk-help-flash {
0%, 100% { box-shadow: 0 0 0 2px rgba(249,115,22,0.9), 0 0 12px 2px rgba(249,115,22,0.5); }
50% { box-shadow: 0 0 0 3px rgba(249,115,22,1), 0 0 22px 6px rgba(249,115,22,0.8); }
}
.bsk-help-banner {
--bsk-help-ease: cubic-bezier(0.32, 0.72, 0, 1);
--bsk-help-duration: 320ms;
--bsk-help-banner-width: ${PANEL_WIDTH}px;
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 10px;
width: var(--bsk-help-banner-width);
max-width: calc(100vw - ${VIEWPORT_MARGIN * 2}px);
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 12px 40px rgba(124,45,18,0.18), 0 2px 8px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
transition:
padding var(--bsk-help-duration) var(--bsk-help-ease),
gap var(--bsk-help-duration) var(--bsk-help-ease),
width var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] {
gap: 0;
width: var(--bsk-help-banner-width);
padding: 10px 12px;
}
.bsk-help-drag-strip {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
height: 12px;
margin: -8px 0 -10px;
cursor: grab;
user-select: none;
touch-action: none;
}
.bsk-help-drag-pill {
width: 32px;
height: 4px;
border-radius: 999px;
background: #d1d5db;
transition: background-color 160ms var(--bsk-help-ease);
}
.bsk-help-drag-strip:hover .bsk-help-drag-pill {
background: #9ca3af;
}
.bsk-help-banner[data-dragging="true"] .bsk-help-drag-strip {
cursor: grabbing;
}
.bsk-help-banner[data-dragging="true"] .bsk-help-drag-pill {
background: #6b7280;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-drag-strip {
margin: -6px 0 -8px;
}
.bsk-help-header {
display: flex;
align-items: center;
gap: 10px;
}
.bsk-help-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 15px;
font-weight: 600;
color: #111;
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-title {
white-space: nowrap;
}
.bsk-help-header-actions {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
overflow: hidden;
max-width: 0;
opacity: 0;
transform: translateX(8px);
pointer-events: none;
transition:
max-width var(--bsk-help-duration) var(--bsk-help-ease),
opacity 220ms var(--bsk-help-ease),
transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-header-actions {
max-width: 280px;
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
.bsk-help-collapse-toggle {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
cursor: pointer;
border: none;
background: transparent;
padding: 4px;
line-height: 1;
color: #6b7280;
margin-left: auto;
transition: margin-left var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-collapse-toggle {
margin-left: 0;
}
.bsk-help-collapse-icon {
display: flex;
transition: transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-collapse-icon {
transform: rotate(180deg);
}
.bsk-help-body {
display: grid;
grid-template-rows: 1fr;
min-width: 0;
transition: grid-template-rows var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-body {
position: absolute;
grid-template-rows: 0fr;
width: 0;
height: 0;
overflow: hidden;
pointer-events: none;
}
.bsk-help-body-inner {
overflow: hidden;
min-height: 0;
min-width: 0;
}
.bsk-help-body-content {
display: flex;
flex-direction: column;
gap: 10px;
opacity: 1;
transform: translateY(0);
transition:
opacity 220ms var(--bsk-help-ease),
transform var(--bsk-help-duration) var(--bsk-help-ease);
}
.bsk-help-banner[data-collapsed="true"] .bsk-help-body-content {
opacity: 0;
transform: translateY(6px);
}
.bsk-help-prompt {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: #444;
}
.bsk-help-note-input {
width: 100%;
box-sizing: border-box;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
}
.bsk-help-footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.bsk-help-btn-cancel {
cursor: pointer;
border: 1px solid #e5e7eb;
background: transparent;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: #4b5563;
flex-shrink: 0;
}
.bsk-help-btn-continue {
cursor: pointer;
border: none;
background: #f97316;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: #fff;
flex-shrink: 0;
}
.bsk-help-header-actions .bsk-help-btn-cancel,
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 12px;
}
.bsk-help-header-actions .bsk-help-btn-continue {
padding: 6px 14px;
}
.bsk-help-footer-actions .bsk-help-btn-cancel {
padding: 8px 16px;
}
.bsk-help-footer-actions .bsk-help-btn-continue {
padding: 8px 18px;
}
@media (prefers-reduced-motion: reduce) {
.bsk-help-banner,
.bsk-help-header-actions,
.bsk-help-collapse-toggle,
.bsk-help-collapse-icon,
.bsk-help-body,
.bsk-help-body-content,
.bsk-help-drag-strip,
.bsk-help-drag-pill {
transition-duration: 0.01ms !important;
}
}
`}</style>
{boxes.map((b, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
key={i}
data-slot="help-highlight"
style={{
position: "fixed",
top: b.top,
left: b.left,
width: b.width,
height: b.height,
borderRadius: 6,
zIndex: 2147483646,
pointerEvents: "none",
animation: "bsk-help-flash 1.2s ease-in-out infinite",
}}
/>
))}
<div
ref={bannerRef}
data-slot="help-request-banner"
data-collapsed={collapsed ? "true" : "false"}
data-anchored={panelPos ? "true" : "false"}
data-dragging={dragging ? "true" : "false"}
data-placement={panelPos?.placement}
className="bsk-help-banner"
style={
dragPos
? {
top: dragPos.top,
left: dragPos.left,
bottom: "auto",
transform: "none",
}
: panelPos
? {
top: panelPos.top ?? "auto",
left: panelPos.left,
bottom: panelPos.bottom ?? "auto",
transform: "translateX(-50%)",
}
: undefined
}
>
<div
data-slot="help-drag-handle"
className="bsk-help-drag-strip"
role="img"
aria-label={t("helpRequest.dragHandle")}
onPointerDown={onHeaderPointerDown}
>
<span className="bsk-help-drag-pill" aria-hidden />
</div>
<div className="bsk-help-header">
<img src={logoUrl} alt="browser-skill" style={{ width: 22, height: 22, borderRadius: 4 }} />
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
<button
type="button"
data-slot={collapsed ? "help-cancel-button" : undefined}
className="bsk-help-btn-cancel"
tabIndex={collapsed ? 0 : -1}
onClick={() => request.onCancel()}
>
{t("helpRequest.cancel")}
</button>
<button
type="button"
data-slot={collapsed ? "help-continue-button" : undefined}
className="bsk-help-btn-continue"
tabIndex={collapsed ? 0 : -1}
onClick={() => request.onContinue(note)}
>
{t("helpRequest.continue")}
</button>
</div>
<button
type="button"
data-slot="help-collapse-toggle"
className="bsk-help-collapse-toggle"
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
aria-expanded={!collapsed}
onClick={() => setCollapsed((c) => !c)}
>
<span className="bsk-help-collapse-icon">
<RiArrowDownSLine size={20} aria-hidden />
</span>
</button>
</div>
<div className="bsk-help-body" aria-hidden={collapsed}>
<div className="bsk-help-body-inner">
<div className="bsk-help-body-content">
<p className="bsk-help-prompt">{request.prompt}</p>
<input
type="text"
className="bsk-help-note-input"
aria-label={t("helpRequest.noteLabel")}
placeholder={t("helpRequest.notePlaceholder")}
value={note}
tabIndex={collapsed ? -1 : 0}
onChange={(e) => setNote(e.target.value)}
/>
<div className="bsk-help-footer-actions">
<button
type="button"
data-slot={collapsed ? undefined : "help-cancel-button"}
className="bsk-help-btn-cancel"
tabIndex={collapsed ? -1 : 0}
onClick={() => request.onCancel()}
>
{t("helpRequest.cancel")}
</button>
<button
type="button"
data-slot={collapsed ? undefined : "help-continue-button"}
className="bsk-help-btn-continue"
tabIndex={collapsed ? -1 : 0}
onClick={() => request.onContinue(note)}
>
{t("helpRequest.continue")}
</button>
</div>
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,87 @@
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BorrowConfirmationOverlay } from "../BorrowConfirmationOverlay";
describe("BorrowConfirmationOverlay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
const { container } = render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-1",
isActiveTab: true,
tabTitle: "Example",
timeoutMs: 5000,
onAllow,
onDeny,
},
]}
/>,
);
expect(screen.getByText("允许借用标签页?")).toBeTruthy();
for (let i = 0; i < 5; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const progressCircle = container.querySelectorAll("svg circle")[1];
expect(progressCircle).toBeTruthy();
await act(() => {
const event = new TransitionEvent("transitionend", { bubbles: true });
Object.defineProperty(event, "propertyName", {
value: "stroke-dashoffset",
configurable: true,
});
progressCircle!.dispatchEvent(event);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onAllow).toHaveBeenCalledTimes(1);
expect(onDeny).not.toHaveBeenCalled();
});
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-2",
isActiveTab: true,
tabTitle: "Active Tab",
timeoutMs: 60_000,
onAllow,
onDeny,
},
]}
/>,
);
const denyButton = screen.getByRole("button", { name: "拒绝" });
denyButton.click();
denyButton.click();
await vi.advanceTimersByTimeAsync(150);
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,43 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ControlOverlay } from "../ControlOverlay";
describe("ControlOverlay", () => {
afterEach(() => {
cleanup();
});
it("sets pointer-events none on blocker and pill when automationBypass is true", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={true}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
expect(stopBtn).toBeTruthy();
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none");
});
it("uses pointer-events auto on blocker when automationBypass is false", () => {
const { container } = render(
<ControlOverlay
visible={true}
interrupting={false}
automationBypass={false}
onInterrupt={() => {}}
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
});
});
@@ -0,0 +1,418 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HelpRequestOverlay, type HelpRequestData } from "../HelpRequestOverlay";
function renderOverlay(req: HelpRequestData) {
return render(
createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: req })),
);
}
function baseRequest(overrides: Partial<HelpRequestData> = {}): HelpRequestData {
return {
id: "r1",
prompt: "Please complete the captcha",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
...overrides,
};
}
function overlayStyles(container: HTMLElement): string {
return Array.from(container.querySelectorAll("style"))
.map((style) => style.textContent ?? "")
.join("\n");
}
describe("HelpRequestOverlay", () => {
afterEach(() => {
cleanup();
});
it("renders the prompt", () => {
renderOverlay(baseRequest());
expect(screen.getByText("Please complete the captcha")).toBeTruthy();
});
it("renders a custom title when provided", () => {
renderOverlay(baseRequest({ title: "Verify your identity" }));
expect(screen.getByText("Verify your identity")).toBeTruthy();
});
it("falls back to the default i18n title when title is omitted", () => {
renderOverlay(baseRequest());
expect(screen.getByText(i18n.t("helpRequest.title", { ns: "extension" }))).toBeTruthy();
});
it("calls onContinue with the typed note", () => {
const onContinue = vi.fn();
const { container } = renderOverlay(baseRequest({ onContinue }));
fireEvent.change(screen.getByRole("textbox"), { target: { value: "did it" } });
const continueBtn = container.querySelector(
"[data-slot='help-continue-button']",
) as HTMLButtonElement;
fireEvent.click(continueBtn);
expect(onContinue).toHaveBeenCalledWith("did it");
});
it("calls onCancel when cancel clicked", () => {
const onCancel = vi.fn();
const { container } = renderOverlay(baseRequest({ onCancel }));
const cancelBtn = container.querySelector(
"[data-slot='help-cancel-button']",
) as HTMLButtonElement;
fireEvent.click(cancelBtn);
expect(onCancel).toHaveBeenCalled();
});
it("highlights a matched selector and reports a missing one", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login", "#missing"] }));
// One highlight box rendered for the matched selector.
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(1);
expect((el.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
el.remove();
});
it("scrolls into view for each distinct request when switching A→B directly", () => {
const makeTarget = (id: string) => {
const el = document.createElement("div");
el.id = id;
el.getBoundingClientRect = () =>
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
return el;
};
const elA = makeTarget("a-target");
const elB = makeTarget("b-target");
const { rerender } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "a", selectors: ["#a-target"] }),
}),
),
);
rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "b", selectors: ["#b-target"] }),
}),
),
);
expect((elA.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect((elB.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
elA.remove();
elB.remove();
});
it("anchors the banner near a matched selector", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-anchored")).toBe("true");
expect(banner.style.top).not.toBe("");
expect(banner.style.left).not.toBe("");
expect(banner.style.bottom).toBe("auto");
expect(banner.style.transform).toBe("translateX(-50%)");
expect(banner.style.top).toBe("152px");
expect(banner.style.left).toBe("260px");
el.remove();
});
it("falls back to bottom-center when no selectors match", () => {
renderOverlay(baseRequest({ selectors: [] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-anchored")).toBe("false");
expect(banner.style.top).toBe("");
expect(banner.style.left).toBe("");
});
it("keeps the above placement edge anchored while collapsing", () => {
const el = document.createElement("div");
el.id = "login";
const targetTop = window.innerHeight - 80;
const targetBottom = targetTop + 40;
el.getBoundingClientRect = () =>
({
top: targetTop,
left: 200,
width: 120,
height: 40,
right: 320,
bottom: targetBottom,
}) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-placement")).toBe("above");
expect(banner.style.top).toBe("auto");
expect(banner.style.bottom).toBe(`${window.innerHeight - targetTop + 12}px`);
const bottomBefore = banner.style.bottom;
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
expect(banner.getAttribute("data-placement")).toBe("above");
expect(banner.style.top).toBe("auto");
expect(banner.style.bottom).toBe(bottomBefore);
el.remove();
});
it("removes body content from collapsed width layout", () => {
const { container } = renderOverlay(
baseRequest({
prompt:
"This is a very long prompt that should not make the collapsed help request banner wide.",
}),
);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const body = container.querySelector(".bsk-help-body") as HTMLElement;
const styles = overlayStyles(container);
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(body.getAttribute("aria-hidden")).toBe("true");
expect(styles).toContain('.bsk-help-banner[data-collapsed="true"] .bsk-help-body');
expect(styles).toContain("position: absolute");
expect(styles).toContain("width: 0");
});
it("keeps the same banner width when collapsed", () => {
const { container } = renderOverlay(baseRequest());
const styles = overlayStyles(container);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(styles).toContain("--bsk-help-banner-width: 420px");
expect(styles).toContain("width: var(--bsk-help-banner-width)");
expect(styles).not.toContain("width: auto");
});
it("keeps collapsed header actions visible when the title is long", () => {
const { container } = renderOverlay(
baseRequest({
title:
"This title is intentionally long enough to compete with the collapsed action buttons for space",
}),
);
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const styles = overlayStyles(container);
expect(banner.getAttribute("data-collapsed")).toBe("true");
expect(styles).toMatch(
/\.bsk-help-title\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;/s,
);
expect(styles).toMatch(/\.bsk-help-header-actions\s*\{[^}]*flex-shrink:\s*0;/s);
});
it("moves the banner when dragging the top strip and freezes auto positioning", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
const anchoredTop = banner.style.top;
const anchoredLeft = banner.style.left;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 210, clientY: 154, pointerId: 1 });
fireEvent(window, new PointerEvent("pointermove", { clientX: 260, clientY: 184 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
el.getBoundingClientRect = () =>
({ top: 300, left: 400, width: 120, height: 40, right: 520, bottom: 340 }) as DOMRect;
fireEvent.scroll(window);
fireEvent.resize(window);
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
expect(banner.style.top).not.toBe(anchoredTop);
expect(banner.style.left).not.toBe(anchoredLeft);
el.remove();
});
it("does not start drag when pointer down on a button in the header", () => {
const { container } = renderOverlay(baseRequest());
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const collapseBtn = screen.getByLabelText(
i18n.t("helpRequest.collapse", { ns: "extension" }),
);
fireEvent.pointerDown(collapseBtn, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
expect(banner.getAttribute("data-dragging")).toBe("false");
expect(banner.style.transform).not.toBe("none");
});
it("resets drag position when switching to a new request", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
const { rerender } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "a", selectors: ["#login"] }),
}),
),
);
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 210, clientY: 154, pointerId: 1 });
fireEvent(window, new PointerEvent("pointermove", { clientX: 260, clientY: 184 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
rerender(
createElement(
I18nextProvider,
{ i18n },
createElement(HelpRequestOverlay, {
request: baseRequest({ id: "b", selectors: ["#login"] }),
}),
),
);
const bannerAfter = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
expect(bannerAfter.style.transform).toBe("translateX(-50%)");
el.remove();
});
it("renders a labelled drag handle affordance", () => {
const { container } = renderOverlay(baseRequest());
const handle = container.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
expect(handle).toBeTruthy();
expect(handle.getAttribute("aria-label")).toBe(
i18n.t("helpRequest.dragHandle", { ns: "extension" }),
);
expect(handle.querySelector(".bsk-help-drag-pill")).toBeTruthy();
});
it("moves the banner when dragging the drag handle", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const handle = banner.querySelector("[data-slot='help-drag-handle']") as HTMLElement;
banner.getBoundingClientRect = () =>
({
top: 152,
left: 50,
width: 420,
height: 180,
right: 470,
bottom: 332,
}) as DOMRect;
Object.defineProperty(banner, "offsetWidth", { value: 420, configurable: true });
Object.defineProperty(banner, "offsetHeight", { value: 180, configurable: true });
fireEvent.pointerDown(handle, { button: 0, clientX: 260, clientY: 158, pointerId: 1 });
expect(banner.getAttribute("data-dragging")).toBe("true");
fireEvent(window, new PointerEvent("pointermove", { clientX: 310, clientY: 188 }));
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
expect(banner.style.transform).toBe("none");
expect(banner.style.top).toBe("182px");
expect(banner.style.left).toBe("100px");
el.remove();
});
it("keeps the horizontal anchor stable while collapsing", () => {
const el = document.createElement("div");
el.id = "login";
el.getBoundingClientRect = () =>
({ top: 100, left: 200, width: 120, height: 40, right: 320, bottom: 140 }) as DOMRect;
el.scrollIntoView = vi.fn();
document.body.append(el);
renderOverlay(baseRequest({ selectors: ["#login"] }));
const banner = document.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const leftBefore = banner.style.left;
const transformBefore = banner.style.transform;
fireEvent.click(screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" })));
expect(banner.style.left).toBe(leftBefore);
expect(banner.style.transform).toBe(transformBefore);
expect(banner.style.transform).toBe("translateX(-50%)");
el.remove();
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
import { OverlayController } from "../overlay-controller";
describe("OverlayController", () => {
it("resets agent overlays without clearing user-tab borrow requests", () => {
const controller = new OverlayController();
controller.addBorrowRequest({
id: "borrow-1",
isActiveTab: true,
tabTitle: "User page",
timeoutMs: 5000,
onAllow: vi.fn(),
onDeny: vi.fn(),
});
controller.activateAgentSession("sess-1");
controller.setAgentHelpRequest({
id: "help-1",
prompt: "Finish login",
selectors: ["#login"],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
controller.setAutomationBypass(true);
controller.resetAgentOverlays("sess-1");
const state = controller.snapshot();
expect(state.borrowRequests).toHaveLength(1);
expect(state.borrowRequests[0]?.id).toBe("borrow-1");
expect(state.controlVisible).toBe(false);
expect(state.activeSessionId).toBeNull();
expect(state.activeHelp).toBeNull();
expect(state.automationBypassCount).toBe(0);
});
it("ignores a reset for a different session", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAutomationBypass(true);
controller.resetAgentOverlays("sess-2");
const state = controller.snapshot();
expect(state.activeSessionId).toBe("sess-1");
expect(state.controlVisible).toBe(true);
expect(state.automationBypassCount).toBe(1);
});
it("returns the previous help request when replacing agent help", () => {
const controller = new OverlayController();
controller.setAgentHelpRequest({
id: "help-1",
prompt: "First request",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
const previous = controller.setAgentHelpRequest({
id: "help-2",
prompt: "Second request",
selectors: [],
onContinue: vi.fn(),
onCancel: vi.fn(),
});
expect(previous?.id).toBe("help-1");
expect(controller.snapshot().activeHelp?.id).toBe("help-2");
});
});
@@ -0,0 +1,95 @@
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
import type { HelpRequestData } from "./HelpRequestOverlay";
export interface OverlayState {
borrowRequests: BorrowRequestData[];
activeHelp: HelpRequestData | null;
controlVisible: boolean;
interrupting: boolean;
activeSessionId: string | null;
automationBypassCount: number;
}
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
/**
* Owns overlay state by rendering scope. User-tab overlays survive Agent
* session teardown; Agent-tab overlays are cleared whenever a tab is returned.
*/
export class OverlayController {
private state: MutableOverlayState = {
borrowRequests: [],
activeHelp: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
};
snapshot(): OverlayState {
return {
...this.state,
controlVisible: this.isControlVisible(),
borrowRequests: [...this.state.borrowRequests],
};
}
isControlVisible(): boolean {
return this.state.activeSessionId !== null;
}
addBorrowRequest(request: BorrowRequestData): void {
this.state.borrowRequests = [
...this.state.borrowRequests.filter((r) => r.id !== request.id),
request,
];
}
removeBorrowRequest(requestId: string): void {
this.state.borrowRequests = this.state.borrowRequests.filter((r) => r.id !== requestId);
}
activateAgentSession(sessionId: string): void {
this.state.activeSessionId = sessionId;
this.state.interrupting = false;
}
setInterrupting(interrupting: boolean): void {
if (!this.state.activeSessionId) return;
this.state.interrupting = interrupting;
}
setAgentHelpRequest(request: HelpRequestData): HelpRequestData | null {
const previous = this.state.activeHelp;
this.state.activeHelp = request;
return previous;
}
clearAgentHelpRequest(requestId?: string): void {
if (!this.state.activeHelp) return;
if (requestId && this.state.activeHelp.id !== requestId) return;
this.state.activeHelp = null;
}
setAutomationBypass(enabled: boolean): void {
if (enabled) {
this.state.automationBypassCount += 1;
} else {
this.state.automationBypassCount = Math.max(0, this.state.automationBypassCount - 1);
}
}
resetAgentOverlays(sessionId: string): HelpRequestData | null {
if (this.state.activeSessionId && this.state.activeSessionId !== sessionId) {
return null;
}
const previousHelp = this.state.activeHelp;
this.state = {
...this.state,
activeHelp: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
};
return previousHelp;
}
}
+2
View File
@@ -0,0 +1,2 @@
@import "tailwindcss";
@source "../content";