diff --git a/apps/extension/src/content/RecordOverlay.tsx b/apps/extension/src/content/RecordOverlay.tsx index feba317..6cd2578 100644 --- a/apps/extension/src/content/RecordOverlay.tsx +++ b/apps/extension/src/content/RecordOverlay.tsx @@ -10,6 +10,12 @@ 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; } @@ -71,20 +77,25 @@ export function RecordOverlay({ request }: Props) { setShow(false); }, [request]); + const requestStartedAtMs = request?.startedAtMs; + useEffect(() => { - setElapsedSeconds(0); setDragPos(null); setDragging(false); dragStartRef.current = null; - if (!request) return; + if (!request) { + setElapsedSeconds(0); + return; + } - const startedAt = Date.now(); + const startedAt = requestStartedAtMs ?? Date.now(); const updateElapsed = () => { - setElapsedSeconds(Math.floor((Date.now() - startedAt) / 1_000)); + 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]); + }, [request?.id, requestStartedAtMs]); const onPillPointerDown = useCallback((e: ReactPointerEvent) => { if (e.button !== 0) return; diff --git a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx index 7b8299c..0f2d178 100644 --- a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx @@ -5,7 +5,7 @@ import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { RecordOverlay } from "../RecordOverlay"; -function renderOverlay(request: { id: string; onFinish: () => void } | null) { +function renderOverlay(request: { id: string; startedAtMs?: number; onFinish: () => void } | null) { return render( createElement(I18nextProvider, { i18n }, createElement(RecordOverlay, { request })), ); @@ -129,6 +129,20 @@ describe("RecordOverlay", () => { 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; 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);