diff --git a/apps/extension/assets/record-icon.png b/apps/extension/assets/record-icon.png new file mode 100644 index 0000000..5a2c20a Binary files /dev/null and b/apps/extension/assets/record-icon.png differ diff --git a/apps/extension/src/content/RecordOverlay.tsx b/apps/extension/src/content/RecordOverlay.tsx index a083952..6cd2578 100644 --- a/apps/extension/src/content/RecordOverlay.tsx +++ b/apps/extension/src/content/RecordOverlay.tsx @@ -1,9 +1,21 @@ import { useTranslation } from "@browser-skill/i18n/react"; -import { RiStopCircleLine } from "@remixicon/react"; -import { useEffect, useState } from "react"; +import { + type PointerEvent as ReactPointerEvent, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import recordIconUrl from "../../assets/record-icon.png"; export interface RecordRequestData { 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; } @@ -11,9 +23,51 @@ type Props = { 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) { const { t } = useTranslation("extension"); 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(null); + const dragStartRef = useRef<{ + pointerX: number; + pointerY: number; + originLeft: number; + originTop: number; + panelW: number; + panelH: number; + armed: boolean; + } | null>(null); useEffect(() => { if (request) { @@ -23,8 +77,87 @@ export function RecordOverlay({ request }: Props) { setShow(false); }, [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) => { + 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; + const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS; + const positioned = dragPos !== null; + const defaultTransform = show + ? "translateX(-50%) translateY(0)" + : "translateX(-50%) translateY(8px)"; + return ( <>
- - - {t("recordOverlay.recording")} - + + + {introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)} + +
diff --git a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx index 9ed5e5a..0f2d178 100644 --- a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx @@ -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 { 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", () => { afterEach(() => { 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 { container } = render(); + const { container } = renderOverlay({ id: "rec-1", onFinish }); const pill = container.querySelector("[data-slot='record-overlay-pill']"); expect(pill).toBeTruthy(); - expect(pill?.querySelector("img")).toBeNull(); expect((pill as HTMLElement).style.borderRadius).toBe("9999px"); 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 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"); - fireEvent.click(finish!); + fireEvent.click(finish); expect(onFinish).toHaveBeenCalledTimes(1); }); it("shows a pulsing recording indicator and no full-screen glow layer", () => { - const { container } = render(); + const { container } = renderOverlay({ id: "rec-1", onFinish: vi.fn() }); expect(container.querySelector("[data-slot='record-overlay']")).toBeNull(); const indicator = container.querySelector("[data-slot='record-overlay-indicator']"); expect(indicator).toBeTruthy(); 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"); + }); }); diff --git a/apps/extension/src/content/record-capture.ts b/apps/extension/src/content/record-capture.ts index 12bf6af..07e3e1a 100644 --- a/apps/extension/src/content/record-capture.ts +++ b/apps/extension/src/content/record-capture.ts @@ -468,7 +468,7 @@ export function handleRecordContentMessage( capture: RecordCaptureController | null; setActiveRequestId(id: string | null): void; setCapture(capture: RecordCaptureController | null): void; - onStart(requestId: string): void; + onStart(requestId: string, startedAtMs?: number): void; onStop(): void; }, sendResponse?: (response: RecordStartAck | RecordStopAck) => void, @@ -485,7 +485,7 @@ export function handleRecordContentMessage( }), ); state.setActiveRequestId(message.requestId); - state.onStart(message.requestId); + state.onStart(message.requestId, message.startedAtMs); sendResponse?.({ ok: true }); return sendResponse !== undefined; } diff --git a/apps/extension/src/entrypoints/content.ts b/apps/extension/src/entrypoints/content.ts index cd576a6..d31c10d 100644 --- a/apps/extension/src/entrypoints/content.ts +++ b/apps/extension/src/entrypoints/content.ts @@ -39,6 +39,7 @@ import { sendInterrupt } from "@/lib/overlay-interrupt-client"; import { RECORD_FINISH, RECORD_QUERY, + type RecordQueryResponse, type RecordStartAck, type RecordStopAck, } from "@/lib/record-bridge"; @@ -255,9 +256,10 @@ export default defineContentScript({ setCapture: (capture) => { recordCapture = capture; }, - onStart: (requestId) => { + onStart: (requestId, startedAtMs) => { overlays.setAgentRecordRequest({ id: requestId, + ...(typeof startedAtMs === "number" ? { startedAtMs } : {}), onFinish: () => { void chrome.runtime.sendMessage({ type: RECORD_FINISH, @@ -415,15 +417,17 @@ export default defineContentScript({ try { const recordQuery = (await chrome.runtime.sendMessage({ type: RECORD_QUERY, - })) as { active?: boolean; requestId?: string } | undefined; + })) as RecordQueryResponse | undefined; if ( recordQuery?.active && typeof recordQuery.requestId === "string" && overlays.snapshot().activeRecord === null ) { const requestId = recordQuery.requestId; + const startedAtMs = recordQuery.startedAtMs; overlays.setAgentRecordRequest({ id: requestId, + ...(typeof startedAtMs === "number" ? { startedAtMs } : {}), onFinish: () => { void chrome.runtime.sendMessage({ type: RECORD_FINISH, diff --git a/apps/extension/src/lib/record-bridge.ts b/apps/extension/src/lib/record-bridge.ts index cb5d446..59c2775 100644 --- a/apps/extension/src/lib/record-bridge.ts +++ b/apps/extension/src/lib/record-bridge.ts @@ -25,11 +25,19 @@ export interface RecordQueryMessage { export interface RecordQueryResponse { active: boolean; requestId?: string; + /** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */ + startedAtMs?: number; } export interface RecordStartMessage { type: typeof RECORD_START; 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 { diff --git a/apps/extension/src/tools/record.ts b/apps/extension/src/tools/record.ts index 4f3dde3..c797ef1 100644 --- a/apps/extension/src/tools/record.ts +++ b/apps/extension/src/tools/record.ts @@ -49,6 +49,7 @@ interface ActiveRecording { purpose?: string; steps: DraftTraceStep[]; startedAt: string; + startedAtMs: number; finishPromise: Promise; resolveFinish: (trace: Trace) => void; rejectFinish: (err: Error) => void; @@ -373,7 +374,11 @@ async function rearmRecording( // stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay // already hides the control chrome while activeRecord is set. 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 { await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab); recording.tabId = targetTabId; @@ -501,7 +506,11 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): return; } await rearmRecording(recording, tabId, deps); - sendResponse({ active: true, requestId: recording.requestId }); + sendResponse({ + active: true, + requestId: recording.requestId, + startedAtMs: recording.startedAtMs, + }); })(); return true; }; @@ -572,6 +581,7 @@ export async function handleRecordStart( rejectFinish = reject; }); const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL; + const startedAtMs = Date.now(); recordings.set(params.session_id, { requestId, tabId: target.tabId, @@ -579,7 +589,8 @@ export async function handleRecordStart( startUrl: navigateUrl, ...(params.purpose ? { purpose: params.purpose } : {}), steps: [], - startedAt: new Date().toISOString(), + startedAt: new Date(startedAtMs).toISOString(), + startedAtMs, finishPromise, resolveFinish, rejectFinish, @@ -707,7 +718,7 @@ export async function handleRecordStart( if (cancelled) return cancelled; } - const startMsg: RecordStartMessage = { type: RECORD_START, requestId }; + const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs }; try { await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab); diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 7f6f509..466e8f5 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -85,7 +85,7 @@ "notificationTitle": "BrowserSkill:Agent 需要你的帮助" }, "recordOverlay": { - "recording": "正在录制用户操作", + "recording": "正在录制你的操作", "finish": "结束" } }