Initial public release

This commit is contained in:
BB-fat committed 2026-06-22 17:36:26 +08:00
commit f1036a860c
289 files changed
+54589

No files matched your search

@@ -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 }),
};
}