From 585308df4594c0f2ed77ea0a16d4be7e8357b855 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Wed, 22 Jul 2026 13:27:30 +0800 Subject: [PATCH] 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. --- apps/extension/src/content/RecordOverlay.tsx | 4 +- .../content/__tests__/record-capture.test.ts | 10 +- apps/extension/src/content/record-capture.ts | 6 +- .../src/entrypoints/popup/App.test.tsx | 117 ++++- apps/extension/src/entrypoints/popup/App.tsx | 407 ++++++++++++------ .../src/entrypoints/popup/features.ts | 22 + .../extension/src/entrypoints/popup/style.css | 10 + .../src/lib/__tests__/describe-target.test.ts | 6 +- apps/extension/src/lib/describe-target.ts | 5 +- .../__tests__/record-observation.test.ts | 7 + apps/extension/src/tools/record.ts | 19 +- apps/extension/wxt.config.ts | 10 +- crates/bsk-cli/skill/SKILL.md | 4 +- crates/bsk-cli/src/cli/record.rs | 13 +- crates/bsk-cli/tests/cli_parse.rs | 35 ++ .../i18n/src/locales/en-US/extension.json | 22 +- .../i18n/src/locales/zh-CN/extension.json | 22 +- skill/SKILL.md | 4 +- 18 files changed, 546 insertions(+), 177 deletions(-) create mode 100644 apps/extension/src/entrypoints/popup/features.ts diff --git a/apps/extension/src/content/RecordOverlay.tsx b/apps/extension/src/content/RecordOverlay.tsx index 9273739..a083952 100644 --- a/apps/extension/src/content/RecordOverlay.tsx +++ b/apps/extension/src/content/RecordOverlay.tsx @@ -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, }} > - + {t("recordOverlay.finish")} diff --git a/apps/extension/src/content/__tests__/record-capture.test.ts b/apps/extension/src/content/__tests__/record-capture.test.ts index 0cada90..38ac5d7 100644 --- a/apps/extension/src/content/__tests__/record-capture.test.ts +++ b/apps/extension/src/content/__tests__/record-capture.test.ts @@ -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" }), diff --git a/apps/extension/src/content/record-capture.ts b/apps/extension/src/content/record-capture.ts index e6962a1..12bf6af 100644 --- a/apps/extension/src/content/record-capture.ts +++ b/apps/extension/src/content/record-capture.ts @@ -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, diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index 0f59e46..aa474e1 100644 --- a/apps/extension/src/entrypoints/popup/App.test.tsx +++ b/apps/extension/src/entrypoints/popup/App.test.tsx @@ -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(); + it("does not render record UI on the main view", () => { + render(); - 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(); + + 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(); + + 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(); + 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(); + 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(); + openRecordView(); + + const copyButton = screen.getByRole("button", { name: "复制录制指令" }); + expect(copyButton.getAttribute("disabled")).not.toBeNull(); + expect(screen.getByText("连接后可用")).toBeTruthy(); + }); }); diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index 09b0c3c..cc48bf5 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -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("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 (
-
- -

{t("popup.brandName")}

-
- -
-
-
- - - {t(STATE_LABEL_KEYS[statusState])} - -
-
- - {t(STATE_BADGE_KEYS[statusState])} - - -
-
- {isSkewed && ( -

- {t("popup.versionSkewWarning", { - extensionProtocol: PROTOCOL_VERSION, - daemonProtocol, - })} -

- )} -
- - {snapshot.lastError && ( -
- {snapshot.lastError} -
- )} - -
- - ) => setLabelDraft(event.target.value)} - onBlur={onLabelBlur} - placeholder={t("popup.labelPlaceholder")} - className="mt-0 h-8 text-sm" - data-slot="popup-label-input" - /> -
- -
-
- {extensionVersion} - / - {daemonVersion} -
-
- {t("popup.instanceTitle")} - - {instanceId} - + {view !== "main" ? ( -
-
+ ) : ( + + )} +

{headerTitle}

+ {view === "main" && ( + + )} + + + {view === "main" && ( + <> +
+
+
+ + + {t(STATE_LABEL_KEYS[statusState])} + +
+
+ + {t(STATE_BADGE_KEYS[statusState])} + + +
+
+ {isSkewed && ( +

+ {t("popup.versionSkewWarning", { + extensionProtocol: PROTOCOL_VERSION, + daemonProtocol, + })} +

+ )} +
+ + {snapshot.lastError && ( +
+ {snapshot.lastError} +
+ )} + +
+
+ {extensionVersion} + / + {daemonVersion} +
+
+ {t("popup.instanceTitle")} + + {instanceId} + + +
+
+ + )} + + {view === "features" && ( +
+ {POPUP_FEATURES.map((feature) => { + const Icon = feature.icon; + return ( + + ); + })} +
+ )} + + {view === "record" && ( +
+

+ {t("popup.record.sectionHint")} +

+
+ + ) => setPurposeDraft(event.target.value)} + placeholder={t("popup.record.topicPlaceholder")} + className="mt-0 h-8 text-sm" + data-slot="popup-record-purpose-input" + /> +
+
+ + ) => setStartUrlDraft(event.target.value)} + placeholder={t("popup.record.startUrlPlaceholder")} + className="mt-0 h-8 text-sm" + data-slot="popup-record-start-url-input" + /> +
+