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
27 changes: 27 additions & 0 deletions apps/extension/src/content/__tests__/overlay-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
12 changes: 12 additions & 0 deletions apps/extension/src/content/overlay-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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">;
Expand All @@ -36,6 +42,7 @@ export class OverlayController {
controlMode: "hidden",
automationBypassCount: 0,
suppressControlAfterRecord: false,
controlHintsHidden: false,
};

snapshot(): OverlayState {
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions apps/extension/src/entrypoints/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
import { getControlHintsHidden, STORAGE_KEYS } from "@/lib/instance-id";
import {
isOverlayAgentOverlayResetMessage,
isOverlayAgentStateMessage,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
92 changes: 92 additions & 0 deletions apps/extension/src/entrypoints/popup/App.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
70 changes: 50 additions & 20 deletions apps/extension/src/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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("");
Expand Down Expand Up @@ -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 && (
Expand All @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions apps/extension/src/entrypoints/popup/switch.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading