Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 也会为自身继承的旧环境设置记录说明。原先只依靠这些输入避免等待的脚本,现在需要遵循浏览器设置。
Expand Down
4 changes: 3 additions & 1 deletion apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion apps/extension/src/entrypoints/popup/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<PopupView>("main");
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
Expand Down Expand Up @@ -233,6 +234,7 @@ export function App() {
connectionEnabled={snapshot.connectionEnabled}
disconnected={isDisconnected && !snapshot.lastError}
/>
<BrowserLabel label={snapshot.label} onSave={setLabel} />
<ProfileInstructions instanceId={snapshot.instanceId} connected={connectionLive} />
</section>

Expand Down
90 changes: 90 additions & 0 deletions apps/extension/src/entrypoints/popup/browser-label.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<BrowserLabel label="" onSave={onSave} />);

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(<BrowserLabel label="Personal" onSave={onSave} />);
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(<BrowserLabel label="Work" onSave={onSave} />);

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(<BrowserLabel label="" onSave={onSave} />);

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(<BrowserLabel label="Personal" onSave={onSave} />);

rerender(<BrowserLabel label="Work" onSave={onSave} />);

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(<BrowserLabel label="" onSave={vi.fn()} />);

expect(screen.getByRole("textbox", { name: fieldName })).toBeTruthy();
expect(screen.getByRole("button", { name: saveName })).toBeTruthy();
});
});
80 changes: 80 additions & 0 deletions apps/extension/src/entrypoints/popup/browser-label.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
save();
} else if (event.key === "Escape") {
setDraft(label);
}
};

return (
<div className="mt-3 space-y-1.5 border-t border-border/70 pt-2" data-slot="popup-label-field">
<Label htmlFor="bsk-browser-label" className="text-xs font-medium">
{t("popup.browserLabel.title")}
</Label>
<div className="flex items-center gap-2">
<Input
id="bsk-browser-label"
type="text"
value={draft}
maxLength={MAX_LABEL_LENGTH}
onChange={(event) => 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"
/>
<Button
type="button"
variant="secondary"
size="sm"
className="h-8 px-3 text-xs"
disabled={!changed || looksLikeInstanceId}
onClick={save}
data-slot="popup-label-save"
>
{t("popup.browserLabel.save")}
</Button>
</div>
{looksLikeInstanceId ? (
<p id="bsk-browser-label-hint" role="alert" className="text-[11px] text-destructive">
{t("popup.browserLabel.instanceIdError")}
</p>
) : (
<p id="bsk-browser-label-hint" className="text-[11px] leading-snug text-muted-foreground">
{t("popup.browserLabel.hint")}
</p>
)}
</div>
);
}
35 changes: 35 additions & 0 deletions apps/extension/src/lib/__tests__/connection-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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();
Expand Down
7 changes: 6 additions & 1 deletion apps/extension/src/lib/connection-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,13 @@ export class ConnectionController {
}

async refreshLabel(): Promise<void> {
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 {
Expand Down
10 changes: 10 additions & 0 deletions docs/browser-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/en-US/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/ko-KR/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"copy": "복사",
"copied": "복사됨",
"copyInstanceId": "인스턴스 ID 복사",
"browserLabel": {
"title": "브라우저 이름",
"placeholder": "예: 업무 프로필",
"save": "저장",
"hint": "에이전트에게 브라우저를 지정할 때 사용할 고유한 이름을 입력하세요. 저장하면 BrowserSkill이 다시 연결되고 진행 중인 작업이 종료됩니다.",
"instanceIdError": "8자리 16진수 인스턴스 ID 형식이 아닌 이름을 선택하세요."
},
"record": {
"sectionTitle": "작업 기록",
"cardDesc": "에이전트가 참고할 수 있도록 작업 기록",
Expand Down
7 changes: 7 additions & 0 deletions packages/i18n/src/locales/zh-CN/extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@
"copy": "复制",
"copied": "已复制",
"copyInstanceId": "复制实例 ID",
"browserLabel": {
"title": "浏览器名称",
"placeholder": "例如:工作账号",
"save": "保存",
"hint": "使用唯一名称,方便向 Agent 指定浏览器。保存会重新连接 BrowserSkill,并结束当前任务。",
"instanceIdError": "名称不能使用 8 位十六进制实例 ID 格式。"
},
"record": {
"sectionTitle": "操作录制",
"cardDesc": "录制你的操作,供 Agent 参考",
Expand Down
Loading