From fb936a00447acde004849089004bb9fac1df1e95 Mon Sep 17 00:00:00 2001 From: BB-fat <45072480+BB-fat@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:08:53 +0000 Subject: [PATCH 1/4] feat(extension): add popup toggle to hide Agent control hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a per-browser preference (chrome.storage.local, default: hints shown) that hides the in-page control overlay — the status pill and breathing orange glow shown while the Agent controls a tab, along with the input blocker that comes with them so the page stays usable. - lib/instance-id: bsk_control_hints_hidden key with get/set helpers - overlay-controller: controlHintsHidden state gates shouldShowAgentControlOverlay; preference survives session resets - content script: load preference before first mount and live-apply changes via chrome.storage.onChanged - popup: new "Control hints" switch on the main view - i18n: en-US / zh-CN strings Refs #62 --- .../__tests__/overlay-controller.test.ts | 27 ++++++++ .../src/content/overlay-controller.ts | 12 ++++ apps/extension/src/entrypoints/content.ts | 23 +++++++ .../src/entrypoints/popup/App.test.tsx | 65 +++++++++++++++++++ apps/extension/src/entrypoints/popup/App.tsx | 38 +++++++++++ .../popup/use-control-hints-hidden.ts | 44 +++++++++++++ .../src/lib/__tests__/instance-id.test.ts | 24 +++++++ apps/extension/src/lib/instance-id.ts | 23 +++++++ .../i18n/src/locales/en-US/extension.json | 2 + .../i18n/src/locales/zh-CN/extension.json | 2 + 10 files changed, 260 insertions(+) create mode 100644 apps/extension/src/entrypoints/popup/use-control-hints-hidden.ts diff --git a/apps/extension/src/content/__tests__/overlay-controller.test.ts b/apps/extension/src/content/__tests__/overlay-controller.test.ts index bbcde9f..334f407 100644 --- a/apps/extension/src/content/__tests__/overlay-controller.test.ts +++ b/apps/extension/src/content/__tests__/overlay-controller.test.ts @@ -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); + }); }); diff --git a/apps/extension/src/content/overlay-controller.ts b/apps/extension/src/content/overlay-controller.ts index 0ec7b67..3a2c4d8 100644 --- a/apps/extension/src/content/overlay-controller.ts +++ b/apps/extension/src/content/overlay-controller.ts @@ -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; @@ -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 diff --git a/apps/extension/src/entrypoints/content.ts b/apps/extension/src/entrypoints/content.ts index 651ddc1..52536ad 100644 --- a/apps/extension/src/entrypoints/content.ts +++ b/apps/extension/src/entrypoints/content.ts @@ -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, + 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(); diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index 155e7b6..491c670 100644 --- a/apps/extension/src/entrypoints/popup/App.test.tsx +++ b/apps/extension/src/entrypoints/popup/App.test.tsx @@ -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,67 @@ describe("App", () => { expect(screen.getByText("连接后可用")).toBeTruthy(); }); }); + +describe("control hints toggle", () => { + function stubChromeStorage(initial: Record = {}) { + const store = { ...initial }; + vi.stubGlobal("chrome", { + runtime: { lastError: undefined }, + storage: { + local: { + get: (keys: string | string[], cb: (items: Record) => void) => { + const items: Record = {}; + for (const k of Array.isArray(keys) ? keys : [keys]) { + if (k in store) items[k] = store[k]; + } + cb(items); + }, + set: (items: Record, 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(); + + 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(); + + 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(); + + 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"); + }); +}); diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index 8dc6b6a..57ec918 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -7,6 +7,7 @@ import functionIconUrl from "../../../assets/function.svg"; import { ConnectionStatusIndicator } from "./connection-status-indicator"; import { POPUP_FEATURES, type PopupView } from "./features"; 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 +33,7 @@ function getLogoSrc() { export function App() { const { t } = useTranslation("extension"); const { snapshot, statusState, setConnectionEnabled } = useConnectionState(); + const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden(); const [view, setView] = useState("main"); const [copiedInstanceId, setCopiedInstanceId] = useState(false); const [purposeDraft, setPurposeDraft] = useState(""); @@ -222,6 +224,42 @@ export function App() { )} +
+
+ + + {t("popup.controlHintsToggleTitle")} + + + {t("popup.controlHintsToggleHint")} + + + +
+
+ {snapshot.lastError && (
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, 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]; +} diff --git a/apps/extension/src/lib/__tests__/instance-id.test.ts b/apps/extension/src/lib/__tests__/instance-id.test.ts index 7b0e894..3954486 100644 --- a/apps/extension/src/lib/__tests__/instance-id.test.ts +++ b/apps/extension/src/lib/__tests__/instance-id.test.ts @@ -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); + }); }); diff --git a/apps/extension/src/lib/instance-id.ts b/apps/extension/src/lib/instance-id.ts index 0bbfdf6..087d15f 100644 --- a/apps/extension/src/lib/instance-id.ts +++ b/apps/extension/src/lib/instance-id.ts @@ -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>; @@ -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 { + 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 { + 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; diff --git a/packages/i18n/src/locales/en-US/extension.json b/packages/i18n/src/locales/en-US/extension.json index b3c5663..c21298a 100644 --- a/packages/i18n/src/locales/en-US/extension.json +++ b/packages/i18n/src/locales/en-US/extension.json @@ -23,6 +23,8 @@ "disabled": "Off" }, "connectionToggleTitle": "BrowserSkill connection", + "controlHintsToggleTitle": "Control hints", + "controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.", "versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.", "upgradeAvailable": "Upgradable", "launcher": { diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 466e8f5..0256557 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -23,6 +23,8 @@ "disabled": "Off" }, "connectionToggleTitle": "BrowserSkill 连接", + "controlHintsToggleTitle": "控制提示", + "controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。", "versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。", "upgradeAvailable": "可升级", "launcher": { From bfc4aa95aacec80567a06c8c041f275dd55b4116 Mon Sep 17 00:00:00 2001 From: BB-fat <1056871944@qq.com> Date: Thu, 13 Aug 2026 01:50:00 +0000 Subject: [PATCH 2/4] refactor(extension): compact control-hints card into a single row Follow-up to the control-hints toggle: collapse the card to one line so it matches the connection card's height, demote the switch to a smaller secondary size, and move the description into an info-icon tooltip (hover/focus, keyboard reachable, role=tooltip) using existing design tokens. Behavior and the bsk_control_hints_hidden key are unchanged. --- .../src/entrypoints/popup/App.test.tsx | 24 +++++++++++++ apps/extension/src/entrypoints/popup/App.tsx | 35 ++++++++++++++----- .../i18n/src/locales/en-US/extension.json | 1 + .../i18n/src/locales/zh-CN/extension.json | 1 + 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index 491c670..6ece91e 100644 --- a/apps/extension/src/entrypoints/popup/App.test.tsx +++ b/apps/extension/src/entrypoints/popup/App.test.tsx @@ -308,4 +308,28 @@ describe("control hints 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(); + + 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("renders the hints switch smaller than the primary connection switch", async () => { + stubChromeStorage(); + + render(); + + const hintsToggle = await screen.findByRole("switch", { name: "控制提示" }); + const connectionToggle = screen.getByRole("switch", { name: "BrowserSkill 连接" }); + expect(hintsToggle.className).toContain("h-4"); + expect(connectionToggle.className).toContain("h-5"); + }); }); diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index 57ec918..43814af 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -1,6 +1,12 @@ 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 { + 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"; @@ -229,12 +235,25 @@ export function App() { data-slot="popup-control-hints-card" >
- - + + {t("popup.controlHintsToggleTitle")} - - {t("popup.controlHintsToggleHint")} + + + + {t("popup.controlHintsToggleHint")} + + />
{isSkewed && ( @@ -256,26 +243,12 @@ export function App() { - + /> diff --git a/apps/extension/src/entrypoints/popup/switch.test.tsx b/apps/extension/src/entrypoints/popup/switch.test.tsx new file mode 100644 index 0000000..9678572 --- /dev/null +++ b/apps/extension/src/entrypoints/popup/switch.test.tsx @@ -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(); + + 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(); + + const toggle = screen.getByRole("switch", { name: "开关" }); + expect(toggle.className).toContain("bg-muted"); + + fireEvent.click(toggle); + expect(onCheckedChange).toHaveBeenCalledWith(true); + }); +}); diff --git a/apps/extension/src/entrypoints/popup/switch.tsx b/apps/extension/src/entrypoints/popup/switch.tsx new file mode 100644 index 0000000..47f6858 --- /dev/null +++ b/apps/extension/src/entrypoints/popup/switch.tsx @@ -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 ( + + ); +}