fix(extension): dismiss overlay on borrow timeout

Send borrow-cancel when the confirmation timeout denies, and add a
timer fallback so auto-deny does not depend only on CSS transitionend.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BB-fat
2026-07-18 22:31:27 +08:00
co-authored by Cursor
parent 9f82d89788
commit 11ec51dfb8
5 changed files with 95 additions and 34 deletions
@@ -62,6 +62,9 @@ function BorrowToastStack({ children }: { children: React.ReactNode }) {
);
}
/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */
const PROGRESS_TRANSITION_MS = 1000;
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
const { t } = useTranslation("extension");
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
@@ -74,17 +77,6 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
onAllowRef.current = request.onAllow;
onDenyRef.current = request.onDeny;
useEffect(() => {
if (secondsLeft <= 0) {
if (!settledRef.current) {
setAwaitingAutoDeny(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
function triggerAllow() {
if (settledRef.current) return;
settledRef.current = true;
@@ -105,6 +97,27 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
}
useEffect(() => {
if (secondsLeft <= 0) {
if (!settledRef.current) {
setAwaitingAutoDeny(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
// Prefer transitionend for visual sync, but fall back to a timer so
// background tabs / suppressed CSS transitions still auto-deny.
useEffect(() => {
if (!awaitingAutoDeny || settledRef.current) return;
const id = setTimeout(() => {
handleDeny();
}, PROGRESS_TRANSITION_MS);
return () => clearTimeout(id);
}, [awaitingAutoDeny]);
const progress = secondsLeft / totalSeconds;
const truncatedTitle =
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
@@ -38,9 +38,6 @@ describe("BorrowConfirmationOverlay", () => {
});
}
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const progressCircle = container.querySelectorAll("svg circle")[1];
expect(progressCircle).toBeTruthy();
await act(() => {
@@ -58,6 +55,42 @@ describe("BorrowConfirmationOverlay", () => {
expect(onAllow).not.toHaveBeenCalled();
});
it("auto-denies via timer fallback when progress transitionend never fires", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-fallback",
isActiveTab: true,
tabTitle: "Example",
timeoutMs: 5000,
onAllow,
onDeny,
},
]}
/>,
);
for (let i = 0; i < 5; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
// No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();