mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
- scripts/release.mjs: unified version bump + tag + push automation
- CHANGELOG.md: document all changes from 0.1.x through 0.2.0
- popup: show "CLI {ver} / Ext {ver}" instead of bare version numbers
Co-authored-by: Cursor <cursoragent@cursor.com>
419 lines
16 KiB
TypeScript
419 lines
16 KiB
TypeScript
import { useTranslation } from "@browser-skill/i18n/react";
|
|
import { Badge, Button, Input, Label } from "@browser-skill/ui";
|
|
import {
|
|
RiArrowLeftLine,
|
|
RiArrowRightSLine,
|
|
RiCheckLine,
|
|
RiFileCopyLine,
|
|
RiInformationLine,
|
|
} from "@remixicon/react";
|
|
import { type ChangeEvent, useEffect, useState } from "react";
|
|
import { PROTOCOL_VERSION } from "@/transport/handshake";
|
|
import functionIconUrl from "../../../assets/function.svg";
|
|
import { ConnectionStatusIndicator } from "./connection-status-indicator";
|
|
import { POPUP_FEATURES, type PopupView } from "./features";
|
|
import { Switch } from "./switch";
|
|
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
|
|
import { useControlHintsHidden } from "./use-control-hints-hidden";
|
|
|
|
const STATE_LABEL_KEYS = {
|
|
disconnected: "popup.stateLabel.disconnected",
|
|
connected: "popup.stateLabel.connected",
|
|
version_skew: "popup.stateLabel.version_skew",
|
|
disabled: "popup.stateLabel.disabled",
|
|
} as const satisfies Record<PopupStatusState, string>;
|
|
|
|
const STATE_BADGE_KEYS = {
|
|
disconnected: "popup.stateBadge.disconnected",
|
|
connected: "popup.stateBadge.connected",
|
|
version_skew: "popup.stateBadge.version_skew",
|
|
disabled: "popup.stateBadge.disabled",
|
|
} as const satisfies Record<PopupStatusState, string>;
|
|
|
|
function getLogoSrc() {
|
|
if (typeof chrome !== "undefined" && chrome.runtime?.getURL) {
|
|
return chrome.runtime.getURL("icon/logo.png");
|
|
}
|
|
return "/icon/logo.png";
|
|
}
|
|
|
|
export function App() {
|
|
const { t } = useTranslation("extension");
|
|
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
|
|
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
|
|
const [view, setView] = useState<PopupView>("main");
|
|
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
|
|
const [purposeDraft, setPurposeDraft] = useState("");
|
|
const [startUrlDraft, setStartUrlDraft] = useState("");
|
|
// Bumped on every successful copy so the "copied" toast re-shows (and its
|
|
// auto-hide timer restarts) even when the copied content is unchanged.
|
|
const [copiedTick, setCopiedTick] = useState(0);
|
|
const showCopiedToast = copiedTick > 0;
|
|
|
|
useEffect(() => {
|
|
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const applyTheme = (isDark: boolean) => {
|
|
document.documentElement.classList.toggle("dark", isDark);
|
|
};
|
|
applyTheme(query.matches);
|
|
const onChange = (event: MediaQueryListEvent) => applyTheme(event.matches);
|
|
query.addEventListener("change", onChange);
|
|
return () => query.removeEventListener("change", onChange);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setCopiedInstanceId(false);
|
|
setCopiedTick(0);
|
|
}, [snapshot.instanceId]);
|
|
|
|
// Hide the copied toast when the command changes (topic / start URL edits).
|
|
useEffect(() => {
|
|
setCopiedTick(0);
|
|
}, [purposeDraft, startUrlDraft]);
|
|
|
|
// Auto-hide the copied toast shortly after it appears.
|
|
useEffect(() => {
|
|
if (copiedTick === 0) return;
|
|
const timer = window.setTimeout(() => setCopiedTick(0), 1500);
|
|
return () => window.clearTimeout(timer);
|
|
}, [copiedTick]);
|
|
|
|
const isSkewed = statusState === "version_skew";
|
|
const connectionLive = statusState === "connected" || isSkewed;
|
|
const daemonVersion = snapshot.handshake?.version ?? "—";
|
|
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
|
|
const extensionVersion = snapshot.extensionVersion || "—";
|
|
const instanceId = snapshot.instanceId || "—";
|
|
|
|
const copyInstanceId = async () => {
|
|
if (!snapshot.instanceId || !navigator.clipboard?.writeText) return;
|
|
await navigator.clipboard.writeText(snapshot.instanceId);
|
|
setCopiedInstanceId(true);
|
|
};
|
|
|
|
const recordReady = connectionLive && 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);
|
|
setCopiedTick((tick) => tick + 1);
|
|
};
|
|
|
|
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={view === "main" ? "popup-brand-header" : "popup-subview-header"}
|
|
>
|
|
{view !== "main" ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 shrink-0 rounded-md"
|
|
aria-label={t("popup.back")}
|
|
onClick={() => setView(view === "record" ? "features" : "main")}
|
|
data-slot="popup-back"
|
|
>
|
|
<RiArrowLeftLine className="size-4" aria-hidden />
|
|
</Button>
|
|
) : (
|
|
<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="group size-8 shrink-0 rounded-md"
|
|
aria-label={t("popup.launcher.title")}
|
|
onClick={() => setView("features")}
|
|
data-slot="popup-launcher"
|
|
>
|
|
<img
|
|
src={functionIconUrl}
|
|
alt=""
|
|
className="size-6 opacity-35 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
|
|
data-slot="popup-launcher-icon"
|
|
/>
|
|
</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>
|
|
<Switch
|
|
checked={snapshot.connectionEnabled}
|
|
onCheckedChange={setConnectionEnabled}
|
|
aria-label={t("popup.connectionToggleTitle")}
|
|
data-slot="popup-connection-toggle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{isSkewed && (
|
|
<p
|
|
className="mt-2 text-xs leading-snug text-muted-foreground"
|
|
data-slot="popup-version-skew-warning"
|
|
>
|
|
{t("popup.versionSkewWarning", {
|
|
extensionProtocol: PROTOCOL_VERSION,
|
|
cliProtocol: daemonProtocol,
|
|
})}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section
|
|
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
|
|
data-slot="popup-control-hints-card"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="flex min-w-0 items-center gap-1">
|
|
<span className="truncate text-sm font-medium">
|
|
{t("popup.controlHintsToggleTitle")}
|
|
</span>
|
|
<span className="group relative inline-flex shrink-0">
|
|
<button
|
|
type="button"
|
|
aria-label={t("popup.controlHintsInfoLabel")}
|
|
data-slot="popup-control-hints-info"
|
|
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
>
|
|
<RiInformationLine className="size-3.5" aria-hidden />
|
|
</button>
|
|
<span
|
|
role="tooltip"
|
|
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
|
>
|
|
{t("popup.controlHintsToggleHint")}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
<Switch
|
|
checked={!controlHintsHidden}
|
|
onCheckedChange={(shown) => setControlHintsHidden(!shown)}
|
|
aria-label={t("popup.controlHintsToggleTitle")}
|
|
data-slot="popup-control-hints-toggle"
|
|
/>
|
|
</div>
|
|
</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.daemonVersionHint")}>CLI {daemonVersion}</span>
|
|
<span aria-hidden>/</span>
|
|
<span title={t("popup.extensionVersionHint")}>Ext {extensionVersion}</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>
|
|
)}
|
|
<div className="relative ml-auto shrink-0">
|
|
{showCopiedToast && (
|
|
<div
|
|
role="status"
|
|
className="absolute bottom-full right-0 mb-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium text-background shadow-md backdrop-blur-sm"
|
|
data-slot="popup-record-copied-toast"
|
|
>
|
|
<RiCheckLine className="size-3" aria-hidden />
|
|
{t("popup.record.copied")}
|
|
</div>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
className="h-7 px-2.5 text-xs"
|
|
disabled={!recordReady}
|
|
onClick={() => {
|
|
void copyRecordPrompt();
|
|
}}
|
|
data-slot="popup-record-copy"
|
|
>
|
|
<RiFileCopyLine className="size-3.5" aria-hidden />
|
|
{t("popup.record.copyButton")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|