feat(extension): add popup toggle to hide Agent control hints (#81)

Add a per-browser "Control hints" switch to the popup that hides the in-page control overlay (status pill + orange glow, along with its input blocker) while the Agent controls a tab. Preference lives in chrome.storage.local (bsk_control_hints_hidden, default: shown) and is applied live in content scripts via storage.onChanged.

Both popup settings rows share a single Switch component; the hint copy lives in an info-icon tooltip.

Refs #62
This commit is contained in:
BB-fat
2026-08-13 10:24:17 +08:00
committed by GitHub
parent f81a1e7796
commit 37fbaacc94
12 changed files with 373 additions and 20 deletions
@@ -130,4 +130,31 @@ describe("OverlayController", () => {
expect(controller.snapshot().controlVisible).toBe(true);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
});
it("hides the control overlay when the user hides control hints", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
controller.setControlHintsHidden(true);
expect(controller.snapshot().controlHintsHidden).toBe(true);
// The session still owns the tab — only the chrome is hidden.
expect(controller.snapshot().controlVisible).toBe(true);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
controller.setControlHintsHidden(false);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
});
it("keeps the control-hints preference across session overlay resets", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setControlHintsHidden(true);
controller.resetAgentOverlays("sess-1");
expect(controller.snapshot().controlHintsHidden).toBe(true);
controller.activateAgentSession("sess-2");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
});
});
@@ -18,6 +18,12 @@ export interface OverlayState {
* so 「Agent 正在控制」does not flash between RecordOverlay and teardown.
*/
suppressControlAfterRecord: boolean;
/**
* User preference (chrome.storage, toggled from the popup): hide the
* control hints — status pill, orange glow, and the input blocker that
* comes with them. Not session state; survives overlay resets.
*/
controlHintsHidden: boolean;
}
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
@@ -36,6 +42,7 @@ export class OverlayController {
controlMode: "hidden",
automationBypassCount: 0,
suppressControlAfterRecord: false,
controlHintsHidden: false,
};
snapshot(): OverlayState {
@@ -129,6 +136,10 @@ export class OverlayController {
}
}
setControlHintsHidden(hidden: boolean): void {
this.state.controlHintsHidden = hidden;
}
resetAgentOverlays(sessionId: string): HelpRequestData | null {
if (this.state.activeSessionId && this.state.activeSessionId !== sessionId) {
return null;
@@ -152,6 +163,7 @@ export class OverlayController {
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
return (
state.controlVisible &&
!state.controlHintsHidden &&
!state.suppressControlAfterRecord &&
state.activeHelp === null &&
state.activeRecord === null
+23
View File
@@ -32,6 +32,7 @@ import {
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
import { getControlHintsHidden, STORAGE_KEYS } from "@/lib/instance-id";
import {
isOverlayAgentOverlayResetMessage,
isOverlayAgentStateMessage,
@@ -76,6 +77,14 @@ export default defineContentScript({
let hostLossReported = false;
let remountInProgress = false;
// Load the user's control-hints preference up front so an already-active
// Agent session does not flash the overlay before the stored value lands.
try {
overlays.setControlHintsHidden(await getControlHintsHidden());
} catch (err) {
console.debug("[bsk overlay] control-hints preference read failed", err);
}
const captureSuppress = createCaptureSuppressController(() => overlayHost);
const ui = await createShadowRootUi(ctx, {
@@ -479,6 +488,19 @@ export default defineContentScript({
if (event.persisted) void requestOverlayState();
};
// Live-apply popup toggles of the control-hints preference.
const onStorageChange = (
changes: Record<string, chrome.storage.StorageChange>,
areaName: string,
) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
if (!change) return;
overlays.setControlHintsHidden(change.newValue === true);
renderAll();
};
chrome.storage.onChanged.addListener(onStorageChange);
ui.mount();
chrome.runtime.onMessage.addListener(onMessage);
void requestOverlayState();
@@ -509,6 +531,7 @@ export default defineContentScript({
ctx.onInvalidated(() => {
hostObserver.disconnect();
chrome.runtime.onMessage.removeListener(onMessage);
chrome.storage.onChanged.removeListener(onStorageChange);
window.removeEventListener("pageshow", onPageShow);
// Restore history hooks / remove capture listeners before the CS unloads.
recordCapture?.dispose();
@@ -1,6 +1,7 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { STORAGE_KEYS } from "@/lib/instance-id";
import { EXTENSION_VERSION } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
@@ -244,3 +245,94 @@ describe("App", () => {
expect(screen.getByText("连接后可用")).toBeTruthy();
});
});
describe("control hints toggle", () => {
function stubChromeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
vi.stubGlobal("chrome", {
runtime: { lastError: undefined },
storage: {
local: {
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
const items: Record<string, unknown> = {};
for (const k of Array.isArray(keys) ? keys : [keys]) {
if (k in store) items[k] = store[k];
}
cb(items);
},
set: (items: Record<string, unknown>, cb?: () => void) => {
Object.assign(store, items);
cb?.();
},
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
});
return store;
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("renders the control hints toggle on when no preference is stored", async () => {
stubChromeStorage();
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("reflects the stored hidden preference", async () => {
stubChromeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
await waitFor(() => expect(toggle.getAttribute("aria-checked")).toBe("false"));
});
it("persists the hidden preference when the toggle is turned off", async () => {
const store = stubChromeStorage();
render(<App />);
const toggle = await screen.findByRole("switch", { name: "控制提示" });
fireEvent.click(toggle);
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
expect(toggle.getAttribute("aria-checked")).toBe("false");
});
it("keeps the hint copy in an accessible info tooltip", async () => {
stubChromeStorage();
render(<App />);
const info = await screen.findByRole("button", { name: "控制提示说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByRole("tooltip");
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
// Hidden until the info button is hovered or focused.
expect(tooltip.className).toContain("opacity-0");
});
it("uses the same switch component and size for both settings rows", async () => {
stubChromeStorage();
render(<App />);
const hintsToggle = await screen.findByRole("switch", { name: "控制提示" });
const connectionToggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
// One shared Switch component, one size — hierarchy comes from copy and
// the info icon, not control size. Both rows default to checked, so the
// class strings must be identical.
expect(hintsToggle.className).toContain("h-5 w-9");
expect(hintsToggle.className).toBe(connectionToggle.className);
});
});
+50 -20
View File
@@ -1,12 +1,20 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
import { RiArrowLeftLine, RiArrowRightSLine, RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Badge, Button, Input, Label } from "@browser-skill/ui";
import {
RiArrowLeftLine,
RiArrowRightSLine,
RiCheckLine,
RiFileCopyLine,
RiInformationLine,
} from "@remixicon/react";
import { type ChangeEvent, useEffect, useState } from "react";
import { PROTOCOL_VERSION } from "@/transport/handshake";
import functionIconUrl from "../../../assets/function.svg";
import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { POPUP_FEATURES, type PopupView } from "./features";
import { Switch } from "./switch";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
import { useControlHintsHidden } from "./use-control-hints-hidden";
const STATE_LABEL_KEYS = {
disconnected: "popup.stateLabel.disconnected",
@@ -32,6 +40,7 @@ function getLogoSrc() {
export function App() {
const { t } = useTranslation("extension");
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
const [view, setView] = useState<PopupView>("main");
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
const [purposeDraft, setPurposeDraft] = useState("");
@@ -187,26 +196,12 @@ export function App() {
>
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<button
type="button"
role="switch"
aria-checked={snapshot.connectionEnabled}
<Switch
checked={snapshot.connectionEnabled}
onCheckedChange={setConnectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
)}
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
/>
</div>
</div>
{isSkewed && (
@@ -222,6 +217,41 @@ export function App() {
)}
</section>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-control-hints-card"
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1">
<span className="truncate text-sm font-medium">
{t("popup.controlHintsToggleTitle")}
</span>
<span className="group relative inline-flex shrink-0">
<button
type="button"
aria-label={t("popup.controlHintsInfoLabel")}
data-slot="popup-control-hints-info"
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<RiInformationLine className="size-3.5" aria-hidden />
</button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
{t("popup.controlHintsToggleHint")}
</span>
</span>
</span>
<Switch
checked={!controlHintsHidden}
onCheckedChange={(shown) => setControlHintsHidden(!shown)}
aria-label={t("popup.controlHintsToggleTitle")}
data-slot="popup-control-hints-toggle"
/>
</div>
</section>
{snapshot.lastError && (
<div
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
@@ -0,0 +1,29 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Switch } from "./switch";
describe("Switch", () => {
afterEach(() => {
cleanup();
});
it("renders switch semantics with the checked state", () => {
render(<Switch checked={true} onCheckedChange={vi.fn()} aria-label="开关" />);
const toggle = screen.getByRole("switch", { name: "开关" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(toggle.className).toContain("h-5 w-9");
expect(toggle.className).toContain("bg-primary");
});
it("calls onCheckedChange with the negated state when clicked", () => {
const onCheckedChange = vi.fn();
render(<Switch checked={false} onCheckedChange={onCheckedChange} aria-label="开关" />);
const toggle = screen.getByRole("switch", { name: "开关" });
expect(toggle.className).toContain("bg-muted");
fireEvent.click(toggle);
expect(onCheckedChange).toHaveBeenCalledWith(true);
});
});
@@ -0,0 +1,43 @@
import { cn } from "@browser-skill/ui";
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
"aria-label": string;
"data-slot"?: string;
}
/**
* The popup's single toggle switch — every settings row uses this same
* component and size; hierarchy between primary and secondary settings is
* conveyed by copy/iconography, not by control size.
*/
export function Switch({
checked,
onCheckedChange,
"aria-label": ariaLabel,
"data-slot": dataSlot,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
data-slot={dataSlot}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
checked ? "bg-primary" : "bg-muted",
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
);
}
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
import { getControlHintsHidden, STORAGE_KEYS, setControlHintsHidden } from "@/lib/instance-id";
/**
* Popup-side view of the "hide control hints" preference in
* `chrome.storage.local`. Defaults to shown when storage is unavailable
* (e.g. unit tests) so the popup still renders; writes go straight to
* storage and content scripts pick them up via `storage.onChanged`.
*/
export function useControlHintsHidden(): [boolean, (hidden: boolean) => void] {
const [hidden, setHidden] = useState(false);
useEffect(() => {
if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined;
let cancelled = false;
getControlHintsHidden()
.then((value) => {
if (!cancelled) setHidden(value);
})
.catch((err) => {
console.debug("[browser-skill] control-hints preference read failed", err);
});
const onChanged = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.CONTROL_HINTS_HIDDEN];
if (change) setHidden(change.newValue === true);
};
chrome.storage.onChanged.addListener(onChanged);
return () => {
cancelled = true;
chrome.storage.onChanged.removeListener(onChanged);
};
}, []);
const update = (value: boolean) => {
setHidden(value);
if (typeof chrome === "undefined" || !chrome.storage?.local) return;
setControlHintsHidden(value).catch((err) => {
console.debug("[browser-skill] control-hints preference write failed", err);
});
};
return [hidden, update];
}
@@ -1,10 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import {
getConnectionEnabled,
getControlHintsHidden,
getLabel,
getOrCreateInstanceId,
STORAGE_KEYS,
setConnectionEnabled,
setControlHintsHidden,
setLabel,
} from "../instance-id";
@@ -93,4 +95,26 @@ describe("instance-id", () => {
expect(store[STORAGE_KEYS.CONNECTION_ENABLED]).toBe(false);
expect(await getConnectionEnabled(backend)).toBe(false);
});
it("getControlHintsHidden returns false when storage is empty", async () => {
const { backend } = fakeStorage();
expect(await getControlHintsHidden(backend)).toBe(false);
});
it("getControlHintsHidden returns persisted boolean values", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: true });
expect(await getControlHintsHidden(backend)).toBe(true);
});
it("getControlHintsHidden treats non-boolean stored values as shown", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONTROL_HINTS_HIDDEN]: "true" });
expect(await getControlHintsHidden(backend)).toBe(false);
});
it("setControlHintsHidden persists the value retrievable by getControlHintsHidden", async () => {
const { backend, store } = fakeStorage();
await setControlHintsHidden(true, backend);
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
expect(await getControlHintsHidden(backend)).toBe(true);
});
});
+23
View File
@@ -1,6 +1,7 @@
const STORAGE_KEY = "bsk_instance_id";
const LABEL_STORAGE_KEY = "bh_label";
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden";
export interface StorageBackend {
get(keys: string | string[]): Promise<Record<string, unknown>>;
@@ -105,8 +106,30 @@ export async function setConnectionEnabled(
await storage.set({ [CONNECTION_ENABLED_KEY]: enabled });
}
/**
* User preference for the in-page control hints (status pill + orange glow
* shown while the Agent controls a tab). Defaults to shown when unset or
* non-boolean; when hidden the whole control overlay — including its input
* blocker — is skipped so the page looks and behaves normally.
*/
export async function getControlHintsHidden(
storage: StorageBackend = defaultStorage(),
): Promise<boolean> {
const items = await storage.get(CONTROL_HINTS_HIDDEN_KEY);
const raw = items[CONTROL_HINTS_HIDDEN_KEY];
return typeof raw === "boolean" ? raw : false;
}
export async function setControlHintsHidden(
hidden: boolean,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden });
}
export const STORAGE_KEYS = {
INSTANCE_ID: STORAGE_KEY,
LABEL: LABEL_STORAGE_KEY,
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_KEY,
} as const;