Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
284 changes: 170 additions & 114 deletions apps/extension/src/content/HelpRequestOverlay.tsx

Large diffs are not rendered by default.

34 changes: 34 additions & 0 deletions apps/extension/src/content/__tests__/HelpRequestOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -204,6 +229,15 @@ describe("HelpRequestOverlay", () => {
expect(styles).toContain("width: 0");
});

it("uses natural body height in the expanded layout", () => {
const { container } = renderOverlay(baseRequest());
const styles = overlayStyles(container);

expect(styles).toMatch(/\.bsk-help-body\s*\{[^}]*display:\s*block;/s);
expect(styles).not.toMatch(/\.bsk-help-body\s*\{[^}]*grid-template-rows/s);
expect(styles).toMatch(/\.bsk-help-banner\s*\{[^}]*justify-content:\s*flex-start;/s);
});

it("keeps the same banner width when collapsed", () => {
const { container } = renderOverlay(baseRequest());
const styles = overlayStyles(container);
Expand Down
101 changes: 73 additions & 28 deletions apps/extension/src/entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@ import {
type RecordCaptureController,
} from "@/content/record-capture";
import {
HELP_RESPONSE,
HELP_ACK,
HELP_FINISH,
HELP_QUERY,
type HelpAckMessage,
type HelpCancelMessage,
type HelpFinishMessage,
type HelpQueryResponse,
type HelpRequestMessage,
type HelpResponseMessage,
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
Expand Down Expand Up @@ -55,8 +59,6 @@ export default defineContentScript({
if (window.top !== window) return;

const overlays = new OverlayController();
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
null;
let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
let reactRoot: ReactDOM.Root | null = null;
Expand Down Expand Up @@ -116,8 +118,7 @@ export default defineContentScript({
function resetAgentOverlayState(sessionId: string) {
const previousHelp = overlays.resetAgentOverlays(sessionId);
if (previousHelp) {
activeHelpRespond?.("cancelled");
activeHelpRespond = null;
void sendHelpFinish(previousHelp.id, "cancelled");
}
recordCapture?.dispose();
recordCapture = null;
Expand Down Expand Up @@ -156,7 +157,7 @@ export default defineContentScript({
| OverlayAgentOverlayResetMessage
| OverlayAutomationBypassMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
sendResponse: (response: BorrowResponseMessage | HelpAckMessage) => void,
) => {
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
Expand Down Expand Up @@ -220,41 +221,30 @@ export default defineContentScript({
if (isHelpCancelMessage(message)) {
const state = overlays.snapshot();
if (state.activeHelp && state.activeHelp.id === message.requestId) {
activeHelpRespond?.("cancelled");
overlays.clearAgentHelpRequest(message.requestId);
renderOverlay();
}
return false;
}

if (isHelpRequestMessage(message)) {
const helpMsg = message as HelpRequestMessage;
let responded = false;
const respond = (outcome: "continued" | "cancelled", note?: string) => {
if (responded) return;
responded = true;
const reply: HelpResponseMessage = {
type: HELP_RESPONSE,
outcome,
...(note ? { note } : {}),
};
sendResponse(reply);
activeHelpRespond = null;
overlays.clearAgentHelpRequest(helpMsg.requestId);
renderOverlay();
};
const previousHelp = overlays.setAgentHelpRequest({
id: helpMsg.requestId,
prompt: helpMsg.prompt,
...(helpMsg.title ? { title: helpMsg.title } : {}),
...(helpMsg.displayMode ? { displayMode: helpMsg.displayMode } : {}),
selectors: helpMsg.selectors,
onContinue: (note: string) => respond("continued", note.trim() ? note : undefined),
onCancel: () => respond("cancelled"),
onContinue: (note: string) =>
void sendHelpFinish(helpMsg.requestId, "continued", note.trim() ? note : undefined),
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"),
});
if (previousHelp) {
activeHelpRespond?.("cancelled");
if (previousHelp && previousHelp.id !== helpMsg.requestId) {
void sendHelpFinish(previousHelp.id, "cancelled");
}
activeHelpRespond = respond;
renderOverlay();
return true; // async sendResponse
sendResponse({ type: HELP_ACK, ok: true });
return false;
}

if (message.type === "borrow-request") {
Expand Down Expand Up @@ -282,9 +272,60 @@ export default defineContentScript({
return false;
};

async function sendHelpFinish(
requestId: string,
outcome: "continued" | "cancelled",
note?: string,
): Promise<void> {
const msg: HelpFinishMessage = {
type: HELP_FINISH,
requestId,
outcome,
...(note ? { note } : {}),
};
overlays.clearAgentHelpRequest(requestId);
renderOverlay();
await chrome.runtime.sendMessage(msg).catch((err) => {
console.debug("[bsk overlay] help finish failed", err);
});
}
Comment on lines +286 to +291

function mountHelpRequest(helpMsg: Omit<HelpRequestMessage, "type">): void {
overlays.setAgentHelpRequest({
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),
onCancel: () => void sendHelpFinish(helpMsg.requestId, "cancelled"),
});
}

async function queryActiveHelpWithRetry(): Promise<boolean> {
for (let attempt = 0; attempt < 6; attempt += 1) {
try {
const helpQuery = (await chrome.runtime.sendMessage({
type: HELP_QUERY,
})) as HelpQueryResponse | undefined;
if (helpQuery?.active && helpQuery.request) {
mountHelpRequest(helpQuery.request);
renderOverlay();
return true;
}
} catch (err) {
console.debug("[bsk overlay] help query failed", err);
}
await new Promise((resolve) => window.setTimeout(resolve, 150));
}
return false;
}

async function syncAgentOverlay(): Promise<void> {
if (!(await anySessionLive())) return;
try {
const helpActive = await queryActiveHelpWithRetry();
const reply = (await chrome.runtime.sendMessage({
kind: OVERLAY_MSG_WHO_AM_I,
})) as OverlayWhoAmIResponse | undefined;
Expand Down Expand Up @@ -319,6 +360,10 @@ export default defineContentScript({
});
}

if (!helpActive && overlays.snapshot().activeHelp === null) {
void queryActiveHelpWithRetry();
}

overlays.activateAgentSession(reply.sessionId);
renderOverlay();
} catch (err) {
Expand Down
25 changes: 25 additions & 0 deletions apps/extension/src/lib/__tests__/help-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
import { describe, expect, it } from "vitest";
import {
HELP_ACK,
HELP_CANCEL,
HELP_FINISH,
HELP_QUERY,
HELP_REQUEST,
HELP_RESPONSE,
isHelpAckMessage,
isHelpCancelMessage,
isHelpFinishMessage,
isHelpQueryMessage,
isHelpRequestMessage,
isHelpResponseMessage,
} from "../help-bridge";

describe("help-bridge", () => {
it("exposes stable message type constants", () => {
expect(HELP_REQUEST).toBe("bsk-help-request");
expect(HELP_RESPONSE).toBe("bsk-help-response");
expect(HELP_CANCEL).toBe("bsk-help-cancel");
expect(HELP_ACK).toBe("bsk-help-ack");
expect(HELP_QUERY).toBe("bsk-help-query");
expect(HELP_FINISH).toBe("bsk-help-finish");
});

it("type-guards a help-request message", () => {
Expand All @@ -20,6 +30,7 @@ describe("help-bridge", () => {
type: HELP_REQUEST,
requestId: "r1",
prompt: "log in",
displayMode: "compact",
selectors: ["#login"],
timeoutMs: 1000,
}),
Expand Down Expand Up @@ -55,4 +66,18 @@ describe("help-bridge", () => {
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(false);
expect(isHelpCancelMessage(null)).toBe(false);
});

it("type-guards lifecycle messages", () => {
expect(isHelpAckMessage({ type: HELP_ACK, ok: true })).toBe(true);
expect(isHelpQueryMessage({ type: HELP_QUERY })).toBe(true);
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "continued" })).toBe(
true,
);
expect(isHelpResponseMessage({ type: HELP_RESPONSE, outcome: "cancelled", note: "no" })).toBe(
true,
);
expect(isHelpFinishMessage({ type: HELP_FINISH, requestId: "r1", outcome: "weird" })).toBe(
false,
);
});
});
62 changes: 61 additions & 1 deletion apps/extension/src/lib/help-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,34 @@
* Mirrors the borrow-confirmation message style (see
* `@/tools/borrow-confirmation`). The background asks a specific tab to
* enter "help mode" (render HelpRequestOverlay + hide ControlOverlay);
* the content script replies once the user clicks Continue / Cancel.
* the content script acks display, then later reports Done / Cancel.
*/

export const HELP_REQUEST = "bsk-help-request";
export const HELP_RESPONSE = "bsk-help-response";
export const HELP_CANCEL = "bsk-help-cancel";
export const HELP_ACK = "bsk-help-ack";
export const HELP_QUERY = "bsk-help-query";
export const HELP_FINISH = "bsk-help-finish";

export interface HelpRequestMessage {
type: typeof HELP_REQUEST;
requestId: string;
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;
}

export interface HelpAckMessage {
type: typeof HELP_ACK;
ok: true;
}

export interface HelpResponseMessage {
type: typeof HELP_RESPONSE;
outcome: "continued" | "cancelled";
Expand All @@ -35,6 +45,22 @@ export interface HelpCancelMessage {
requestId: string;
}

export interface HelpQueryMessage {
type: typeof HELP_QUERY;
}

export interface HelpQueryResponse {
active: boolean;
request?: Omit<HelpRequestMessage, "type">;
}

export interface HelpFinishMessage {
type: typeof HELP_FINISH;
requestId: string;
outcome: "continued" | "cancelled";
note?: string;
}

export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
if (typeof msg !== "object" || msg === null) {
return false;
Expand All @@ -45,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"
Expand All @@ -58,3 +85,36 @@ export function isHelpCancelMessage(msg: unknown): msg is HelpCancelMessage {
const m = msg as Record<string, unknown>;
return m.type === HELP_CANCEL && typeof m.requestId === "string";
}

export function isHelpResponseMessage(msg: unknown): msg is HelpResponseMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === HELP_RESPONSE &&
(m.outcome === "continued" || m.outcome === "cancelled") &&
(m.note === undefined || typeof m.note === "string")
);
}

export function isHelpAckMessage(msg: unknown): msg is HelpAckMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === HELP_ACK && m.ok === true;
}

export function isHelpQueryMessage(msg: unknown): msg is HelpQueryMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === HELP_QUERY;
}

export function isHelpFinishMessage(msg: unknown): msg is HelpFinishMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === HELP_FINISH &&
typeof m.requestId === "string" &&
(m.outcome === "continued" || m.outcome === "cancelled") &&
(m.note === undefined || typeof m.note === "string")
);
}
Loading