mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(record): add popup quick-actions launcher and make record --url optional
Add a popup launcher + action-recording panel that builds a copyable `bsk record start` prompt, add `--browser` targeting, and default `--url` to https://example.com/ when omitted. Drop the popup browser-alias field.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { RiCheckboxCircleLine } from "@remixicon/react";
|
||||
import { RiStopCircleLine } from "@remixicon/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface RecordRequestData {
|
||||
@@ -105,7 +105,7 @@ export function RecordOverlay({ request }: Props) {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<RiCheckboxCircleLine size={18} color="#fff" />
|
||||
<RiStopCircleLine size={18} color="#fff" />
|
||||
{t("recordOverlay.finish")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
|
||||
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
|
||||
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
|
||||
|
||||
vi.stubGlobal("chrome", {
|
||||
runtime: {
|
||||
@@ -128,12 +128,8 @@ describe("record-capture semantic", () => {
|
||||
const capture = startRecordCapture("rec-press", (step) => steps.push(step));
|
||||
const input = document.querySelector("input")!;
|
||||
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "a", bubbles: true }),
|
||||
);
|
||||
input.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
|
||||
);
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
|
||||
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
capture.dispose();
|
||||
expect(steps.filter((s) => s.op === "press")).toEqual([
|
||||
expect.objectContaining({ op: "press", key: "Enter" }),
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
describeEventTarget,
|
||||
describeTarget,
|
||||
type TargetDescriptor,
|
||||
} from "@/lib/describe-target";
|
||||
import { describeEventTarget, describeTarget, type TargetDescriptor } from "@/lib/describe-target";
|
||||
import {
|
||||
isRecordCancelMessage,
|
||||
isRecordStartMessage,
|
||||
|
||||
@@ -24,6 +24,11 @@ const baseSnapshot: SnapshotInfo = {
|
||||
connectionEnabled: true,
|
||||
};
|
||||
|
||||
function openRecordView() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
}
|
||||
|
||||
describe("App", () => {
|
||||
const setLabel = vi.fn();
|
||||
const setConnectionEnabled = vi.fn();
|
||||
@@ -63,13 +68,35 @@ describe("App", () => {
|
||||
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses flex gap for label field spacing", () => {
|
||||
const { container } = render(<App />);
|
||||
it("does not render record UI on the main view", () => {
|
||||
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");
|
||||
expect(screen.queryByRole("button", { name: "复制录制指令" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "操作录制" })).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the feature list from the launcher and navigates to record", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
|
||||
expect(screen.getByText("操作录制")).toBeTruthy();
|
||||
expect(screen.getByText("录制你的操作,供 Agent 复用")).toBeTruthy();
|
||||
expect(screen.queryByText("未连接")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "复制录制指令" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns from the feature list to the main view", () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "返回" }));
|
||||
|
||||
expect(screen.getByText("未连接")).toBeTruthy();
|
||||
expect(screen.queryByText("录制你的操作,供 Agent 复用")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows single-line compact metadata and copies the instance id", async () => {
|
||||
@@ -136,4 +163,82 @@ describe("App", () => {
|
||||
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
|
||||
).toBe("false");
|
||||
});
|
||||
|
||||
it("copies the record prompt with instance id and --browser when connected", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("例如:发布一篇文章"), {
|
||||
target: { value: "发布 wiki 文档" },
|
||||
});
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).toBeNull();
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("03c3e47f");
|
||||
expect(copied).toContain("--browser 03c3e47f");
|
||||
expect(copied).toContain('--purpose "发布 wiki 文档"');
|
||||
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
|
||||
await waitFor(() => expect(copyButton.textContent).toContain("已复制"));
|
||||
});
|
||||
|
||||
it("includes --url in record prompt when a start URL is provided", async () => {
|
||||
mockUseConnectionState.mockReturnValue({
|
||||
snapshot: {
|
||||
...baseSnapshot,
|
||||
state: "connected",
|
||||
instanceId: "03c3e47f",
|
||||
handshake: {
|
||||
server: "bh",
|
||||
version: mockDaemonVersion,
|
||||
protocol_version: "1.0",
|
||||
},
|
||||
},
|
||||
statusState: "connected",
|
||||
setLabel,
|
||||
setConnectionEnabled,
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("https://…"), {
|
||||
target: { value: "https://example.com/" },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "复制录制指令" }));
|
||||
|
||||
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
|
||||
expect(copied).toContain("--url https://example.com/");
|
||||
expect(copied).not.toContain("--purpose");
|
||||
});
|
||||
|
||||
it("disables record copy when disconnected", () => {
|
||||
render(<App />);
|
||||
openRecordView();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
|
||||
expect(copyButton.getAttribute("disabled")).not.toBeNull();
|
||||
expect(screen.getByText("连接后可用")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { useTranslation } from "@browser-skill/i18n/react";
|
||||
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import {
|
||||
RiApps2Line,
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
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 { POPUP_FEATURES, type PopupView } from "./features";
|
||||
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
||||
|
||||
const STATE_LABEL_KEYS = {
|
||||
@@ -29,9 +36,12 @@ function getLogoSrc() {
|
||||
|
||||
export function App() {
|
||||
const { t } = useTranslation("extension");
|
||||
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
|
||||
const [labelDraft, setLabelDraft] = useState(snapshot.label);
|
||||
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
||||
const [view, setView] = useState<PopupView>("main");
|
||||
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
||||
const [purposeDraft, setPurposeDraft] = useState("");
|
||||
const [startUrlDraft, setStartUrlDraft] = useState("");
|
||||
const [copiedPrompt, setCopiedPrompt] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
@@ -44,18 +54,11 @@ export function App() {
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLabelDraft(snapshot.label);
|
||||
}, [snapshot.label]);
|
||||
|
||||
useEffect(() => {
|
||||
setCopiedInstanceId(false);
|
||||
setCopiedPrompt(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 ?? "—";
|
||||
@@ -68,134 +71,286 @@ export function App() {
|
||||
setCopiedInstanceId(true);
|
||||
};
|
||||
|
||||
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
|
||||
const recordPurpose = purposeDraft.trim();
|
||||
const recordStartUrl = startUrlDraft.trim();
|
||||
const recordCommand = snapshot.instanceId
|
||||
? [
|
||||
`bsk record start --browser ${snapshot.instanceId}`,
|
||||
recordStartUrl ? `--url ${recordStartUrl}` : "",
|
||||
recordPurpose ? `--purpose "${recordPurpose}"` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: "";
|
||||
const recordPrompt = snapshot.instanceId
|
||||
? t("popup.record.promptTemplate", {
|
||||
command: recordCommand,
|
||||
})
|
||||
: "";
|
||||
|
||||
const copyRecordPrompt = async () => {
|
||||
if (!recordReady || !navigator.clipboard?.writeText) return;
|
||||
await navigator.clipboard.writeText(recordPrompt);
|
||||
setCopiedPrompt(true);
|
||||
};
|
||||
|
||||
const headerTitle =
|
||||
view === "features"
|
||||
? t("popup.launcher.title")
|
||||
: view === "record"
|
||||
? t("popup.record.sectionTitle")
|
||||
: t("popup.brandName");
|
||||
|
||||
return (
|
||||
<main
|
||||
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
|
||||
data-slot="popup-root"
|
||||
data-view={view}
|
||||
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"
|
||||
<header
|
||||
className="flex items-center gap-2"
|
||||
data-slot={view === "main" ? "popup-brand-header" : "popup-subview-header"}
|
||||
>
|
||||
<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>
|
||||
{view !== "main" ? (
|
||||
<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"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
aria-label={t("popup.back")}
|
||||
onClick={() => setView(view === "record" ? "features" : "main")}
|
||||
data-slot="popup-back"
|
||||
>
|
||||
{copiedInstanceId ? (
|
||||
<RiCheckLine className="size-3" aria-hidden />
|
||||
) : (
|
||||
<RiFileCopyLine className="size-3" aria-hidden />
|
||||
)}
|
||||
<RiArrowLeftLine className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<img src={getLogoSrc()} alt="" className="size-7 rounded-lg" data-slot="popup-brand-logo" />
|
||||
)}
|
||||
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold tracking-tight">{headerTitle}</h1>
|
||||
{view === "main" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md"
|
||||
aria-label={t("popup.launcher.title")}
|
||||
onClick={() => setView("features")}
|
||||
data-slot="popup-launcher"
|
||||
>
|
||||
<RiApps2Line className="size-4" aria-hidden />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{view === "main" && (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{view === "features" && (
|
||||
<section className="space-y-2" data-slot="popup-feature-list">
|
||||
{POPUP_FEATURES.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<button
|
||||
key={feature.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-border/80 bg-card/60 px-3 py-2.5 text-left transition-colors hover:bg-card/80"
|
||||
onClick={() => setView(feature.id)}
|
||||
data-slot={`popup-feature-${feature.id}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-foreground">{t(feature.titleKey)}</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
|
||||
{t(feature.descKey)}
|
||||
</span>
|
||||
</span>
|
||||
<RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{view === "record" && (
|
||||
<section className="space-y-2.5" data-slot="popup-record-body">
|
||||
<p
|
||||
className="text-[11px] leading-snug text-muted-foreground"
|
||||
data-slot="popup-record-hint-desc"
|
||||
>
|
||||
{t("popup.record.sectionHint")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-purpose-field">
|
||||
<Label htmlFor="bh-record-purpose" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.topicLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-purpose"
|
||||
type="text"
|
||||
value={purposeDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setPurposeDraft(event.target.value)}
|
||||
placeholder={t("popup.record.topicPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-purpose-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5" data-slot="popup-record-start-url-field">
|
||||
<Label htmlFor="bh-record-start-url" className="block text-xs text-muted-foreground">
|
||||
{t("popup.record.startUrlLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="bh-record-start-url"
|
||||
type="url"
|
||||
value={startUrlDraft}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => setStartUrlDraft(event.target.value)}
|
||||
placeholder={t("popup.record.startUrlPlaceholder")}
|
||||
className="mt-0 h-8 text-sm"
|
||||
data-slot="popup-record-start-url-input"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={recordPrompt}
|
||||
rows={5}
|
||||
className="w-full resize-none rounded-md border border-input bg-muted/40 px-2.5 py-2 font-mono text-[10px] leading-snug text-foreground/90 focus-visible:outline-none"
|
||||
data-slot="popup-record-prompt"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
{!recordReady && (
|
||||
<p className="text-[10px] text-muted-foreground" data-slot="popup-record-hint">
|
||||
{t("popup.record.hintDisconnected")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="ml-auto h-7 shrink-0 px-2.5 text-xs"
|
||||
disabled={!recordReady}
|
||||
onClick={() => {
|
||||
void copyRecordPrompt();
|
||||
}}
|
||||
data-slot="popup-record-copy"
|
||||
>
|
||||
{copiedPrompt ? (
|
||||
<>
|
||||
<RiCheckLine className="size-3.5" aria-hidden />
|
||||
{t("popup.record.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiFileCopyLine className="size-3.5" aria-hidden />
|
||||
{t("popup.record.copyButton")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { RiRecordCircleLine } from "@remixicon/react";
|
||||
import type { RemixiconComponentType } from "@remixicon/react";
|
||||
|
||||
export type PopupFeatureId = "record";
|
||||
|
||||
export type PopupView = "main" | "features" | PopupFeatureId;
|
||||
|
||||
export type PopupFeature = {
|
||||
id: PopupFeatureId;
|
||||
icon: RemixiconComponentType;
|
||||
titleKey: "popup.record.sectionTitle";
|
||||
descKey: "popup.record.cardDesc";
|
||||
};
|
||||
|
||||
export const POPUP_FEATURES: PopupFeature[] = [
|
||||
{
|
||||
id: "record",
|
||||
icon: RiRecordCircleLine,
|
||||
titleKey: "popup.record.sectionTitle",
|
||||
descKey: "popup.record.cardDesc",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,13 @@
|
||||
@import "@browser-skill/ui/styles";
|
||||
@source "../../";
|
||||
@source "../../../../../packages/ui/src";
|
||||
|
||||
/* The shared base sets html/body/#root to height:100%, which is meant for
|
||||
full-page surfaces. In a popup that pins the window to the tallest content
|
||||
it ever showed, so collapsing the record section leaves blank space. Size
|
||||
to content instead so Chrome can grow and shrink the popup to fit. */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeEventTarget,
|
||||
describeTarget,
|
||||
isMeaningfulClickTarget,
|
||||
} from "../describe-target";
|
||||
import { describeEventTarget, describeTarget, isMeaningfulClickTarget } from "../describe-target";
|
||||
|
||||
describe("describeTarget", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -140,7 +140,10 @@ export function accessibleName(el: Element): string | undefined {
|
||||
|
||||
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) {
|
||||
const value = normalizeWhitespace(el.value);
|
||||
if (value && (el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")) {
|
||||
if (
|
||||
value &&
|
||||
(el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")
|
||||
) {
|
||||
return truncate(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,18 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureBrowserObservationListeners,
|
||||
isBrowserObservationAttachedForTests,
|
||||
RECORD_DEFAULT_START_URL,
|
||||
releaseBrowserObservationListenersIfIdle,
|
||||
resetBrowserObservationForTests,
|
||||
setBrowserObservationAttachForTests,
|
||||
} from "../record";
|
||||
|
||||
describe("record defaults", () => {
|
||||
it("uses Baidu as the default injectable start URL", () => {
|
||||
expect(RECORD_DEFAULT_START_URL).toBe("https://www.baidu.com/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser observation lifecycle", () => {
|
||||
afterEach(() => {
|
||||
resetBrowserObservationForTests();
|
||||
|
||||
@@ -77,6 +77,9 @@ function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
|
||||
export const RECORD_DEFAULT_START_URL = "https://www.baidu.com/";
|
||||
|
||||
/** Pages where MV3 content scripts cannot attach (Agent Window boots here). */
|
||||
function isContentScriptRestrictedUrl(url: string | undefined): boolean {
|
||||
if (!url) return true;
|
||||
@@ -568,12 +571,12 @@ export async function handleRecordStart(
|
||||
resolveFinish = resolve;
|
||||
rejectFinish = reject;
|
||||
});
|
||||
const provisionalUrl = params.url;
|
||||
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
|
||||
recordings.set(params.session_id, {
|
||||
requestId,
|
||||
tabId: target.tabId,
|
||||
agentWindowId: ctx.agentWindowId,
|
||||
startUrl: provisionalUrl,
|
||||
startUrl: navigateUrl,
|
||||
...(params.purpose ? { purpose: params.purpose } : {}),
|
||||
steps: [],
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -582,7 +585,7 @@ export async function handleRecordStart(
|
||||
rejectFinish,
|
||||
settled: false,
|
||||
finishing: false,
|
||||
currentUrl: provisionalUrl,
|
||||
currentUrl: navigateUrl,
|
||||
pendingNavigation: false,
|
||||
pendingNavigationDeadline: undefined,
|
||||
});
|
||||
@@ -629,12 +632,12 @@ export async function handleRecordStart(
|
||||
if (cancelled) return cancelled;
|
||||
}
|
||||
|
||||
if (params.url && deps.cdp) {
|
||||
if (deps.cdp) {
|
||||
const nav = await handleNavigate(
|
||||
manager,
|
||||
{
|
||||
session_id: params.session_id,
|
||||
url: params.url,
|
||||
url: navigateUrl,
|
||||
tab_id: target.tabId,
|
||||
},
|
||||
{ cdp: deps.cdp, tabsApi: deps.tabsApi, signal: deps.signal },
|
||||
@@ -663,7 +666,7 @@ export async function handleRecordStart(
|
||||
const tab = await deps.tabsApi.get(target.tabId);
|
||||
startUrl = tab.url;
|
||||
} catch {
|
||||
startUrl = params.url;
|
||||
startUrl = navigateUrl;
|
||||
}
|
||||
|
||||
const active = recordings.get(params.session_id);
|
||||
@@ -680,7 +683,7 @@ export async function handleRecordStart(
|
||||
code: "invalid_params",
|
||||
message: params.url
|
||||
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
|
||||
: "Agent Window starts on about:blank where the content script cannot inject — pass --url https://… before recording",
|
||||
: `cannot record on restricted URL (${startUrl}); default start page must be injectable http(s)`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -713,7 +716,7 @@ export async function handleRecordStart(
|
||||
return {
|
||||
code: "protocol_error",
|
||||
message:
|
||||
"failed to start recording in content script — reload the BrowserSkill extension, then retry with --url https://…",
|
||||
"failed to start recording in content script — reload the BrowserSkill extension, then retry",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,15 @@ export default defineConfig({
|
||||
name: "BrowserSkill",
|
||||
description:
|
||||
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
|
||||
permissions: ["alarms", "debugger", "notifications", "tabs", "storage", "webNavigation", "windows"],
|
||||
permissions: [
|
||||
"alarms",
|
||||
"debugger",
|
||||
"notifications",
|
||||
"tabs",
|
||||
"storage",
|
||||
"webNavigation",
|
||||
"windows",
|
||||
],
|
||||
host_permissions: ["<all_urls>"],
|
||||
icons: {
|
||||
16: "icon/logo.png",
|
||||
|
||||
@@ -211,8 +211,8 @@ which refs/selectors matched a live element.
|
||||
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
|
||||
|
||||
```bash
|
||||
bsk record start --url https://… [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
# `--url` is required on a fresh session (about:blank cannot host the content script).
|
||||
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
# `--url` is optional; default https://www.baidu.com/ when omitted (must be http(s)).
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
|
||||
|
||||
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
|
||||
|
||||
@@ -41,12 +41,16 @@ pub enum RecordSub {
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct RecordStartArgs {
|
||||
/// Target browser instance id or label. Required when multiple browsers
|
||||
/// are connected; omit when only one is online.
|
||||
#[arg(long)]
|
||||
pub browser: Option<String>,
|
||||
|
||||
#[arg(long = "tab-id")]
|
||||
pub tab_id: Option<i64>,
|
||||
|
||||
/// Navigate to this http(s) URL before recording (required for a fresh
|
||||
/// session — Agent Window starts on about:blank, which cannot host the
|
||||
/// content script).
|
||||
/// Navigate to this http(s) URL before recording. When omitted, defaults
|
||||
/// to `https://www.baidu.com/`.
|
||||
#[arg(long)]
|
||||
pub url: Option<String>,
|
||||
|
||||
@@ -81,7 +85,7 @@ fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError>
|
||||
}
|
||||
|
||||
let info = ensure_daemon().context("ensure daemon is running")?;
|
||||
let session = start_session(info.sock_path.clone(), None)?;
|
||||
let session = start_session(info.sock_path.clone(), args.browser)?;
|
||||
|
||||
let start_params = RecordStartParams {
|
||||
session_id: session.session_id.clone(),
|
||||
@@ -240,6 +244,7 @@ mod tests {
|
||||
#[test]
|
||||
fn start_args_default_output_is_trace_json() {
|
||||
let args = RecordStartArgs {
|
||||
browser: None,
|
||||
tab_id: None,
|
||||
url: None,
|
||||
purpose: None,
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::Duration;
|
||||
|
||||
use bsk::cli::daemon::{DaemonCmd, parse_duration};
|
||||
use bsk::cli::navigate::NavigateCmd;
|
||||
use bsk::cli::record::{RecordCmd, RecordSub};
|
||||
use bsk::{Cli, Command};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -192,3 +193,37 @@ fn rejects_zero_click_count() {
|
||||
Cli::try_parse_from(["bsk", "click", "@e1", "--session", "s1", "--count", "0"]).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_record_start_with_browser_and_url() {
|
||||
let cli = parse(&[
|
||||
"bsk",
|
||||
"record",
|
||||
"start",
|
||||
"--browser",
|
||||
"022ca8ac",
|
||||
"--url",
|
||||
"https://x",
|
||||
]);
|
||||
let Command::Record(RecordCmd {
|
||||
sub: RecordSub::Start(args),
|
||||
}) = cli.command
|
||||
else {
|
||||
panic!("expected record start subcommand");
|
||||
};
|
||||
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
|
||||
assert_eq!(args.url.as_deref(), Some("https://x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_record_start_without_url() {
|
||||
let cli = parse(&["bsk", "record", "start", "--browser", "022ca8ac"]);
|
||||
let Command::Record(RecordCmd {
|
||||
sub: RecordSub::Start(args),
|
||||
}) = cli.command
|
||||
else {
|
||||
panic!("expected record start subcommand");
|
||||
};
|
||||
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
|
||||
assert!(args.url.is_none());
|
||||
}
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
"connectionToggleTitle": "BrowserSkill connection",
|
||||
"versionSkewWarning": "Extension protocol v{{extensionProtocol}}, daemon v{{daemonProtocol}}.",
|
||||
"upgradeAvailable": "Upgradable",
|
||||
"labelTitle": "Browser alias",
|
||||
"labelDescription": "Distinguish multiple browser instances.",
|
||||
"labelPlaceholder": "e.g. Personal Chrome",
|
||||
"launcher": {
|
||||
"title": "Quick actions"
|
||||
},
|
||||
"back": "Back",
|
||||
"runtimeTitle": "Runtime",
|
||||
"daemonVersion": "daemon v{{version}}",
|
||||
"extensionVersion": "extension v{{version}}",
|
||||
@@ -40,7 +41,20 @@
|
||||
"instanceDescription": "Unique connection identifier for this browser",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyInstanceId": "Copy instance ID"
|
||||
"copyInstanceId": "Copy instance ID",
|
||||
"record": {
|
||||
"sectionTitle": "Action recording",
|
||||
"cardDesc": "Record your actions for Agent reference",
|
||||
"sectionHint": "Generates a recording instruction — send it to your Agent to start recording your actions; the Agent can then use the recording to automate similar tasks.",
|
||||
"topicLabel": "Topic (optional)",
|
||||
"topicPlaceholder": "e.g. Publish an article",
|
||||
"startUrlLabel": "Starting URL (optional)",
|
||||
"startUrlPlaceholder": "https://…",
|
||||
"copyButton": "Copy recording instructions",
|
||||
"copied": "Copied",
|
||||
"hintDisconnected": "Available when connected",
|
||||
"promptTemplate": "Use BrowserSkill's `bsk` CLI to record the browser actions I'm about to demonstrate.\n\nSteps:\n1. Run: {{command}}\n2. Once you confirm it's started, I'll operate in the opened window myself.\n3. After I click Finish, read the returned trace.json and summarize the actions (pages + steps); don't re-run or \"verify\"."
|
||||
}
|
||||
},
|
||||
"controlOverlay": {
|
||||
"status": "Agent controlling",
|
||||
|
||||
@@ -25,9 +25,10 @@
|
||||
"connectionToggleTitle": "BrowserSkill 连接",
|
||||
"versionSkewWarning": "扩展协议 v{{extensionProtocol}},daemon 协议 v{{daemonProtocol}}。",
|
||||
"upgradeAvailable": "可升级",
|
||||
"labelTitle": "浏览器别名",
|
||||
"labelDescription": "用于区分多个浏览器实例。",
|
||||
"labelPlaceholder": "例如:个人 Chrome",
|
||||
"launcher": {
|
||||
"title": "快捷功能"
|
||||
},
|
||||
"back": "返回",
|
||||
"runtimeTitle": "运行版本",
|
||||
"daemonVersion": "daemon v{{version}}",
|
||||
"extensionVersion": "扩展 v{{version}}",
|
||||
@@ -40,7 +41,20 @@
|
||||
"instanceDescription": "本浏览器的唯一连接标识",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"copyInstanceId": "复制实例 ID"
|
||||
"copyInstanceId": "复制实例 ID",
|
||||
"record": {
|
||||
"sectionTitle": "操作录制",
|
||||
"cardDesc": "录制你的操作,供 Agent 参考",
|
||||
"sectionHint": "生成一段录制指令,发给 Agent 后可以启动插件录制用户操作,之后 Agent 可以参考录制结果自动完成同类任务。",
|
||||
"topicLabel": "操作主题(可选)",
|
||||
"topicPlaceholder": "例如:发布一篇文章",
|
||||
"startUrlLabel": "起始网址(可选)",
|
||||
"startUrlPlaceholder": "https://…",
|
||||
"copyButton": "复制录制指令",
|
||||
"copied": "已复制",
|
||||
"hintDisconnected": "连接后可用",
|
||||
"promptTemplate": "用 BrowserSkill 的 `bsk` CLI 录制我接下来的浏览器操作。\n\n步骤:\n1. 执行:{{command}}\n2. 你确认已开始后,我会在打开的窗口里自己操作。\n3. 我点“结束”后,读取返回的 trace.json 并总结操作(pages + steps),不要重跑或“验证”。"
|
||||
}
|
||||
},
|
||||
"controlOverlay": {
|
||||
"status": "Agent 正在控制",
|
||||
|
||||
+2
-2
@@ -211,8 +211,8 @@ which refs/selectors matched a live element.
|
||||
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
|
||||
|
||||
```bash
|
||||
bsk record start --url https://… [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
# `--url` is required on a fresh session (about:blank cannot host the content script).
|
||||
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
|
||||
# `--url` is optional; default https://www.baidu.com/ when omitted (must be http(s)).
|
||||
# Blocks until the user clicks Finish in the recording panel, then writes ./trace.json and closes the window.
|
||||
|
||||
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
|
||||
|
||||
Reference in New Issue
Block a user