fix(extension): keep record overlay timer across navigations

This commit is contained in:
“haonan”
2026-08-06 16:28:21 +08:00
parent 66684c4ce3
commit 674c53339f
6 changed files with 62 additions and 14 deletions
+16 -5
View File
@@ -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<HTMLDivElement>) => {
if (e.button !== 0) return;
@@ -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;
+2 -2
View File
@@ -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;
}
+6 -2
View File
@@ -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,
+8
View File
@@ -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 {
+15 -4
View File
@@ -49,6 +49,7 @@ interface ActiveRecording {
purpose?: string;
steps: DraftTraceStep[];
startedAt: string;
startedAtMs: number;
finishPromise: Promise<Trace>;
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);