mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(runtime): incorrectly takeover the browser when user open up different tab within the same window
This commit is contained in:
@@ -15,6 +15,8 @@ export interface HelpRequestData {
|
||||
prompt: string;
|
||||
/** Custom overlay title; falls back to i18n when omitted. */
|
||||
title?: string;
|
||||
/** Full task UI on the subject tab; compact status UI on related tabs. */
|
||||
displayMode?: "full" | "compact";
|
||||
/** CSS selectors to scroll to + flash-highlight. */
|
||||
selectors: string[];
|
||||
onContinue: (note: string) => void;
|
||||
@@ -174,6 +176,8 @@ function measure(selectors: string[]): Box[] {
|
||||
|
||||
export function HelpRequestOverlay({ request }: Props) {
|
||||
const { t } = useTranslation("extension");
|
||||
const isCompact = request?.displayMode === "compact";
|
||||
const effectiveSelectors = isCompact ? [] : (request?.selectors ?? []);
|
||||
const [note, setNote] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [boxes, setBoxes] = useState<Box[]>([]);
|
||||
@@ -207,8 +211,9 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
// Scroll the first matched target into view once per distinct request id.
|
||||
useLayoutEffect(() => {
|
||||
if (!request) return;
|
||||
if (isCompact) return;
|
||||
if (lastScrolledIdRef.current === request.id) return;
|
||||
for (const sel of request.selectors) {
|
||||
for (const sel of effectiveSelectors) {
|
||||
const el = querySelectorSafe(sel);
|
||||
if (el) {
|
||||
lastScrolledIdRef.current = request.id;
|
||||
@@ -216,7 +221,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [request]);
|
||||
}, [request, isCompact, effectiveSelectors]);
|
||||
|
||||
// Keep highlight boxes aligned with the page as it scrolls / resizes.
|
||||
useEffect(() => {
|
||||
@@ -225,7 +230,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const nextBoxes = measure(request.selectors);
|
||||
const nextBoxes = measure(effectiveSelectors);
|
||||
setBoxes((prev) => (boxesEqual(prev, nextBoxes) ? prev : nextBoxes));
|
||||
};
|
||||
update();
|
||||
@@ -237,7 +242,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
window.removeEventListener("resize", update);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [request]);
|
||||
}, [request, effectiveSelectors]);
|
||||
|
||||
const onHeaderPointerDown = useCallback(
|
||||
(e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
@@ -294,8 +299,12 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
setPanelPos(null);
|
||||
return;
|
||||
}
|
||||
if (isCompact) {
|
||||
setPanelPos(null);
|
||||
return;
|
||||
}
|
||||
if (userMovedRef.current) return;
|
||||
const measured = boxes.length > 0 ? boxes : measure(request.selectors);
|
||||
const measured = boxes.length > 0 ? boxes : measure(effectiveSelectors);
|
||||
const anchor = unionBox(measured);
|
||||
const banner = bannerRef.current;
|
||||
if (!anchor || !banner) {
|
||||
@@ -319,7 +328,7 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
const nextPos = placePanel(anchor, panelW, panelH, placementRef.current.placement);
|
||||
setPanelPos((prev) => (panelPosEqual(prev, nextPos) ? prev : nextPos));
|
||||
}, [request, boxes, collapsed]);
|
||||
}, [request, boxes, collapsed, isCompact, effectiveSelectors]);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
@@ -364,6 +373,13 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] {
|
||||
width: min(360px, calc(100vw - ${VIEWPORT_MARGIN * 2}px));
|
||||
padding: 12px 14px;
|
||||
gap: 0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-drag-strip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -409,6 +425,10 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bsk-help-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
@@ -419,6 +439,17 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.bsk-help-compact-title {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-collapsed="true"] .bsk-help-title {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -562,6 +593,17 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-cancel,
|
||||
.bsk-help-banner[data-display-mode="compact"] .bsk-help-header-actions .bsk-help-btn-continue {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bsk-help-footer-actions .bsk-help-btn-cancel {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
@@ -584,29 +626,31 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{boxes.map((b, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
|
||||
key={i}
|
||||
data-slot="help-highlight"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: b.top,
|
||||
left: b.left,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
borderRadius: 6,
|
||||
zIndex: 2147483646,
|
||||
pointerEvents: "none",
|
||||
animation: "bsk-help-flash 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{!isCompact &&
|
||||
boxes.map((b, i) => (
|
||||
<div
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: boxes are positional
|
||||
key={i}
|
||||
data-slot="help-highlight"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: b.top,
|
||||
left: b.left,
|
||||
width: b.width,
|
||||
height: b.height,
|
||||
borderRadius: 6,
|
||||
zIndex: 2147483646,
|
||||
pointerEvents: "none",
|
||||
animation: "bsk-help-flash 1.2s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div
|
||||
ref={bannerRef}
|
||||
data-slot="help-request-banner"
|
||||
data-collapsed={collapsed ? "true" : "false"}
|
||||
data-display-mode={isCompact ? "compact" : "full"}
|
||||
data-anchored={panelPos ? "true" : "false"}
|
||||
data-dragging={dragging ? "true" : "false"}
|
||||
data-placement={panelPos?.placement}
|
||||
@@ -629,15 +673,17 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
data-slot="help-drag-handle"
|
||||
className="bsk-help-drag-strip"
|
||||
role="img"
|
||||
aria-label={t("helpRequest.dragHandle")}
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
>
|
||||
<span className="bsk-help-drag-pill" aria-hidden />
|
||||
</div>
|
||||
{!isCompact && (
|
||||
<div
|
||||
data-slot="help-drag-handle"
|
||||
className="bsk-help-drag-strip"
|
||||
role="img"
|
||||
aria-label={t("helpRequest.dragHandle")}
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
>
|
||||
<span className="bsk-help-drag-pill" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bsk-help-header">
|
||||
<img
|
||||
@@ -645,77 +691,87 @@ export function HelpRequestOverlay({ request }: Props) {
|
||||
alt="browser-skill"
|
||||
style={{ width: 22, height: 22, borderRadius: 4 }}
|
||||
/>
|
||||
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
|
||||
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
|
||||
{isCompact ? (
|
||||
<span className="bsk-help-compact-title">{t("helpRequest.compactStatus")}</span>
|
||||
) : (
|
||||
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-cancel-button" : undefined}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-continue-button" : undefined}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!isCompact && (
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-cancel-button" : undefined}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onCancel()}
|
||||
data-slot="help-collapse-toggle"
|
||||
className="bsk-help-collapse-toggle"
|
||||
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
<span className="bsk-help-collapse-icon">
|
||||
<RiArrowDownSLine size={20} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? "help-continue-button" : undefined}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="help-collapse-toggle"
|
||||
className="bsk-help-collapse-toggle"
|
||||
aria-label={collapsed ? t("helpRequest.expand") : t("helpRequest.collapse")}
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
>
|
||||
<span className="bsk-help-collapse-icon">
|
||||
<RiArrowDownSLine size={20} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bsk-help-body" aria-hidden={collapsed}>
|
||||
<div className="bsk-help-body-inner">
|
||||
<div className="bsk-help-body-content">
|
||||
<p className="bsk-help-prompt">{request.prompt}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="bsk-help-note-input"
|
||||
aria-label={t("helpRequest.noteLabel")}
|
||||
placeholder={t("helpRequest.notePlaceholder")}
|
||||
value={note}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<div className="bsk-help-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-cancel-button"}
|
||||
className="bsk-help-btn-cancel"
|
||||
{!isCompact && (
|
||||
<div className="bsk-help-body" aria-hidden={collapsed}>
|
||||
<div className="bsk-help-body-inner">
|
||||
<div className="bsk-help-body-content">
|
||||
<p className="bsk-help-prompt">{request.prompt}</p>
|
||||
<input
|
||||
type="text"
|
||||
className="bsk-help-note-input"
|
||||
aria-label={t("helpRequest.noteLabel")}
|
||||
placeholder={t("helpRequest.notePlaceholder")}
|
||||
value={note}
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-continue-button"}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
<div className="bsk-help-footer-actions">
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-cancel-button"}
|
||||
className="bsk-help-btn-cancel"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onCancel()}
|
||||
>
|
||||
{t("helpRequest.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={collapsed ? undefined : "help-continue-button"}
|
||||
className="bsk-help-btn-continue"
|
||||
tabIndex={collapsed ? -1 : 0}
|
||||
onClick={() => request.onContinue(note)}
|
||||
>
|
||||
{t("helpRequest.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,31 @@ describe("HelpRequestOverlay", () => {
|
||||
expect(screen.getByText("Please complete the captcha")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders a compact status without full request controls", () => {
|
||||
const el = document.createElement("div");
|
||||
el.id = "login";
|
||||
el.getBoundingClientRect = () =>
|
||||
({ top: 10, left: 10, width: 100, height: 40, right: 110, bottom: 50 }) as DOMRect;
|
||||
document.body.append(el);
|
||||
|
||||
const { container } = renderOverlay(
|
||||
baseRequest({
|
||||
displayMode: "compact",
|
||||
selectors: ["#login"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText(i18n.t("helpRequest.compactStatus", { ns: "extension" }))).toBeTruthy();
|
||||
expect(screen.queryByText("Please complete the captcha")).toBeNull();
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
expect(screen.queryByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }))).toBeNull();
|
||||
expect(container.querySelector("[data-slot='help-continue-button']")).toBeNull();
|
||||
expect(container.querySelector("[data-slot='help-cancel-button']")).toBeNull();
|
||||
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(0);
|
||||
|
||||
el.remove();
|
||||
});
|
||||
|
||||
it("renders a custom title when provided", () => {
|
||||
renderOverlay(baseRequest({ title: "Verify your identity" }));
|
||||
expect(screen.getByText("Verify your identity")).toBeTruthy();
|
||||
|
||||
@@ -233,6 +233,7 @@ export default defineContentScript({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) =>
|
||||
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
|
||||
@@ -294,6 +295,7 @@ export default defineContentScript({
|
||||
id: helpMsg.requestId,
|
||||
prompt: helpMsg.prompt,
|
||||
...(helpMsg.title ? { title: helpMsg.title } : {}),
|
||||
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
|
||||
selectors: helpMsg.selectors,
|
||||
onContinue: (note: string) =>
|
||||
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("help-bridge", () => {
|
||||
type: HELP_REQUEST,
|
||||
requestId: "r1",
|
||||
prompt: "log in",
|
||||
displayMode: "compact",
|
||||
selectors: ["#login"],
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface HelpRequestMessage {
|
||||
prompt: string;
|
||||
/** Custom overlay title; omitted when the extension should use its default. */
|
||||
title?: string;
|
||||
/** Full task UI on the subject tab; compact status UI on related tabs. */
|
||||
displayMode?: "full" | "compact";
|
||||
/** CSS selectors to scroll to + flash-highlight (may be empty). */
|
||||
selectors: string[];
|
||||
timeoutMs: number;
|
||||
@@ -69,6 +71,7 @@ export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
|
||||
typeof m.requestId === "string" &&
|
||||
typeof m.prompt === "string" &&
|
||||
(m.title === undefined || typeof m.title === "string") &&
|
||||
(m.displayMode === undefined || m.displayMode === "full" || m.displayMode === "compact") &&
|
||||
Array.isArray(m.selectors) &&
|
||||
m.selectors.every((selector) => typeof selector === "string") &&
|
||||
typeof m.timeoutMs === "number"
|
||||
|
||||
@@ -1,7 +1,53 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SessionManager } from "@/session-manager/manager";
|
||||
import type { RequestHelpParams } from "@/transport/types";
|
||||
import { handleRequestHelp, type RequestHelpDeps } from "../human-loop";
|
||||
import { handleRequestHelp, type RequestHelpDeps, resetHelpLifecycleForTests } from "../human-loop";
|
||||
|
||||
function chromeEvent<T extends (...args: never[]) => unknown>() {
|
||||
const listeners = new Set<T>();
|
||||
return {
|
||||
addListener: vi.fn((listener: T) => {
|
||||
listeners.add(listener);
|
||||
}),
|
||||
removeListener: vi.fn((listener: T) => {
|
||||
listeners.delete(listener);
|
||||
}),
|
||||
emit: (...args: Parameters<T>) => {
|
||||
for (const listener of [...listeners]) listener(...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installHelpLifecycleChrome() {
|
||||
const runtimeOnMessage =
|
||||
chromeEvent<
|
||||
(
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => unknown
|
||||
>();
|
||||
const tabsOnActivated = chromeEvent<(activeInfo: chrome.tabs.TabActiveInfo) => unknown>();
|
||||
const tabsOnCreated = chromeEvent<(tab: chrome.tabs.Tab) => unknown>();
|
||||
const tabsOnUpdated =
|
||||
chromeEvent<(tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => unknown>();
|
||||
const webNavigationOnCompleted =
|
||||
chromeEvent<(details: chrome.webNavigation.WebNavigationFramedCallbackDetails) => unknown>();
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: { onMessage: runtimeOnMessage },
|
||||
tabs: { onActivated: tabsOnActivated, onCreated: tabsOnCreated, onUpdated: tabsOnUpdated },
|
||||
webNavigation: { onCompleted: webNavigationOnCompleted },
|
||||
});
|
||||
|
||||
return {
|
||||
runtimeOnMessage,
|
||||
tabsOnActivated,
|
||||
tabsOnCreated,
|
||||
tabsOnUpdated,
|
||||
webNavigationOnCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeManager(sessionId: string, agentWindowId: number, tabId: number) {
|
||||
const mgr = {
|
||||
@@ -35,6 +81,11 @@ function baseDeps(over: Partial<RequestHelpDeps> = {}): RequestHelpDeps {
|
||||
}
|
||||
|
||||
describe("handleRequestHelp", () => {
|
||||
afterEach(() => {
|
||||
resetHelpLifecycleForTests();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects unknown session", async () => {
|
||||
const res = await handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
@@ -146,6 +197,126 @@ describe("handleRequestHelp", () => {
|
||||
expect(res).toMatchObject({ outcome: "completed", completed_by: "system", tab_id: 5 });
|
||||
});
|
||||
|
||||
it("keeps user control active on new tabs opened in the agent window", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chromeEvents = installHelpLifecycleChrome();
|
||||
const sendToTab = vi.fn(async () => ({ type: "bsk-help-ack", ok: true }));
|
||||
const deps = baseDeps({
|
||||
autoAttachLifecycle: undefined,
|
||||
sendToTab,
|
||||
tabsApi: {
|
||||
get: vi.fn(async (tabId: number) => ({
|
||||
id: tabId,
|
||||
windowId: 99,
|
||||
active: tabId === 6,
|
||||
url: tabId === 6 ? "https://app.example/reset/success" : "https://app.example/login",
|
||||
})) as never,
|
||||
query: vi.fn(async () => [{ id: 5, windowId: 99, active: true }] as never),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const pending = handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({
|
||||
tab_id: 5,
|
||||
timeout_ms: 1_000,
|
||||
completion_criteria: {
|
||||
any: [{ url_contains: "/reset/success" }],
|
||||
stable_for_ms: 0,
|
||||
},
|
||||
}),
|
||||
deps,
|
||||
);
|
||||
await vi.waitFor(() => expect(sendToTab).toHaveBeenCalledWith(5, expect.anything()));
|
||||
|
||||
chromeEvents.tabsOnActivated.emit({ tabId: 6, windowId: 99 });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(sendToTab).toHaveBeenCalledWith(
|
||||
6,
|
||||
expect.objectContaining({
|
||||
type: "bsk-help-request",
|
||||
displayMode: "compact",
|
||||
selectors: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
outcome: "completed",
|
||||
completed_by: "system",
|
||||
tab_id: 6,
|
||||
});
|
||||
expect(sendToTab).toHaveBeenCalledWith(
|
||||
6,
|
||||
expect.objectContaining({ type: "bsk-help-cancel" }),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets same-window tabs query active help after content-script load", async () => {
|
||||
const chromeEvents = installHelpLifecycleChrome();
|
||||
const ac = new AbortController();
|
||||
const deps = baseDeps({
|
||||
autoAttachLifecycle: undefined,
|
||||
signal: ac.signal,
|
||||
sendToTab: vi.fn(async () => ({ type: "bsk-help-ack", ok: true })),
|
||||
cdp: {
|
||||
send: vi.fn(async () => ({ root: { nodeId: 1 }, nodeIds: [] })),
|
||||
} as unknown as RequestHelpDeps["cdp"],
|
||||
});
|
||||
|
||||
const pending = handleRequestHelp(
|
||||
fakeManager("abcd", 99, 5),
|
||||
baseParams({ tab_id: 5, timeout_ms: 60_000 }),
|
||||
deps,
|
||||
);
|
||||
await vi.waitFor(() => expect(deps.sendToTab).toHaveBeenCalledWith(5, expect.anything()));
|
||||
|
||||
const sameWindowResponse = vi.fn();
|
||||
chromeEvents.runtimeOnMessage.emit(
|
||||
{ type: "bsk-help-query" },
|
||||
{ tab: { id: 6 } as chrome.tabs.Tab },
|
||||
sameWindowResponse,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(sameWindowResponse).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
request: expect.objectContaining({
|
||||
requestId: expect.any(String),
|
||||
prompt: "log in",
|
||||
displayMode: "compact",
|
||||
selectors: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const subjectResponse = vi.fn();
|
||||
chromeEvents.runtimeOnMessage.emit(
|
||||
{ type: "bsk-help-query" },
|
||||
{ tab: { id: 5 } as chrome.tabs.Tab },
|
||||
subjectResponse,
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(subjectResponse).toHaveBeenCalledWith({
|
||||
active: true,
|
||||
request: expect.objectContaining({
|
||||
requestId: expect.any(String),
|
||||
prompt: "log in",
|
||||
displayMode: "full",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
ac.abort();
|
||||
await expect(pending).resolves.toMatchObject({ code: "cancelled" });
|
||||
});
|
||||
|
||||
it("tags ref targets via CDP and reports them matched", async () => {
|
||||
const mgr = {
|
||||
get: (id: string) =>
|
||||
|
||||
@@ -66,11 +66,11 @@ export interface RequestHelpDeps {
|
||||
}
|
||||
|
||||
interface ActiveHelpRequest {
|
||||
sessionId: string;
|
||||
ctx: SessionContext;
|
||||
requestId: string;
|
||||
tabId: number;
|
||||
agentWindowId: number;
|
||||
subjectTabId: number;
|
||||
primaryTabId: number;
|
||||
overlayTabIds: Set<number>;
|
||||
prompt: string;
|
||||
title?: string;
|
||||
targets: HelpTarget[];
|
||||
@@ -127,12 +127,14 @@ function sleep(ms: number): Promise<void> {
|
||||
}
|
||||
|
||||
function helpRequestMessage(help: ActiveHelpRequest): HelpRequestMessage {
|
||||
const isPrimaryTab = help.subjectTabId === help.primaryTabId;
|
||||
return {
|
||||
type: HELP_REQUEST,
|
||||
requestId: help.requestId,
|
||||
prompt: help.prompt,
|
||||
...(help.title ? { title: help.title } : {}),
|
||||
selectors: help.selectors,
|
||||
displayMode: isPrimaryTab ? "full" : "compact",
|
||||
selectors: isPrimaryTab ? help.selectors : [],
|
||||
timeoutMs: help.timeoutMs,
|
||||
};
|
||||
}
|
||||
@@ -275,10 +277,10 @@ async function resolveHelpTargets(
|
||||
}
|
||||
|
||||
async function refreshHelpTargets(help: ActiveHelpRequest): Promise<void> {
|
||||
await clearRefTags(help.deps.cdp, help.tabId);
|
||||
await clearRefTags(help.deps.cdp, help.subjectTabId);
|
||||
const { selectors, resolvedTargets } = await resolveHelpTargets(
|
||||
help.ctx,
|
||||
help.tabId,
|
||||
help.subjectTabId,
|
||||
help.targets,
|
||||
help.deps,
|
||||
);
|
||||
@@ -287,13 +289,16 @@ async function refreshHelpTargets(help: ActiveHelpRequest): Promise<void> {
|
||||
}
|
||||
|
||||
async function cleanupHelp(help: ActiveHelpRequest): Promise<void> {
|
||||
await clearRefTags(help.deps.cdp, help.tabId);
|
||||
await clearRefTags(help.deps.cdp, help.subjectTabId);
|
||||
if (help.deps.notifications) {
|
||||
await help.deps.notifications.clear(help.notificationId).catch(() => {});
|
||||
}
|
||||
await help.deps
|
||||
.sendToTab(help.tabId, { type: HELP_CANCEL, requestId: help.requestId })
|
||||
.catch(() => {});
|
||||
const tabsToCancel = new Set([help.subjectTabId, ...help.overlayTabIds]);
|
||||
await Promise.all(
|
||||
[...tabsToCancel].map((tabId) =>
|
||||
help.deps.sendToTab(tabId, { type: HELP_CANCEL, requestId: help.requestId }).catch(() => {}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function finishHelp(
|
||||
@@ -307,14 +312,17 @@ function finishHelp(
|
||||
clearTimeout(help.timeoutTimer);
|
||||
if (help.completionTimer) clearInterval(help.completionTimer);
|
||||
if (help.abortHandler) help.deps.signal?.removeEventListener("abort", help.abortHandler);
|
||||
clearRearmTimer(help.tabId);
|
||||
clearRearmTimer(help.subjectTabId);
|
||||
for (const tabId of help.overlayTabIds) clearRearmTimer(tabId);
|
||||
if (notifyContent) void cleanupHelp(help);
|
||||
help.resolve(value);
|
||||
}
|
||||
|
||||
function findHelpByTabId(tabId: number): ActiveHelpRequest | null {
|
||||
function findHelpBySubjectTabId(tabId: number): ActiveHelpRequest | null {
|
||||
for (const help of activeHelpRequests.values()) {
|
||||
if (!help.settled && help.tabId === tabId) return help;
|
||||
if (!help.settled && (help.subjectTabId === tabId || help.overlayTabIds.has(tabId))) {
|
||||
return help;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -323,7 +331,7 @@ async function findHelpForTab(
|
||||
tabId: number,
|
||||
deps: RequestHelpDeps,
|
||||
): Promise<ActiveHelpRequest | null> {
|
||||
const direct = findHelpByTabId(tabId);
|
||||
const direct = findHelpBySubjectTabId(tabId);
|
||||
if (direct) return direct;
|
||||
|
||||
try {
|
||||
@@ -331,7 +339,7 @@ async function findHelpForTab(
|
||||
const windowId = tab.windowId;
|
||||
if (typeof windowId !== "number") return null;
|
||||
for (const help of activeHelpRequests.values()) {
|
||||
if (!help.settled && help.agentWindowId === windowId) return help;
|
||||
if (!help.settled && help.ctx.agentWindowId === windowId) return help;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
@@ -346,18 +354,32 @@ function clearRearmTimer(tabId: number): void {
|
||||
helpRearmTimers.delete(tabId);
|
||||
}
|
||||
|
||||
async function rearmHelp(help: ActiveHelpRequest, targetTabId: number): Promise<boolean> {
|
||||
async function sendCurrentHelpOverlay(help: ActiveHelpRequest): Promise<HelpFinishMessage | null> {
|
||||
const legacyFinish = await sendHelpRequestWithAck(
|
||||
help.subjectTabId,
|
||||
helpRequestMessage(help),
|
||||
help.deps,
|
||||
);
|
||||
help.overlayTabIds.add(help.subjectTabId);
|
||||
return legacyFinish;
|
||||
}
|
||||
|
||||
async function refreshAndSendHelpOverlay(
|
||||
help: ActiveHelpRequest,
|
||||
): Promise<HelpFinishMessage | null> {
|
||||
await refreshHelpTargets(help);
|
||||
return sendCurrentHelpOverlay(help);
|
||||
}
|
||||
|
||||
async function rearmHelp(help: ActiveHelpRequest): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < HELP_REARM_MAX_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
help.tabId = targetTabId;
|
||||
await refreshHelpTargets(help);
|
||||
const msg = helpRequestMessage(help);
|
||||
const legacyFinish = await sendHelpRequestWithAck(targetTabId, msg, help.deps);
|
||||
const legacyFinish = await refreshAndSendHelpOverlay(help);
|
||||
if (legacyFinish && !help.settled) {
|
||||
finishHelp(help, {
|
||||
outcome: legacyFinish.outcome,
|
||||
...(legacyFinish.note ? { note: legacyFinish.note } : {}),
|
||||
tab_id: help.tabId,
|
||||
tab_id: help.subjectTabId,
|
||||
resolved_targets: help.resolvedTargets,
|
||||
});
|
||||
}
|
||||
@@ -378,7 +400,9 @@ function scheduleRearmForTab(tabId: number, deps: RequestHelpDeps): void {
|
||||
helpRearmTimers.delete(tabId);
|
||||
void (async () => {
|
||||
const help = await findHelpForTab(tabId, deps);
|
||||
if (help) await rearmHelp(help, tabId);
|
||||
if (!help) return;
|
||||
help.subjectTabId = tabId;
|
||||
await rearmHelp(help);
|
||||
})();
|
||||
}, HELP_REARM_DEBOUNCE_MS),
|
||||
);
|
||||
@@ -433,15 +457,17 @@ function attachHelpRuntimeListener(deps: RequestHelpDeps): () => void {
|
||||
sendResponse({ active: false });
|
||||
return;
|
||||
}
|
||||
help.tabId = tabId;
|
||||
help.subjectTabId = tabId;
|
||||
await refreshHelpTargets(help);
|
||||
help.overlayTabIds.add(tabId);
|
||||
sendResponse({
|
||||
active: true,
|
||||
request: {
|
||||
requestId: help.requestId,
|
||||
prompt: help.prompt,
|
||||
...(help.title ? { title: help.title } : {}),
|
||||
selectors: help.selectors,
|
||||
displayMode: tabId === help.primaryTabId ? "full" : "compact",
|
||||
selectors: tabId === help.primaryTabId ? help.selectors : [],
|
||||
timeoutMs: help.timeoutMs,
|
||||
},
|
||||
});
|
||||
@@ -466,13 +492,14 @@ async function finishHelpFromContent(
|
||||
if (tabId !== undefined) {
|
||||
const match = await findHelpForTab(tabId, deps);
|
||||
if (match !== help) return;
|
||||
help.subjectTabId = tabId;
|
||||
}
|
||||
finishHelp(
|
||||
help,
|
||||
{
|
||||
outcome: message.outcome,
|
||||
...(message.note ? { note: message.note } : {}),
|
||||
tab_id: help.tabId,
|
||||
tab_id: help.subjectTabId,
|
||||
resolved_targets: help.resolvedTargets,
|
||||
},
|
||||
false,
|
||||
@@ -481,15 +508,8 @@ async function finishHelpFromContent(
|
||||
|
||||
function attachHelpTabListener(deps: RequestHelpDeps): () => void {
|
||||
const onCreated = (tab: chrome.tabs.Tab) => {
|
||||
const tabId = tab.id;
|
||||
const windowId = tab.windowId;
|
||||
if (tabId === undefined || windowId === undefined) return;
|
||||
for (const help of activeHelpRequests.values()) {
|
||||
if (!help.settled && help.agentWindowId === windowId) {
|
||||
scheduleRearmForTab(tabId, deps);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof tab.id !== "number") return;
|
||||
scheduleRearmForTab(tab.id, deps);
|
||||
};
|
||||
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
|
||||
scheduleRearmForTab(activeInfo.tabId, deps);
|
||||
@@ -531,7 +551,7 @@ async function evaluateCompletionCondition(
|
||||
if (condition.url_contains || condition.url_matches) {
|
||||
let url = "";
|
||||
try {
|
||||
const tab = await help.deps.tabsApi.get(help.tabId);
|
||||
const tab = await help.deps.tabsApi.get(help.subjectTabId);
|
||||
url = tab.url ?? "";
|
||||
} catch {
|
||||
return false;
|
||||
@@ -567,7 +587,7 @@ async function evaluateCompletionCondition(
|
||||
})()`;
|
||||
try {
|
||||
const result = await help.deps.cdp.send<{ result?: { value?: boolean } }>(
|
||||
help.tabId,
|
||||
help.subjectTabId,
|
||||
"Runtime.evaluate",
|
||||
{ expression, returnByValue: true },
|
||||
);
|
||||
@@ -619,7 +639,7 @@ function startCompletionPolling(help: ActiveHelpRequest): void {
|
||||
finishHelp(help, {
|
||||
outcome: "completed",
|
||||
completed_by: "system",
|
||||
tab_id: help.tabId,
|
||||
tab_id: help.subjectTabId,
|
||||
resolved_targets: help.resolvedTargets,
|
||||
});
|
||||
};
|
||||
@@ -677,17 +697,17 @@ export async function handleRequestHelp(
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
finishHelp(help, {
|
||||
outcome: "timed_out",
|
||||
tab_id: help.tabId,
|
||||
tab_id: help.subjectTabId,
|
||||
resolved_targets: help.resolvedTargets,
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
const help: ActiveHelpRequest = {
|
||||
sessionId: params.session_id,
|
||||
ctx,
|
||||
requestId,
|
||||
tabId,
|
||||
agentWindowId: ctx.agentWindowId,
|
||||
subjectTabId: tabId,
|
||||
primaryTabId: tabId,
|
||||
overlayTabIds: new Set(),
|
||||
prompt: params.prompt,
|
||||
...(params.title ? { title: params.title } : {}),
|
||||
targets: initialTargets,
|
||||
@@ -715,13 +735,13 @@ export async function handleRequestHelp(
|
||||
deps.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
startCompletionPolling(help);
|
||||
|
||||
void sendHelpRequestWithAck(tabId, helpRequestMessage(help), deps)
|
||||
void sendCurrentHelpOverlay(help)
|
||||
.then((legacyFinish) => {
|
||||
if (!legacyFinish || help.settled) return;
|
||||
finishHelp(help, {
|
||||
outcome: legacyFinish.outcome,
|
||||
...(legacyFinish.note ? { note: legacyFinish.note } : {}),
|
||||
tab_id: help.tabId,
|
||||
tab_id: help.subjectTabId,
|
||||
resolved_targets: help.resolvedTargets,
|
||||
});
|
||||
})
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
},
|
||||
"helpRequest": {
|
||||
"title": "Agent needs your help",
|
||||
"compactStatus": "Agent is still waiting for you to finish logging in",
|
||||
"noteLabel": "Optional note back to the agent",
|
||||
"notePlaceholder": "Add a note for the agent (optional)",
|
||||
"continue": "Done, return control",
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
},
|
||||
"helpRequest": {
|
||||
"title": "Agent 需要你的帮助",
|
||||
"compactStatus": "当前 Agent 仍在等待你完成登录",
|
||||
"noteLabel": "给 Agent 的备注(可选)",
|
||||
"notePlaceholder": "给 Agent 留言(可选)",
|
||||
"continue": "完成并交还控制权",
|
||||
|
||||
Reference in New Issue
Block a user