mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Initial public release
This commit is contained in:
@@ -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]">"{truncatedTitle}"</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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "../content";
|
||||
Reference in New Issue
Block a user