feat(extension): emit pending hover before actions

This commit is contained in:
Ljy-0827
2026-08-06 17:47:19 +08:00
parent 73724617f9
commit d685c75d25
4 changed files with 183 additions and 19 deletions
@@ -456,6 +456,60 @@ describe("ToolDispatcher", () => {
);
});
it("limits default-target hover reassertion to the active tab", async () => {
vi.stubGlobal("chrome", {
tabs: {
query: vi.fn(async () => [{ id: 9, windowId: 4242, active: true }]),
get: vi.fn(async (tabId: number) => ({ id: tabId, windowId: 4242, active: tabId === 9 })),
sendMessage: vi.fn(async () => undefined),
},
});
const { transport } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 9,
entries: [],
next_since: 0,
truncated: false,
})),
setDeviceMetricsOverride: vi.fn(async () => {}),
clearDeviceMetricsOverride: vi.fn(async () => {}),
setUserAgentOverride: vi.fn(async () => {}),
setTouchEmulationEnabled: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
helpers.rememberHover("aa11", { tab_id: 9, x: 30, y: 40 });
await helpers.withHoverReassert({ session_id: "aa11" }, async () => ({}));
expect(cdp.send).toHaveBeenCalledTimes(1);
expect(cdp.send).toHaveBeenCalledWith(9, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 30,
y: 40,
});
});
it("releases the current session hover latch before navigation-style work", async () => {
vi.stubGlobal("chrome", {
tabs: {
+32 -12
View File
@@ -58,7 +58,7 @@ import {
type SessionStartParams,
type SessionStopParams,
} from "./session";
import { chromeTabsApi } from "./shared";
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
import {
type BorrowConfirmationApprover,
handleTabBorrow,
@@ -90,6 +90,11 @@ interface HoverLatch {
y: number;
}
interface HoverLatchScope {
session_id: string;
tab_id?: number;
}
export interface DispatcherDeps {
transport: Transport;
sessions: SessionManager;
@@ -341,23 +346,24 @@ export class ToolDispatcher {
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi } : undefined,
),
);
case "tool.observe":
return this.withHoverReassert(req.params as ObserveParams, () =>
case "tool.observe": {
const params = req.params as ObserveParams;
const hoverScope = await this.resolveHoverLatchScope(params);
return this.withHoverReassert(params, () =>
handleObserve(
this.sessions,
req.params as ObserveParams,
params,
this.cdp
? {
cdp: this.cdp,
tabsApi: chromeTabsCaptureApi,
conditionalSurfaceProbe: !this.hasHoverLatchForRequest(
req.params as ObserveParams,
),
conditionalSurfaceProbe: !this.hasHoverLatchForScope(hoverScope),
hoverProbeBypassOverlay: bypassOverlay,
}
: undefined,
),
);
}
case "tool.get_html":
return handleGetHtml(
this.sessions,
@@ -551,8 +557,8 @@ export class ToolDispatcher {
return result;
}
private hasHoverLatchForRequest(params: { session_id: string; tab_id?: number }): boolean {
return this.hoverLatchesForRequest(params).length > 0;
private hasHoverLatchForScope(scope: HoverLatchScope): boolean {
return this.hoverLatchesForRequest(scope).length > 0;
}
private async withHoverReassert<T>(
@@ -560,12 +566,13 @@ export class ToolDispatcher {
work: () => Promise<T>,
options: { releaseAfter?: boolean } = {},
): Promise<T> {
await this.reassertHover(params);
const scope = await this.resolveHoverLatchScope(params);
await this.reassertHover(scope);
try {
return await work();
} finally {
if (options.releaseAfter) {
await this.releaseHoverLatch(params.session_id, params.tab_id);
await this.releaseHoverLatch(scope.session_id, scope.tab_id);
}
}
}
@@ -574,10 +581,23 @@ export class ToolDispatcher {
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
): Promise<T> {
await this.releaseHoverLatch(params.session_id, params.tab_id);
const scope = await this.resolveHoverLatchScope(params);
await this.releaseHoverLatch(scope.session_id, scope.tab_id);
return work();
}
private async resolveHoverLatchScope(params: {
session_id: string;
tab_id?: number;
}): Promise<HoverLatchScope> {
if (params.tab_id !== undefined) return params;
const ctx = lookupSession(this.sessions, params, "hover latch");
if (isRpcError(ctx)) return params;
const target = await resolveTargetTab(this.sessions, ctx, undefined, chromeTabsApi);
if (isRpcError(target)) return params;
return { session_id: params.session_id, tab_id: target.tabId };
}
private hoverLatchesForRequest(params: { session_id: string; tab_id?: number }): HoverLatch[] {
return [...this.hoverLatches.values()].filter((latch) => {
if (latch.sessionId !== params.session_id) return false;