From baddd0f70063a37a3eef6eb02d954d5a169fa61a Mon Sep 17 00:00:00 2001 From: nemo Date: Sun, 20 Sep 2026 22:56:42 +0800 Subject: [PATCH] feat(extension): add browser instance naming --- README.md | 3 + README.zh-CN.md | 3 + apps/extension/src/entrypoints/background.ts | 4 +- apps/extension/src/entrypoints/popup/App.tsx | 4 +- .../entrypoints/popup/browser-label.test.tsx | 90 +++++++++++++++++++ .../src/entrypoints/popup/browser-label.tsx | 80 +++++++++++++++++ .../__tests__/connection-controller.test.ts | 35 ++++++++ .../src/lib/connection-controller.ts | 7 +- docs/browser-profiles.md | 10 +++ .../i18n/src/locales/en-US/extension.json | 7 ++ .../i18n/src/locales/ko-KR/extension.json | 7 ++ .../i18n/src/locales/zh-CN/extension.json | 7 ++ 12 files changed, 254 insertions(+), 3 deletions(-) create mode 100644 apps/extension/src/entrypoints/popup/browser-label.test.tsx create mode 100644 apps/extension/src/entrypoints/popup/browser-label.tsx diff --git a/README.md b/README.md index a8efbbf4..f0781e34 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,9 @@ Start tasks with `bsk session start`; add `--no-focus` to avoid focusing the Age For a specific Chrome profile, use **Copy profile instructions** in that profile's extension popup and send them to your agent. This pins each new session to its instance with `--browser`, even when only one browser is online. See [browser profile selection](docs/browser-profiles.md). +For recurring use, set a unique **Browser name** in the same popup and select it with +`--browser "Work profile"`. Saving a name reconnects BrowserSkill and ends active tasks in +that browser so the daemon can use the new name immediately. For unattended operation, turn off the corresponding settings in the extension. `--unattended`, `tab borrow --no-confirm`, and `BSK_REQUEST_HELP=off` remain accepted for compatibility but are deprecated and cannot override the switches. The CLI logs a notice when these inputs are used; diff --git a/README.zh-CN.md b/README.zh-CN.md index 0da92ee3..d562b749 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -216,6 +216,9 @@ bsk update --yes 需要指定 Chrome Profile 时,在目标 Profile 的扩展弹窗中点击“复制此 Profile 的指令”, 再发给 Agent。指令通过 `--browser` 为每个新会话固定实例,即使只有一个浏览器在线也不省略。 详见[浏览器 Profile 选择](docs/browser-profiles.md)。 +需要长期使用时,可以在同一弹窗中设置唯一的“浏览器名称”,并通过 +`--browser "工作账号"` 选择。保存名称会重新连接 BrowserSkill,并结束该浏览器中的当前任务, +使 Daemon 立即使用新名称。 无人值守由用户在插件中关闭相应开关。`--unattended`、`tab borrow --no-confirm`、 `BSK_REQUEST_HELP=off` 保留兼容识别,但已弃用,不能覆盖插件开关。CLI 使用这些输入时会输出说明, Daemon 也会为自身继承的旧环境设置记录说明。原先只依靠这些输入避免等待的脚本,现在需要遵循浏览器设置。 diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 830bbbe9..4d459591 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -476,7 +476,9 @@ export default defineBackground(() => { const msg = raw as PopupOutbound; if (msg && typeof msg === "object" && "kind" in msg) { if (msg.kind === "set_label") { - void setLabel(msg.value).then(() => controller.refreshLabel()); + void setLabel(msg.value) + .then(() => controller.refreshLabel()) + .catch((err) => console.error("[browser-skill] label update failed", err)); } else if (msg.kind === "set_connection_enabled") { void controller.setConnectionEnabled(msg.value); // Persist user intent in message order, independently of slow cleanup. diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index d2451963..2b39112b 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -6,6 +6,7 @@ import { AuditPanel } from "@/components/audit-panel"; import { compareProtocol } from "@/lib/semver"; import { PROTOCOL_VERSION } from "@/transport/handshake"; import functionIconUrl from "../../../assets/function.svg"; +import { BrowserLabel } from "./browser-label"; import { ConnectionSettings } from "./connection-settings"; import { ConnectionStatusIndicator } from "./connection-status-indicator"; import { POPUP_FEATURES, type PopupView } from "./features"; @@ -40,7 +41,7 @@ function getLogoSrc() { export function App() { const { t } = useTranslation("extension"); - const { snapshot, statusState, setConnectionEnabled } = useConnectionState(); + const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState(); const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden(); const [view, setView] = useState("main"); const [copiedInstanceId, setCopiedInstanceId] = useState(false); @@ -233,6 +234,7 @@ export function App() { connectionEnabled={snapshot.connectionEnabled} disconnected={isDisconnected && !snapshot.lastError} /> + diff --git a/apps/extension/src/entrypoints/popup/browser-label.test.tsx b/apps/extension/src/entrypoints/popup/browser-label.test.tsx new file mode 100644 index 00000000..a99e6f23 --- /dev/null +++ b/apps/extension/src/entrypoints/popup/browser-label.test.tsx @@ -0,0 +1,90 @@ +import { i18n } from "@browser-skill/i18n"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BrowserLabel } from "./browser-label"; + +describe("browser label", () => { + beforeEach(async () => { + await i18n.changeLanguage("en-US"); + }); + + afterEach(async () => { + cleanup(); + await i18n.changeLanguage("zh-CN"); + vi.restoreAllMocks(); + }); + + it("saves a trimmed human-readable browser name", () => { + const onSave = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox", { name: "Browser name" }), { + target: { value: " Work profile " }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith("Work profile"); + }); + + it("supports Enter to save and Escape to discard", () => { + const onSave = vi.fn(); + render(); + const input = screen.getByRole("textbox", { name: "Browser name" }); + + fireEvent.change(input, { target: { value: "Work" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onSave).toHaveBeenCalledWith("Work"); + + fireEvent.change(input, { target: { value: "Temporary" } }); + fireEvent.keyDown(input, { key: "Escape" }); + expect((input as HTMLInputElement).value).toBe("Personal"); + }); + + it("allows clearing a saved name", () => { + const onSave = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox", { name: "Browser name" }), { + target: { value: "" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + expect(onSave).toHaveBeenCalledWith(""); + }); + + it("rejects names that can be confused with an instance id", () => { + const onSave = vi.fn(); + render(); + + fireEvent.change(screen.getByRole("textbox", { name: "Browser name" }), { + target: { value: "deadbeef" }, + }); + + expect(screen.getByRole("alert")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Save" }).hasAttribute("disabled")).toBe(true); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("follows a label update from the background snapshot", () => { + const onSave = vi.fn(); + const { rerender } = render(); + + rerender(); + + expect((screen.getByRole("textbox", { name: "Browser name" }) as HTMLInputElement).value).toBe( + "Work", + ); + expect(screen.getByRole("button", { name: "Save" }).hasAttribute("disabled")).toBe(true); + }); + + it.each([ + ["zh-CN", "浏览器名称", "保存"], + ["ko-KR", "브라우저 이름", "저장"], + ])("renders the naming controls in %s", async (locale, fieldName, saveName) => { + await i18n.changeLanguage(locale); + render(); + + expect(screen.getByRole("textbox", { name: fieldName })).toBeTruthy(); + expect(screen.getByRole("button", { name: saveName })).toBeTruthy(); + }); +}); diff --git a/apps/extension/src/entrypoints/popup/browser-label.tsx b/apps/extension/src/entrypoints/popup/browser-label.tsx new file mode 100644 index 00000000..84052b7e --- /dev/null +++ b/apps/extension/src/entrypoints/popup/browser-label.tsx @@ -0,0 +1,80 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { Button, Input, Label } from "@browser-skill/ui"; +import { type KeyboardEvent, useEffect, useState } from "react"; +import { SHORT_INSTANCE_ID_PATTERN } from "@/lib/instance-id"; + +const MAX_LABEL_LENGTH = 48; + +export function BrowserLabel({ + label, + onSave, +}: { + label: string; + onSave: (value: string) => void; +}) { + const { t } = useTranslation("extension"); + const [draft, setDraft] = useState(label); + + useEffect(() => { + setDraft(label); + }, [label]); + + const normalized = draft.trim(); + const changed = normalized !== label; + const looksLikeInstanceId = normalized !== "" && SHORT_INSTANCE_ID_PATTERN.test(normalized); + const save = () => { + if (!changed || looksLikeInstanceId) return; + onSave(normalized); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + save(); + } else if (event.key === "Escape") { + setDraft(label); + } + }; + + return ( +
+ +
+ setDraft(event.target.value)} + onKeyDown={onKeyDown} + placeholder={t("popup.browserLabel.placeholder")} + className="h-8 rounded-lg text-sm" + aria-invalid={looksLikeInstanceId || undefined} + aria-describedby="bsk-browser-label-hint" + data-slot="popup-label-input" + /> + +
+ {looksLikeInstanceId ? ( + + ) : ( +

+ {t("popup.browserLabel.hint")} +

+ )} +
+ ); +} diff --git a/apps/extension/src/lib/__tests__/connection-controller.test.ts b/apps/extension/src/lib/__tests__/connection-controller.test.ts index 0414bbbc..c2eba3cd 100644 --- a/apps/extension/src/lib/__tests__/connection-controller.test.ts +++ b/apps/extension/src/lib/__tests__/connection-controller.test.ts @@ -3,6 +3,7 @@ import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake"; import type { ConnectionStateHandler, FrameHandler, Transport } from "../../transport/transport"; import type { ConnectionState, HandshakeResult, ProtocolFrame } from "../../transport/types"; import { __testing__, ConnectionController } from "../connection-controller"; +import { getLabel } from "../instance-id"; vi.mock("../instance-id", () => ({ getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"), @@ -225,6 +226,40 @@ describe("ConnectionController connectionEnabled", () => { expect(controller.snapshot().connectionEnabled).toBe(true); }); + it("reconnects after a saved label changes so the daemon receives it", async () => { + const controller = new ConnectionController(); + const transport = makeMockTransport(); + const onDisconnected = vi.fn(async () => {}); + await controller.attach(transport, { name: "Chrome", version: "120" }, true, { + onDisconnected, + }); + const first = transport.send.mock.calls[0]?.[0] as { id: string }; + transport.emitMessage({ id: first.id, result: handshake("1.3", "1.3") }); + await vi.waitFor(() => expect(controller.snapshot().state).toBe("connected")); + + vi.mocked(getLabel).mockResolvedValueOnce("Work profile"); + await controller.refreshLabel(); + await vi.waitFor(() => expect(transport.connect).toHaveBeenCalledTimes(2)); + + const second = transport.send.mock.calls[1]?.[0] as { + params: { label: string }; + }; + expect(second.params.label).toBe("Work profile"); + expect(controller.snapshot().label).toBe("Work profile"); + expect(onDisconnected).toHaveBeenCalledTimes(1); + }); + + it("does not reconnect when the saved label is unchanged", async () => { + const controller = new ConnectionController(); + const transport = makeMockTransport(); + await controller.attach(transport, { name: "Chrome", version: "120" }, true); + + await controller.refreshLabel(); + + expect(transport.connect).toHaveBeenCalledTimes(1); + expect(transport.disconnect).not.toHaveBeenCalled(); + }); + it("ignores transport state changes while disabled", async () => { const controller = new ConnectionController(); const transport = makeMockTransport(); diff --git a/apps/extension/src/lib/connection-controller.ts b/apps/extension/src/lib/connection-controller.ts index b3c99cf0..013b7152 100644 --- a/apps/extension/src/lib/connection-controller.ts +++ b/apps/extension/src/lib/connection-controller.ts @@ -185,8 +185,13 @@ export class ConnectionController { } async refreshLabel(): Promise { - this.label = await getLabel(); + const nextLabel = await getLabel(); + if (nextLabel === this.label) return; + this.label = nextLabel; this.fire(); + // The label is part of the opening handshake, so reconnect to make the + // daemon's browser registry reflect the saved name immediately. + if (this.ready && this.connectionEnabled) await this.teardown(false); } private startHandshake(browser: { name: string; version: string }): void { diff --git a/docs/browser-profiles.md b/docs/browser-profiles.md index 1118eaea..3e8cfb5d 100644 --- a/docs/browser-profiles.md +++ b/docs/browser-profiles.md @@ -25,6 +25,16 @@ profile display name or directory name. An existing, verified unique BrowserSkil label also works; labels are not populated from Chrome profile names automatically. The instruction only copies text: it does not start a session or change settings. +For a reusable human-readable selector, set **Browser name** in the extension popup, +then confirm it appears in `bsk browsers`. Keep names unique among connected browsers; +label matching is exact and duplicate labels are rejected as ambiguous. Saving a name +reconnects BrowserSkill so the daemon sees it immediately, which ends active tasks in +that browser. You can then start a session with, for example: + +```sh +bsk session start --browser "Work profile" --json +``` + If the target is offline, reconnect BrowserSkill in that profile and retry the same selector. Do not remove `--browser` to get past the error: that could select another profile. A missing selector is rejected when multiple browsers are online, diff --git a/packages/i18n/src/locales/en-US/extension.json b/packages/i18n/src/locales/en-US/extension.json index 9e76942a..ac863a6f 100644 --- a/packages/i18n/src/locales/en-US/extension.json +++ b/packages/i18n/src/locales/en-US/extension.json @@ -55,6 +55,13 @@ "copy": "Copy", "copied": "Copied", "copyInstanceId": "Copy instance ID", + "browserLabel": { + "title": "Browser name", + "placeholder": "e.g. Work profile", + "save": "Save", + "hint": "Use a unique name when talking to your agent. Saving reconnects BrowserSkill and ends active tasks.", + "instanceIdError": "Choose a name other than an 8-character hexadecimal instance ID." + }, "record": { "sectionTitle": "Action recording", "cardDesc": "Record your actions for Agent reference", diff --git a/packages/i18n/src/locales/ko-KR/extension.json b/packages/i18n/src/locales/ko-KR/extension.json index 352c5bac..34ba5c4c 100644 --- a/packages/i18n/src/locales/ko-KR/extension.json +++ b/packages/i18n/src/locales/ko-KR/extension.json @@ -55,6 +55,13 @@ "copy": "복사", "copied": "복사됨", "copyInstanceId": "인스턴스 ID 복사", + "browserLabel": { + "title": "브라우저 이름", + "placeholder": "예: 업무 프로필", + "save": "저장", + "hint": "에이전트에게 브라우저를 지정할 때 사용할 고유한 이름을 입력하세요. 저장하면 BrowserSkill이 다시 연결되고 진행 중인 작업이 종료됩니다.", + "instanceIdError": "8자리 16진수 인스턴스 ID 형식이 아닌 이름을 선택하세요." + }, "record": { "sectionTitle": "작업 기록", "cardDesc": "에이전트가 참고할 수 있도록 작업 기록", diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 996ad519..27779d7a 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -55,6 +55,13 @@ "copy": "复制", "copied": "已复制", "copyInstanceId": "复制实例 ID", + "browserLabel": { + "title": "浏览器名称", + "placeholder": "例如:工作账号", + "save": "保存", + "hint": "使用唯一名称,方便向 Agent 指定浏览器。保存会重新连接 BrowserSkill,并结束当前任务。", + "instanceIdError": "名称不能使用 8 位十六进制实例 ID 格式。" + }, "record": { "sectionTitle": "操作录制", "cardDesc": "录制你的操作,供 Agent 参考",