Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from "vitest";
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
// at module load. WXT injects that helper at build time via auto-imports;
// vitest runs the file directly so we have to stub it before the import is
// resolved. `vi.hoisted` runs before ESM imports, which is exactly what we need.
vi.hoisted(() => {
(globalThis as unknown as { defineBackground: (cb: unknown) => unknown }).defineBackground = (
cb: unknown,
) => cb;
});
import { handleOverlayInterrupt } from "@/entrypoints/background";
describe("handleOverlayInterrupt", () => {
it("sends a session.user_interrupt event to the daemon and acks ok", async () => {
const send = vi.fn().mockReturnValue(undefined);
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
const result = await handleOverlayInterrupt(transport, "sess-1");
expect(send).toHaveBeenCalledWith({
event: "session.user_interrupt",
payload: { session_id: "sess-1" },
});
expect(result).toEqual({ ok: true });
});
it("returns ok=false when the transport send throws", async () => {
const send = vi.fn(() => {
throw new Error("ws closed");
});
const transport = { send } as unknown as Parameters<typeof handleOverlayInterrupt>[0];
const result = await handleOverlayInterrupt(transport, "sess-1");
expect(result).toEqual({ ok: false });
});
});
@@ -0,0 +1,229 @@
import { i18n } from "@browser-skill/i18n";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { ConnectionController } from "@/lib/connection-controller";
import {
getConnectionEnabled,
setConnectionEnabled as persistConnectionEnabled,
setLabel,
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
import {
OVERLAY_MSG_INTERRUPT,
OVERLAY_MSG_WHO_AM_I,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
type OverlayMessage,
} from "@/lib/overlay-bridge";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
import { attachSessionEventHandler } from "@/session-manager/event-handler";
import { SessionManager } from "@/session-manager/manager";
import {
attachBorrowNotificationButtonHandler,
attachBorrowNotificationClickHandler,
type BorrowNotificationCopy,
defaultBorrowChromeNotifications,
defaultBorrowChromeWindows,
requestBorrowConfirmation,
} from "@/tools/borrow-confirmation";
import { ToolDispatcher } from "@/tools/dispatcher";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
export const DEFAULT_DAEMON_PORT = 52800;
export default defineBackground(() => {
const port = DEFAULT_DAEMON_PORT;
const controller = new ConnectionController();
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
const sessions = new SessionManager();
const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
// Re-sync the storage.session flag on SW startup so a previous SW's
// stale `true` does not keep waking us on every page load until the
// first mutation (review M4/M5 round 3 m-R3-1).
void sessionsLive.refresh();
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp,
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
approveBorrow: (ctx) =>
requestBorrowConfirmation(ctx.tabId, {
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
deps: {
// Skip every Agent Window when choosing where to render the
// overlay — Agent Windows boot on about:blank, which has no
// content script, so sendMessage would fail-open and silently
// allow the borrow without any UI shown.
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
// Resolve i18n strings per-borrow so language switches take effect
// without re-creating the dispatcher.
notificationCopy: makeBorrowNotificationCopy(),
},
}),
helpNotificationCopy: () => ({
title: i18n.t("helpRequest.notificationTitle", { ns: "extension" }),
body: "",
}),
});
dispatcher.start();
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
attachBorrowNotificationClickHandler({
onClicked: chrome.notifications.onClicked,
windows: defaultBorrowChromeWindows,
notifications: defaultBorrowChromeNotifications,
});
} else {
console.warn(
"[browser-skill] chrome.notifications unavailable; borrow notifications will be skipped",
);
}
// The Allow / Deny buttons on the OS notification are the *explicit*
// authorization fallback when every candidate user window's content
// script was missing (extension just reloaded, page in BFCache, etc.).
// Without this listener those button clicks would land nowhere and we'd
// be back to fail-open-after-sendMessage-failure.
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
attachBorrowNotificationButtonHandler({
onButtonClicked: chrome.notifications.onButtonClicked,
});
} else {
console.warn(
"[browser-skill] chrome.notifications.onButtonClicked unavailable; borrow Allow/Deny buttons will be inactive",
);
}
attachSessionEventHandler({
manager: sessions,
transport,
cdp,
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
});
// MV3 service worker keepalive + reconnect supervisor (review M4/M5
// C3 + C4). Every 30s the alarm wakes the SW; if the transport is
// not connected we force a fresh connect attempt so we never sit on
// a stale `disconnected` state when the setTimeout-based reconnect
// dies with the SW. Reconnect is skipped when the user has disabled
// the BrowserSkill connection.
startKeepalive({
transport,
shouldConnect: () => controller.isConnectionEnabled,
});
void (async () => {
const connectionEnabled = await getConnectionEnabled();
await controller.attach(transport, detectBrowserMeta(), connectionEnabled);
})().catch((err) => {
console.error("[browser-skill] controller failed to attach", err);
});
chrome.runtime.onMessage.addListener((rawMsg, sender, sendResponse) => {
const msg = rawMsg as OverlayMessage | undefined;
if (!msg || typeof msg !== "object" || !("kind" in msg)) return false;
if (msg.kind === OVERLAY_MSG_WHO_AM_I) {
const windowId = sender.tab?.windowId;
const ctx = typeof windowId === "number" ? sessions.findByWindowId(windowId) : null;
sendResponse({ sessionId: ctx?.sessionId ?? null });
return false;
}
if (msg.kind === OVERLAY_MSG_INTERRUPT) {
const req = msg as OverlayInterruptRequest;
void handleOverlayInterrupt(transport, req.sessionId).then((reply) => {
sendResponse(reply);
});
return true; // keep channel open
}
return false;
});
chrome.runtime.onConnect.addListener((connection) => {
if (connection.name !== POPUP_PORT_NAME) return;
const post = (msg: PopupInbound) => {
try {
connection.postMessage(msg);
} catch (err) {
console.debug("[browser-skill] popup post failed", err);
}
};
const unsubscribe = controller.subscribe((snap) => {
post({ kind: "snapshot", data: snap });
});
connection.onMessage.addListener((raw: unknown) => {
const msg = raw as PopupOutbound;
if (msg && typeof msg === "object" && "kind" in msg) {
if (msg.kind === "set_label") {
void setLabel(msg.value).then(() => controller.refreshLabel());
} else if (msg.kind === "set_port") {
// Placeholder for the future custom-port UI; warn loudly so
// any reintroduced popup control is caught instead of
// silently doing nothing (review M4/M5 C2).
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
} else if (msg.kind === "set_connection_enabled") {
void controller.setConnectionEnabled(msg.value).then(() =>
persistConnectionEnabled(msg.value),
);
}
}
});
connection.onDisconnect.addListener(() => unsubscribe());
});
// Stash on globalThis so the SW DevTools can poke at internals.
// Dev-only to avoid leaking internals to inspectors in shipped builds
// (review M4/M5 M4).
if (import.meta.env.DEV) {
const dbg = globalThis as unknown as {
__bskController?: ConnectionController;
__bhSessions?: SessionManager;
__bhDispatcher?: ToolDispatcher;
};
dbg.__bskController = controller;
dbg.__bhSessions = sessions;
dbg.__bhDispatcher = dispatcher;
}
console.info("[browser-skill] background worker initialised");
});
/**
* Push a `session.user_interrupt` event to the daemon for `sessionId`.
* Returns `{ ok: true }` when `transport.send` accepts the frame and
* `{ ok: false }` when it throws (sink closed, transport not yet
* connected, etc.). The daemon-side cancellation is fire-and-forget
* — a failure here just means the user will need to retry the
* interrupt; no daemon state is left half-updated.
*/
export async function handleOverlayInterrupt(
transport: Pick<Transport, "send">,
sessionId: string,
): Promise<OverlayInterruptResponse> {
try {
transport.send({
event: "session.user_interrupt",
payload: { session_id: sessionId },
});
return { ok: true };
} catch (err) {
console.warn("[browser-skill] failed to send session.user_interrupt", err);
return { ok: false };
}
}
function makeBorrowNotificationCopy(): BorrowNotificationCopy {
return {
title: i18n.t("borrowConfirmation.notificationTitle", { ns: "extension" }),
body: (tabTitle: string) =>
i18n.t("borrowConfirmation.notificationBody", { ns: "extension", tabTitle }),
iconUrl: "icon/logo.png",
allowButton: i18n.t("borrowConfirmation.allow", { ns: "extension" }),
denyButton: i18n.t("borrowConfirmation.deny", { ns: "extension" }),
};
}
+289
View File
@@ -0,0 +1,289 @@
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
import { ControlOverlay } from "@/content/ControlOverlay";
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
import overlayCss from "@/content/overlay.css?inline";
import { OverlayController } from "@/content/overlay-controller";
import {
HELP_RESPONSE,
type HelpCancelMessage,
type HelpRequestMessage,
type HelpResponseMessage,
isHelpCancelMessage,
isHelpRequestMessage,
} from "@/lib/help-bridge";
import {
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_WHO_AM_I,
type OverlayAgentOverlayResetMessage,
type OverlayAutomationBypassMessage,
type OverlayWhoAmIResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
import type {
BorrowCancelMessage,
BorrowRequestMessage,
BorrowResponseMessage,
} from "@/tools/borrow-confirmation";
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
// Run at document_end so the overlay does not block first paint. Only attach
// in the top-level frame so iframes do not double-render overlays.
export default defineContentScript({
matches: ["<all_urls>"],
runAt: "document_end",
allFrames: false,
cssInjectionMode: "ui",
async main(ctx) {
if (window.top !== window) return;
const overlays = new OverlayController();
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
null;
let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null;
let hostLossReported = false;
let remountInProgress = false;
const ui = await createShadowRootUi(ctx, {
name: "browser-skill-overlay",
position: "inline",
anchor: "html",
css: overlayCss,
onMount(container, _shadow, shadowHost) {
shadowHost.setAttribute("aria-hidden", "true");
shadowHost.setAttribute("data-bsk-overlay", "");
overlayHost = shadowHost;
hostLossReported = false;
const app = document.createElement("div");
app.className = "bsk-overlay-root";
container.append(app);
reactRoot = ReactDOM.createRoot(app);
renderOverlay();
return reactRoot;
},
onRemove(root) {
overlayHost = null;
root?.unmount();
reactRoot = null;
},
});
function renderOverlay() {
const overlayState = overlays.snapshot();
reactRoot?.render(
React.createElement(
I18nextProvider,
{ i18n },
React.createElement(
React.Fragment,
null,
React.createElement(BorrowConfirmationOverlay, {
requests: overlayState.borrowRequests,
}),
React.createElement(ControlOverlay, {
visible: overlayState.controlVisible && overlayState.activeHelp === null,
interrupting: overlayState.interrupting,
automationBypass: overlayState.automationBypassCount > 0,
onInterrupt: handleInterrupt,
}),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
),
),
);
}
function resetAgentOverlayState(sessionId: string) {
const previousHelp = overlays.resetAgentOverlays(sessionId);
if (previousHelp) {
activeHelpRespond?.("cancelled");
activeHelpRespond = null;
}
renderOverlay();
}
function handleInterrupt() {
const state = overlays.snapshot();
if (state.interrupting) return;
const sessionId = state.activeSessionId;
if (!sessionId) {
console.warn("[bsk overlay] interrupt requested with no active session id");
return;
}
overlays.setInterrupting(true);
renderOverlay();
void sendInterrupt(
(msg) => chrome.runtime.sendMessage(msg),
sessionId,
).then((reply) => {
// Always retract the mask after the round trip resolves
// (success, failure, or timeout). Cancellation is fire-and-
// forget on the daemon side; the user must not be stuck
// behind a transient issue. The Agent Window stays open.
resetAgentOverlayState(sessionId);
if (!reply.ok) {
console.warn("[bsk overlay] interrupt did not get a clean ack from daemon");
}
});
}
const onMessage = (
message:
| BorrowRequestMessage
| BorrowCancelMessage
| HelpRequestMessage
| HelpCancelMessage
| OverlayAgentOverlayResetMessage
| OverlayAutomationBypassMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
) => {
if (
message &&
typeof message === "object" &&
"type" in message &&
message.type === OVERLAY_AUTOMATION_BYPASS
) {
const bypassMsg = message as OverlayAutomationBypassMessage;
overlays.setAutomationBypass(bypassMsg.enabled);
renderOverlay();
return false;
}
if (isOverlayAgentOverlayResetMessage(message)) {
resetAgentOverlayState(message.sessionId);
return false;
}
if (message.type === "borrow-cancel") {
overlays.removeBorrowRequest(message.requestId);
renderOverlay();
return false;
}
if (isHelpCancelMessage(message)) {
const state = overlays.snapshot();
if (state.activeHelp && state.activeHelp.id === message.requestId) {
activeHelpRespond?.("cancelled");
}
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 } : {}),
selectors: helpMsg.selectors,
onContinue: (note: string) => respond("continued", note.trim() ? note : undefined),
onCancel: () => respond("cancelled"),
});
if (previousHelp) {
activeHelpRespond?.("cancelled");
}
activeHelpRespond = respond;
renderOverlay();
return true; // async sendResponse
}
if (message.type === "borrow-request") {
let responded = false;
const respond = (allowed: boolean) => {
if (responded) return;
responded = true;
sendResponse({ type: "borrow-response", allowed });
overlays.removeBorrowRequest(message.requestId);
renderOverlay();
};
overlays.addBorrowRequest({
id: message.requestId,
isActiveTab: message.isActiveTab,
tabTitle: message.tabTitle,
timeoutMs: message.timeoutMs,
onAllow: () => respond(true),
onDeny: () => respond(false),
});
renderOverlay();
return true;
}
return false;
};
async function mountOverlayIfAgent(): Promise<void> {
if (!(await anySessionLive())) return;
try {
const reply = (await chrome.runtime.sendMessage({
kind: OVERLAY_MSG_WHO_AM_I,
})) as OverlayWhoAmIResponse | undefined;
if (!reply?.sessionId) return;
overlays.activateAgentSession(reply.sessionId);
renderOverlay();
} catch (err) {
console.debug("[bsk overlay] who_am_i failed", err);
}
}
ui.mount();
chrome.runtime.onMessage.addListener(onMessage);
void mountOverlayIfAgent();
const hostObserver = new MutationObserver(() => {
const connected = overlayHost?.isConnected ?? false;
if (overlays.isControlVisible() && !connected && !hostLossReported) {
hostLossReported = true;
if (!remountInProgress) {
remountInProgress = true;
try {
ui.mount();
} finally {
remountInProgress = false;
}
}
}
if (connected) {
hostLossReported = false;
}
});
hostObserver.observe(document.documentElement, { childList: true, subtree: false });
ctx.onInvalidated(() => {
hostObserver.disconnect();
chrome.runtime.onMessage.removeListener(onMessage);
});
},
});
async function anySessionLive(): Promise<boolean> {
if (!chrome.storage?.session?.get) return true;
try {
const result = (await chrome.storage.session.get({
[SESSIONS_LIVE_FLAG_KEY]: false,
})) as Record<string, unknown> | undefined;
return Boolean(result?.[SESSIONS_LIVE_FLAG_KEY]);
} catch (err) {
console.debug("[bsk overlay] sessions-live flag read failed", err);
return true;
}
}
@@ -0,0 +1,139 @@
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 { EXTENSION_VERSION } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
vi.mock("./use-connection-state", () => ({
useConnectionState: vi.fn(),
}));
const mockUseConnectionState = vi.mocked(useConnectionState);
/** Arbitrary peer fixture — only used to distinguish daemon vs extension in the UI. */
const mockDaemonVersion = "daemon-fixture";
const baseSnapshot: SnapshotInfo = {
state: "disconnected",
instanceId: "",
label: "",
extensionVersion: EXTENSION_VERSION,
handshake: null,
lastError: null,
connectionEnabled: true,
};
describe("App", () => {
const setLabel = vi.fn();
const setConnectionEnabled = vi.fn();
beforeEach(() => {
mockUseConnectionState.mockReturnValue({
snapshot: baseSnapshot,
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("shows status label without helper subtitle", () => {
render(<App />);
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
});
it("uses flex gap for label field spacing", () => {
const { container } = render(<App />);
const labelField = container.querySelector("[data-slot='popup-label-field']");
expect(labelField?.className).toContain("flex");
expect(labelField?.className).toContain("flex-col");
expect(labelField?.className).toContain("gap-3");
});
it("shows single-line compact metadata and copies the instance id", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
label: "个人 Chrome",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.queryByText(/^扩展 v/)).toBeNull();
expect(screen.queryByText(/^daemon v/)).toBeNull();
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
expect(screen.getByText("03c3e47f")).toBeTruthy();
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
expect(copyButton.textContent).toBe("");
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("03c3e47f");
await waitFor(() => expect(copyButton.getAttribute("title")).toBe("已复制"));
});
it("renders the connection toggle with switch semantics", () => {
render(<App />);
const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
});
it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
render(<App />);
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
expect(setConnectionEnabled).toHaveBeenCalledWith(false);
});
it("shows disabled status when connection is turned off", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, connectionEnabled: false },
statusState: "disabled",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.getByText("连接已关闭")).toBeTruthy();
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
"false",
);
});
});
@@ -0,0 +1,209 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { type ChangeEvent, useEffect, useState } from "react";
import { PROTOCOL_VERSION } from "@/transport/handshake";
import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
const STATE_LABEL_KEYS = {
disconnected: "popup.stateLabel.disconnected",
connected: "popup.stateLabel.connected",
version_skew: "popup.stateLabel.version_skew",
disabled: "popup.stateLabel.disabled",
} as const satisfies Record<PopupStatusState, string>;
const STATE_BADGE_KEYS = {
disconnected: "popup.stateBadge.disconnected",
connected: "popup.stateBadge.connected",
version_skew: "popup.stateBadge.version_skew",
disabled: "popup.stateBadge.disabled",
} as const satisfies Record<PopupStatusState, string>;
function getLogoSrc() {
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
return chrome.runtime.getURL("icon/logo.png");
}
return "/icon/logo.png";
}
export function App() {
const { t } = useTranslation("extension");
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
const [labelDraft, setLabelDraft] = useState(snapshot.label);
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)");
const applyTheme = (isDark: boolean) => {
document.documentElement.classList.toggle("dark", isDark);
};
applyTheme(query.matches);
const onChange = (event: MediaQueryListEvent) => applyTheme(event.matches);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
}, []);
useEffect(() => {
setLabelDraft(snapshot.label);
}, [snapshot.label]);
useEffect(() => {
setCopiedInstanceId(false);
}, [snapshot.instanceId]);
const onLabelBlur = () => {
if (labelDraft !== snapshot.label) setLabel(labelDraft);
};
const isSkewed = statusState === "version_skew";
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
const extensionVersion = snapshot.extensionVersion || "—";
const instanceId = snapshot.instanceId || "—";
const copyInstanceId = async () => {
if (!snapshot.instanceId || !navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(snapshot.instanceId);
setCopiedInstanceId(true);
};
return (
<main
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
data-slot="popup-root"
data-version-skew={isSkewed ? "true" : undefined}
>
<header className="flex items-center gap-2" data-slot="popup-brand-header">
<img
src={getLogoSrc()}
alt=""
className="size-7 rounded-lg"
data-slot="popup-brand-logo"
/>
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
</header>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-connection-card"
>
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
<div className="flex min-w-0 items-center gap-2">
<ConnectionStatusIndicator state={statusState} />
<span
className="truncate text-sm font-medium"
data-slot="popup-state-label"
>
{t(STATE_LABEL_KEYS[statusState])}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px] font-medium uppercase"
data-slot="popup-state-badge"
>
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<button
type="button"
role="switch"
aria-checked={snapshot.connectionEnabled}
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 && (
<p
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
data-slot="popup-version-skew-warning"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
})}
</p>
)}
</section>
{snapshot.lastError && (
<div
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
data-slot="popup-error"
>
{snapshot.lastError}
</div>
)}
<div className="flex flex-col gap-3" data-slot="popup-label-field">
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
{t("popup.labelTitle")}
</Label>
<Input
id="bh-label"
type="text"
value={labelDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
onBlur={onLabelBlur}
placeholder={t("popup.labelPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-label-input"
/>
</div>
<section
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span aria-hidden>/</span>
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<span className="shrink-0">{t("popup.instanceTitle")}</span>
<code
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
data-slot="popup-instance-id"
>
{instanceId}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 rounded-md"
disabled={!snapshot.instanceId}
aria-label={t("popup.copyInstanceId")}
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
onClick={() => {
void copyInstanceId();
}}
data-slot="popup-copy-instance-id"
>
{copiedInstanceId ? (
<RiCheckLine className="size-3" aria-hidden />
) : (
<RiFileCopyLine className="size-3" aria-hidden />
)}
</Button>
</div>
</section>
</main>
);
}
@@ -0,0 +1,29 @@
import { cn } from "@browser-skill/ui";
import type { PopupStatusState } from "./use-connection-state";
const DOT_STYLES: Record<PopupStatusState, string> = {
disconnected: "bg-muted-foreground/40",
connected: "bg-emerald-500",
version_skew: "bg-amber-500",
disabled: "bg-muted-foreground/25",
};
interface ConnectionStatusIndicatorProps {
state: PopupStatusState;
className?: string;
}
/** Flat online-status dot (Slack / GitHub style). */
export function ConnectionStatusIndicator({ state, className }: ConnectionStatusIndicatorProps) {
return (
<span
className={cn(
"size-2 shrink-0 rounded-full ring-2 ring-background",
DOT_STYLES[state],
className,
)}
data-slot={`popup-state-dot-${state}`}
aria-hidden
/>
);
}
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>browser-skill</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
@@ -0,0 +1,19 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./style.css";
const container = document.getElementById("root");
if (!container) {
throw new Error("[browser-skill] popup root element missing");
}
ReactDOM.createRoot(container).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<App />
</I18nextProvider>
</React.StrictMode>,
);
@@ -0,0 +1,3 @@
@import "@browser-skill/ui/styles";
@source "../../";
@source "../../../../../packages/ui/src";
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolvePopupDisplayState, resolvePopupStatusState } from "./use-connection-state";
describe("resolvePopupDisplayState", () => {
it("returns wire state when not connecting", () => {
expect(resolvePopupDisplayState("connected", "disconnected")).toBe("connected");
expect(resolvePopupDisplayState("disconnected", "connected")).toBe("disconnected");
expect(resolvePopupDisplayState("version_skew", "connected")).toBe("version_skew");
});
it("holds last stable state while connecting", () => {
expect(resolvePopupDisplayState("connecting", "connected")).toBe("connected");
expect(resolvePopupDisplayState("connecting", "disconnected")).toBe("disconnected");
expect(resolvePopupDisplayState("connecting", "version_skew")).toBe("version_skew");
});
it("falls back to disconnected when no stable state yet", () => {
expect(resolvePopupDisplayState("connecting", "connecting")).toBe("disconnected");
});
});
describe("resolvePopupStatusState", () => {
it("returns disabled when connection is turned off", () => {
expect(
resolvePopupStatusState({ connectionEnabled: false, state: "connected" }, "connected"),
).toBe("disabled");
});
it("returns version_skew when enabled and wire state is version_skew", () => {
expect(
resolvePopupStatusState({ connectionEnabled: true, state: "version_skew" }, "connected"),
).toBe("version_skew");
});
it("returns display state when enabled and not version_skew", () => {
expect(
resolvePopupStatusState({ connectionEnabled: true, state: "connected" }, "connected"),
).toBe("connected");
});
});
@@ -0,0 +1,103 @@
import { useEffect, useRef, useState } from "react";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { POPUP_PORT_NAME, type PopupInbound, type PopupOutbound } from "@/lib/popup-bridge";
import type { ConnectionState } from "@/transport/types";
export type PopupDisplayState = Exclude<ConnectionState, "connecting">;
/** Popup status including user-disabled connection preference. */
export type PopupStatusState = PopupDisplayState | "disabled";
/** Popup UI: omit transient `connecting` to avoid label/dot flicker on reconnect. */
export function resolvePopupDisplayState(
state: ConnectionState,
lastStable: ConnectionState,
): PopupDisplayState {
if (state !== "connecting") return state;
return lastStable === "connecting" ? "disconnected" : lastStable;
}
/** When the user has disabled connection, always show the disabled status. */
export function resolvePopupStatusState(
snapshot: Pick<SnapshotInfo, "connectionEnabled" | "state">,
displayState: PopupDisplayState,
): PopupStatusState {
if (!snapshot.connectionEnabled) return "disabled";
if (snapshot.state === "version_skew") return "version_skew";
return displayState;
}
const FALLBACK_SNAPSHOT: SnapshotInfo = {
state: "disconnected",
instanceId: "",
label: "",
extensionVersion: "",
handshake: null,
lastError: null,
connectionEnabled: true,
};
/**
* Live snapshot of the background's `ConnectionController` for popup UI.
*
* Posts `set_label` mutations back to the background; the incoming
* snapshot stream reflects the canonical state. The `set_port` channel
* is defined by the popup-bridge but is not currently routed to the
* background — wiring it requires `ConnectionController` to swap its
* Transport URL and persist the chosen port. Tracked for a follow-up
* milestone; the popup intentionally does not expose a control until
* the underlying support lands (review M4/M5 C2).
*/
export function useConnectionState(): {
snapshot: SnapshotInfo;
statusState: PopupStatusState;
setLabel: (value: string) => void;
setConnectionEnabled: (value: boolean) => void;
} {
const [snapshot, setSnapshot] = useState<SnapshotInfo>(FALLBACK_SNAPSHOT);
const portRef = useRef<chrome.runtime.Port | null>(null);
const lastStableRef = useRef<ConnectionState>("disconnected");
useEffect(() => {
if (typeof chrome === "undefined" || !chrome.runtime?.connect) {
return undefined;
}
const port = chrome.runtime.connect({ name: POPUP_PORT_NAME });
portRef.current = port;
const onMessage = (raw: unknown) => {
const msg = raw as PopupInbound;
if (msg?.kind === "snapshot") setSnapshot(msg.data);
};
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(() => {
portRef.current = null;
});
return () => {
port.onMessage.removeListener(onMessage);
port.disconnect();
portRef.current = null;
};
}, []);
const post = (msg: PopupOutbound) => {
try {
portRef.current?.postMessage(msg);
} catch (err) {
console.warn("[browser-skill] popup post failed", err);
}
};
const displayState = resolvePopupDisplayState(snapshot.state, lastStableRef.current);
if (snapshot.state !== "connecting") {
lastStableRef.current = snapshot.state;
}
const statusState = resolvePopupStatusState(snapshot, displayState);
return {
snapshot,
statusState,
setLabel: (value: string) => post({ kind: "set_label", value }),
setConnectionEnabled: (value: boolean) => post({ kind: "set_connection_enabled", value }),
};
}