mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Merge pull request #63 from Tencent/feat/record-overlay-timer
feat(extension): compact recording overlay with live timer
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -1,9 +1,21 @@
|
|||||||
import { useTranslation } from "@browser-skill/i18n/react";
|
import { useTranslation } from "@browser-skill/i18n/react";
|
||||||
import { RiStopCircleLine } from "@remixicon/react";
|
import {
|
||||||
import { useEffect, useState } from "react";
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import recordIconUrl from "../../assets/record-icon.png";
|
||||||
|
|
||||||
export interface RecordRequestData {
|
export interface RecordRequestData {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Epoch ms when the recording began. Supplied by the background so the timer
|
||||||
|
* keeps counting the whole session across navigations; falls back to mount
|
||||||
|
* time when the background could not report it.
|
||||||
|
*/
|
||||||
|
startedAtMs?: number;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11,9 +23,51 @@ type Props = {
|
|||||||
request: RecordRequestData | null;
|
request: RecordRequestData | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 16;
|
||||||
|
const DRAG_THRESHOLD_PX = 8;
|
||||||
|
const INTRO_DURATION_MS = 10_000;
|
||||||
|
const TIMER_REFRESH_MS = 250;
|
||||||
|
|
||||||
|
function formatElapsed(seconds: number): string {
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function RecordOverlay({ request }: Props) {
|
export function RecordOverlay({ request }: Props) {
|
||||||
const { t } = useTranslation("extension");
|
const { t } = useTranslation("extension");
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
||||||
|
const [dragPos, setDragPos] = useState<{ top: number; left: number } | null>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const pillRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragStartRef = useRef<{
|
||||||
|
pointerX: number;
|
||||||
|
pointerY: number;
|
||||||
|
originLeft: number;
|
||||||
|
originTop: number;
|
||||||
|
panelW: number;
|
||||||
|
panelH: number;
|
||||||
|
armed: boolean;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (request) {
|
if (request) {
|
||||||
@@ -23,8 +77,87 @@ export function RecordOverlay({ request }: Props) {
|
|||||||
setShow(false);
|
setShow(false);
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
const requestStartedAtMs = request?.startedAtMs;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDragPos(null);
|
||||||
|
setDragging(false);
|
||||||
|
dragStartRef.current = null;
|
||||||
|
if (!request) {
|
||||||
|
setElapsedSeconds(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = requestStartedAtMs ?? Date.now();
|
||||||
|
const updateElapsed = () => {
|
||||||
|
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1_000)));
|
||||||
|
};
|
||||||
|
updateElapsed();
|
||||||
|
const timer = window.setInterval(updateElapsed, TIMER_REFRESH_MS);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [request?.id, requestStartedAtMs]);
|
||||||
|
|
||||||
|
const onPillPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (e.target instanceof Element && e.target.closest("button, a, input, textarea, select")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pill = pillRef.current;
|
||||||
|
if (!pill) return;
|
||||||
|
const rect = pill.getBoundingClientRect();
|
||||||
|
dragStartRef.current = {
|
||||||
|
pointerX: e.clientX,
|
||||||
|
pointerY: e.clientY,
|
||||||
|
originLeft: rect.left,
|
||||||
|
originTop: rect.top,
|
||||||
|
panelW: pill.offsetWidth || rect.width,
|
||||||
|
panelH: pill.offsetHeight || rect.height,
|
||||||
|
armed: false,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
const start = dragStartRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
const dx = e.clientX - start.pointerX;
|
||||||
|
const dy = e.clientY - start.pointerY;
|
||||||
|
if (!start.armed) {
|
||||||
|
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
|
||||||
|
start.armed = true;
|
||||||
|
setDragging(true);
|
||||||
|
setDragPos({ top: start.originTop, left: start.originLeft });
|
||||||
|
}
|
||||||
|
setDragPos(
|
||||||
|
clampDragPos(start.originTop + dy, start.originLeft + dx, start.panelW, start.panelH),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
const start = dragStartRef.current;
|
||||||
|
if (!start) return;
|
||||||
|
if (start.armed) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
|
|
||||||
|
const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS;
|
||||||
|
const positioned = dragPos !== null;
|
||||||
|
const defaultTransform = show
|
||||||
|
? "translateX(-50%) translateY(0)"
|
||||||
|
: "translateX(-50%) translateY(8px)";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -35,78 +168,118 @@ export function RecordOverlay({ request }: Props) {
|
|||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={pillRef}
|
||||||
data-slot="record-overlay-pill"
|
data-slot="record-overlay-pill"
|
||||||
|
data-phase={introVisible ? "intro" : "timer"}
|
||||||
|
data-dragging={dragging ? "true" : "false"}
|
||||||
|
onPointerDown={onPillPointerDown}
|
||||||
style={{
|
style={{
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
bottom: 32,
|
...(positioned
|
||||||
left: "50%",
|
? { top: dragPos.top, left: dragPos.left, bottom: "auto" }
|
||||||
|
: { bottom: 32, left: "50%" }),
|
||||||
zIndex: 2147483647,
|
zIndex: 2147483647,
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 10,
|
gap: 5,
|
||||||
backgroundColor: "#fff",
|
backgroundColor: "#fff",
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
padding: "10px 10px 10px 20px",
|
height: 50,
|
||||||
boxShadow: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
boxSizing: "border-box",
|
||||||
|
padding: "6px 8px 6px 16px",
|
||||||
|
boxShadow: dragging
|
||||||
|
? "0 12px 40px rgba(15,23,42,0.22), 0 2px 8px rgba(0,0,0,0.12)"
|
||||||
|
: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
|
||||||
opacity: show ? 1 : 0,
|
opacity: show ? 1 : 0,
|
||||||
transform: show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)",
|
transform: positioned ? "none" : defaultTransform,
|
||||||
transition: "opacity 300ms ease-out, transform 300ms ease-out",
|
transition: dragging
|
||||||
|
? "box-shadow 150ms ease-out"
|
||||||
|
: "opacity 300ms ease-out, transform 300ms ease-out, box-shadow 150ms ease-out",
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
maxWidth: "min(420px, calc(100vw - 32px))",
|
maxWidth: "min(420px, calc(100vw - 32px))",
|
||||||
|
cursor: dragging ? "grabbing" : "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
touchAction: "none",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<div
|
||||||
data-slot="record-overlay-indicator"
|
data-slot="record-overlay-status"
|
||||||
style={{
|
|
||||||
width: 9,
|
|
||||||
height: 9,
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#ef4444",
|
|
||||||
flexShrink: 0,
|
|
||||||
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
style={{
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 0,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 16,
|
borderRadius: 9999,
|
||||||
fontWeight: 500,
|
outline: "none",
|
||||||
color: "#333",
|
minHeight: 32,
|
||||||
whiteSpace: "nowrap",
|
paddingRight: 4,
|
||||||
userSelect: "none",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t("recordOverlay.recording")}
|
<span
|
||||||
</span>
|
data-slot="record-overlay-indicator"
|
||||||
|
style={{
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: "#ef4444",
|
||||||
|
flexShrink: 0,
|
||||||
|
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
data-slot="record-overlay-label"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
flex: "0 1 auto",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "#333",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
userSelect: "none",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
width: introVisible ? "auto" : 48,
|
||||||
|
maxWidth: introVisible ? 280 : 48,
|
||||||
|
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
|
||||||
|
textAlign: introVisible ? "left" : "center",
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
transition: "max-width 180ms ease-out",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-slot="record-overlay-finish"
|
data-slot="record-overlay-finish"
|
||||||
|
aria-label={t("recordOverlay.finish")}
|
||||||
onClick={request.onFinish}
|
onClick={request.onFinish}
|
||||||
style={{
|
style={{
|
||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
display: "flex",
|
display: "block",
|
||||||
alignItems: "center",
|
width: 32,
|
||||||
gap: 6,
|
height: 32,
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 9999,
|
borderRadius: 9999,
|
||||||
padding: "8px 20px 8px 16px",
|
padding: 0,
|
||||||
fontSize: 15,
|
backgroundColor: "transparent",
|
||||||
fontWeight: 600,
|
|
||||||
color: "#fff",
|
|
||||||
backgroundColor: "#f97316",
|
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
lineHeight: 1,
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RiStopCircleLine size={18} color="#fff" />
|
<img
|
||||||
{t("recordOverlay.finish")}
|
src={recordIconUrl}
|
||||||
|
alt=""
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
draggable={false}
|
||||||
|
style={{ display: "block", margin: "auto" }}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,37 +1,181 @@
|
|||||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
import { i18n } from "@browser-skill/i18n";
|
||||||
|
import { I18nextProvider } from "@browser-skill/i18n/react";
|
||||||
|
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { createElement } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { RecordOverlay } from "../RecordOverlay";
|
import { RecordOverlay } from "../RecordOverlay";
|
||||||
|
|
||||||
|
function renderOverlay(request: { id: string; startedAtMs?: number; onFinish: () => void } | null) {
|
||||||
|
return render(
|
||||||
|
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advanceRecording(ms: number) {
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("RecordOverlay", () => {
|
describe("RecordOverlay", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the bottom pill with orange finish CTA and no logo", () => {
|
it("renders the bottom pill with the icon-only finish button", () => {
|
||||||
const onFinish = vi.fn();
|
const onFinish = vi.fn();
|
||||||
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish }} />);
|
const { container } = renderOverlay({ id: "rec-1", onFinish });
|
||||||
|
|
||||||
const pill = container.querySelector("[data-slot='record-overlay-pill']");
|
const pill = container.querySelector("[data-slot='record-overlay-pill']");
|
||||||
expect(pill).toBeTruthy();
|
expect(pill).toBeTruthy();
|
||||||
expect(pill?.querySelector("img")).toBeNull();
|
|
||||||
expect((pill as HTMLElement).style.borderRadius).toBe("9999px");
|
expect((pill as HTMLElement).style.borderRadius).toBe("9999px");
|
||||||
expect((pill as HTMLElement).style.backgroundColor).toBe("#fff");
|
expect((pill as HTMLElement).style.backgroundColor).toBe("#fff");
|
||||||
|
expect((pill as HTMLElement).style.gap).toBe("5px");
|
||||||
|
expect((pill as HTMLElement).style.padding).toBe("6px 8px 6px 16px");
|
||||||
|
expect((pill as HTMLElement).style.height).toBe("50px");
|
||||||
|
expect((pill as HTMLElement).style.boxSizing).toBe("border-box");
|
||||||
|
expect(container.querySelector("[data-slot='record-overlay-collapse']")).toBeNull();
|
||||||
|
|
||||||
const finish = container.querySelector("[data-slot='record-overlay-finish']");
|
const status = container.querySelector("[data-slot='record-overlay-status']") as HTMLElement;
|
||||||
|
expect(status.style.minHeight).toBe("32px");
|
||||||
|
const finish = screen.getByRole("button", {
|
||||||
|
name: i18n.t("recordOverlay.finish", { ns: "extension" }),
|
||||||
|
});
|
||||||
expect(finish).toBeTruthy();
|
expect(finish).toBeTruthy();
|
||||||
expect((finish as HTMLElement).style.backgroundColor).toBe("#f97316");
|
expect(finish.textContent).toBe("");
|
||||||
|
const icon = finish.querySelector("img");
|
||||||
|
expect(icon).toBeTruthy();
|
||||||
|
expect(icon?.getAttribute("src")).toContain("record-icon.png");
|
||||||
|
expect((finish as HTMLElement).style.width).toBe("32px");
|
||||||
|
expect((finish as HTMLElement).style.height).toBe("32px");
|
||||||
|
expect(icon?.getAttribute("width")).toBe("24");
|
||||||
|
expect(icon?.getAttribute("height")).toBe("24");
|
||||||
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
|
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
|
||||||
|
|
||||||
fireEvent.click(finish!);
|
fireEvent.click(finish);
|
||||||
expect(onFinish).toHaveBeenCalledTimes(1);
|
expect(onFinish).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
|
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
|
||||||
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish: vi.fn() }} />);
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
expect(container.querySelector("[data-slot='record-overlay']")).toBeNull();
|
expect(container.querySelector("[data-slot='record-overlay']")).toBeNull();
|
||||||
|
|
||||||
const indicator = container.querySelector("[data-slot='record-overlay-indicator']");
|
const indicator = container.querySelector("[data-slot='record-overlay-indicator']");
|
||||||
expect(indicator).toBeTruthy();
|
expect(indicator).toBeTruthy();
|
||||||
expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse");
|
expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows the recording message for ten seconds, then displays elapsed time", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
|
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||||
|
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||||
|
|
||||||
|
expect(pill.dataset.phase).toBe("intro");
|
||||||
|
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
|
||||||
|
|
||||||
|
advanceRecording(9999);
|
||||||
|
expect(pill.dataset.phase).toBe("intro");
|
||||||
|
|
||||||
|
advanceRecording(1);
|
||||||
|
expect(pill.dataset.phase).toBe("timer");
|
||||||
|
expect(label.textContent).toBe("00:10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats elapsed time as zero-padded minutes and seconds", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
|
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||||
|
|
||||||
|
advanceRecording(125_000);
|
||||||
|
expect(label.textContent).toBe("02:05");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a fixed timer label width with tabular numerals", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
|
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||||
|
|
||||||
|
advanceRecording(10_000);
|
||||||
|
expect(label.style.width).toBe("48px");
|
||||||
|
expect(label.style.maxWidth).toBe("48px");
|
||||||
|
expect(label.style.fontVariantNumeric).toBe("tabular-nums");
|
||||||
|
expect(label.style.textAlign).toBe("center");
|
||||||
|
|
||||||
|
advanceRecording(1_000);
|
||||||
|
expect(label.textContent).toBe("00:11");
|
||||||
|
expect(label.style.width).toBe("48px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the intro and elapsed time for a new request", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = { id: "rec-1", onFinish: vi.fn() };
|
||||||
|
const second = { id: "rec-2", onFinish: vi.fn() };
|
||||||
|
const { container, rerender } = renderOverlay(first);
|
||||||
|
|
||||||
|
advanceRecording(65_000);
|
||||||
|
rerender(
|
||||||
|
createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request: second })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||||
|
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||||
|
expect(pill.dataset.phase).toBe("intro");
|
||||||
|
expect(label.textContent).toBe(i18n.t("recordOverlay.recording", { ns: "extension" }));
|
||||||
|
|
||||||
|
advanceRecording(10_000);
|
||||||
|
expect(pill.dataset.phase).toBe("timer");
|
||||||
|
expect(label.textContent).toBe("00:10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues the session timer when the overlay remounts on a new page", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const startedAtMs = Date.now() - 65_000;
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", startedAtMs, onFinish: vi.fn() });
|
||||||
|
|
||||||
|
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||||
|
const label = container.querySelector("[data-slot='record-overlay-label']") as HTMLElement;
|
||||||
|
expect(pill.dataset.phase).toBe("timer");
|
||||||
|
expect(label.textContent).toBe("01:05");
|
||||||
|
|
||||||
|
advanceRecording(5_000);
|
||||||
|
expect(label.textContent).toBe("01:10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not start drag when pointer down on finish", () => {
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
|
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||||
|
const finish = container.querySelector("[data-slot='record-overlay-finish']") as HTMLElement;
|
||||||
|
|
||||||
|
fireEvent.pointerDown(finish, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
|
||||||
|
expect(pill.getAttribute("data-dragging")).toBe("false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the pill when dragging the non-button area without toggling collapse", () => {
|
||||||
|
const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() });
|
||||||
|
const pill = container.querySelector("[data-slot='record-overlay-pill']") as HTMLElement;
|
||||||
|
|
||||||
|
pill.getBoundingClientRect = () =>
|
||||||
|
({
|
||||||
|
top: 600,
|
||||||
|
left: 200,
|
||||||
|
width: 320,
|
||||||
|
height: 48,
|
||||||
|
right: 520,
|
||||||
|
bottom: 648,
|
||||||
|
}) as DOMRect;
|
||||||
|
Object.defineProperty(pill, "offsetWidth", { value: 320, configurable: true });
|
||||||
|
Object.defineProperty(pill, "offsetHeight", { value: 48, configurable: true });
|
||||||
|
|
||||||
|
fireEvent.pointerDown(pill, { button: 0, clientX: 300, clientY: 620, pointerId: 1 });
|
||||||
|
fireEvent(window, new PointerEvent("pointermove", { clientX: 360, clientY: 580 }));
|
||||||
|
fireEvent(window, new PointerEvent("pointerup", { pointerId: 1 }));
|
||||||
|
|
||||||
|
expect(pill.getAttribute("data-phase")).toBe("intro");
|
||||||
|
expect(pill.style.left).toBe("260px");
|
||||||
|
expect(pill.style.top).toBe("560px");
|
||||||
|
expect(pill.style.bottom).toBe("auto");
|
||||||
|
expect(pill.style.transform).toBe("none");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ export function handleRecordContentMessage(
|
|||||||
capture: RecordCaptureController | null;
|
capture: RecordCaptureController | null;
|
||||||
setActiveRequestId(id: string | null): void;
|
setActiveRequestId(id: string | null): void;
|
||||||
setCapture(capture: RecordCaptureController | null): void;
|
setCapture(capture: RecordCaptureController | null): void;
|
||||||
onStart(requestId: string): void;
|
onStart(requestId: string, startedAtMs?: number): void;
|
||||||
onStop(): void;
|
onStop(): void;
|
||||||
},
|
},
|
||||||
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
|
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
|
||||||
@@ -485,7 +485,7 @@ export function handleRecordContentMessage(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
state.setActiveRequestId(message.requestId);
|
state.setActiveRequestId(message.requestId);
|
||||||
state.onStart(message.requestId);
|
state.onStart(message.requestId, message.startedAtMs);
|
||||||
sendResponse?.({ ok: true });
|
sendResponse?.({ ok: true });
|
||||||
return sendResponse !== undefined;
|
return sendResponse !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { sendInterrupt } from "@/lib/overlay-interrupt-client";
|
|||||||
import {
|
import {
|
||||||
RECORD_FINISH,
|
RECORD_FINISH,
|
||||||
RECORD_QUERY,
|
RECORD_QUERY,
|
||||||
|
type RecordQueryResponse,
|
||||||
type RecordStartAck,
|
type RecordStartAck,
|
||||||
type RecordStopAck,
|
type RecordStopAck,
|
||||||
} from "@/lib/record-bridge";
|
} from "@/lib/record-bridge";
|
||||||
@@ -255,9 +256,10 @@ export default defineContentScript({
|
|||||||
setCapture: (capture) => {
|
setCapture: (capture) => {
|
||||||
recordCapture = capture;
|
recordCapture = capture;
|
||||||
},
|
},
|
||||||
onStart: (requestId) => {
|
onStart: (requestId, startedAtMs) => {
|
||||||
overlays.setAgentRecordRequest({
|
overlays.setAgentRecordRequest({
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
void chrome.runtime.sendMessage({
|
void chrome.runtime.sendMessage({
|
||||||
type: RECORD_FINISH,
|
type: RECORD_FINISH,
|
||||||
@@ -415,15 +417,17 @@ export default defineContentScript({
|
|||||||
try {
|
try {
|
||||||
const recordQuery = (await chrome.runtime.sendMessage({
|
const recordQuery = (await chrome.runtime.sendMessage({
|
||||||
type: RECORD_QUERY,
|
type: RECORD_QUERY,
|
||||||
})) as { active?: boolean; requestId?: string } | undefined;
|
})) as RecordQueryResponse | undefined;
|
||||||
if (
|
if (
|
||||||
recordQuery?.active &&
|
recordQuery?.active &&
|
||||||
typeof recordQuery.requestId === "string" &&
|
typeof recordQuery.requestId === "string" &&
|
||||||
overlays.snapshot().activeRecord === null
|
overlays.snapshot().activeRecord === null
|
||||||
) {
|
) {
|
||||||
const requestId = recordQuery.requestId;
|
const requestId = recordQuery.requestId;
|
||||||
|
const startedAtMs = recordQuery.startedAtMs;
|
||||||
overlays.setAgentRecordRequest({
|
overlays.setAgentRecordRequest({
|
||||||
id: requestId,
|
id: requestId,
|
||||||
|
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
|
||||||
onFinish: () => {
|
onFinish: () => {
|
||||||
void chrome.runtime.sendMessage({
|
void chrome.runtime.sendMessage({
|
||||||
type: RECORD_FINISH,
|
type: RECORD_FINISH,
|
||||||
|
|||||||
@@ -25,11 +25,19 @@ export interface RecordQueryMessage {
|
|||||||
export interface RecordQueryResponse {
|
export interface RecordQueryResponse {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
|
/** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */
|
||||||
|
startedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordStartMessage {
|
export interface RecordStartMessage {
|
||||||
type: typeof RECORD_START;
|
type: typeof RECORD_START;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
/**
|
||||||
|
* Epoch ms when the whole recording began, not when this tab was armed.
|
||||||
|
* The overlay timer must span the session, so it survives navigations and
|
||||||
|
* content-script remounts instead of restarting per page.
|
||||||
|
*/
|
||||||
|
startedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecordStepPayload {
|
export interface RecordStepPayload {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ActiveRecording {
|
|||||||
purpose?: string;
|
purpose?: string;
|
||||||
steps: DraftTraceStep[];
|
steps: DraftTraceStep[];
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
startedAtMs: number;
|
||||||
finishPromise: Promise<Trace>;
|
finishPromise: Promise<Trace>;
|
||||||
resolveFinish: (trace: Trace) => void;
|
resolveFinish: (trace: Trace) => void;
|
||||||
rejectFinish: (err: Error) => void;
|
rejectFinish: (err: Error) => void;
|
||||||
@@ -373,7 +374,11 @@ async function rearmRecording(
|
|||||||
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
|
||||||
// already hides the control chrome while activeRecord is set.
|
// already hides the control chrome while activeRecord is set.
|
||||||
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId: recording.requestId };
|
const startMsg: RecordStartMessage = {
|
||||||
|
type: RECORD_START,
|
||||||
|
requestId: recording.requestId,
|
||||||
|
startedAtMs: recording.startedAtMs,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
|
||||||
recording.tabId = targetTabId;
|
recording.tabId = targetTabId;
|
||||||
@@ -501,7 +506,11 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await rearmRecording(recording, tabId, deps);
|
await rearmRecording(recording, tabId, deps);
|
||||||
sendResponse({ active: true, requestId: recording.requestId });
|
sendResponse({
|
||||||
|
active: true,
|
||||||
|
requestId: recording.requestId,
|
||||||
|
startedAtMs: recording.startedAtMs,
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -572,6 +581,7 @@ export async function handleRecordStart(
|
|||||||
rejectFinish = reject;
|
rejectFinish = reject;
|
||||||
});
|
});
|
||||||
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
||||||
|
const startedAtMs = Date.now();
|
||||||
recordings.set(params.session_id, {
|
recordings.set(params.session_id, {
|
||||||
requestId,
|
requestId,
|
||||||
tabId: target.tabId,
|
tabId: target.tabId,
|
||||||
@@ -579,7 +589,8 @@ export async function handleRecordStart(
|
|||||||
startUrl: navigateUrl,
|
startUrl: navigateUrl,
|
||||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||||
steps: [],
|
steps: [],
|
||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date(startedAtMs).toISOString(),
|
||||||
|
startedAtMs,
|
||||||
finishPromise,
|
finishPromise,
|
||||||
resolveFinish,
|
resolveFinish,
|
||||||
rejectFinish,
|
rejectFinish,
|
||||||
@@ -707,7 +718,7 @@ export async function handleRecordStart(
|
|||||||
if (cancelled) return cancelled;
|
if (cancelled) return cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startMsg: RecordStartMessage = { type: RECORD_START, requestId };
|
const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
|
||||||
|
|||||||
@@ -85,7 +85,7 @@
|
|||||||
"notificationTitle": "BrowserSkill:Agent 需要你的帮助"
|
"notificationTitle": "BrowserSkill:Agent 需要你的帮助"
|
||||||
},
|
},
|
||||||
"recordOverlay": {
|
"recordOverlay": {
|
||||||
"recording": "正在录制用户操作",
|
"recording": "正在录制你的操作",
|
||||||
"finish": "结束"
|
"finish": "结束"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user