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:
“haonan”
2026-07-22 13:27:30 +08:00
parent ac028a7a18
commit 585308df45
18 changed files with 546 additions and 177 deletions
+2 -2
View File
@@ -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 -5
View File
@@ -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();
});
});
+281 -126
View File
@@ -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(() => {
+4 -1
View File
@@ -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();
+11 -8
View File
@@ -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",
};
}
+9 -1
View File
@@ -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",
+2 -2
View File
@@ -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
+9 -4
View File
@@ -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,
+35
View File
@@ -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());
}
+18 -4
View File
@@ -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",
+18 -4
View File
@@ -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
View File
@@ -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