diff --git a/apps/extension/src/content/ControlOverlay.tsx b/apps/extension/src/content/ControlOverlay.tsx index 51ed2f2..c6afc5b 100644 --- a/apps/extension/src/content/ControlOverlay.tsx +++ b/apps/extension/src/content/ControlOverlay.tsx @@ -58,6 +58,7 @@ export function ControlOverlay({ />
{ if (automationBypass) return; event.preventDefault(); @@ -80,13 +81,16 @@ export function ControlOverlay({ />
void; +} + +type Props = { + request: RecordRequestData | null; +}; + +export function RecordOverlay({ request }: Props) { + const { t } = useTranslation("extension"); + const [show, setShow] = useState(false); + + useEffect(() => { + if (request) { + const raf = requestAnimationFrame(() => setShow(true)); + return () => cancelAnimationFrame(raf); + } + setShow(false); + }, [request]); + + if (!request) return null; + + return ( + <> + + +
+ + + {t("recordOverlay.recording")} + + +
+ + ); +} diff --git a/apps/extension/src/content/__tests__/ControlOverlay.test.tsx b/apps/extension/src/content/__tests__/ControlOverlay.test.tsx index b7aae0c..d35c609 100644 --- a/apps/extension/src/content/__tests__/ControlOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/ControlOverlay.test.tsx @@ -7,7 +7,7 @@ describe("ControlOverlay", () => { cleanup(); }); - it("sets pointer-events none on blocker and pill when automationBypass is true", () => { + it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => { const { container } = render( { />, ); - const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling; + const blocker = container.querySelector("[data-slot='control-overlay-blocker']"); expect(blocker).toBeTruthy(); expect((blocker as HTMLElement).style.pointerEvents).toBe("none"); + const pill = container.querySelector("[data-slot='control-overlay-pill']"); + expect(pill).toBeTruthy(); + expect((pill as HTMLElement).style.pointerEvents).toBe("auto"); + const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']"); expect(stopBtn).toBeTruthy(); - expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none"); + expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto"); }); it("uses pointer-events auto on blocker when automationBypass is false", () => { @@ -36,7 +40,7 @@ describe("ControlOverlay", () => { />, ); - const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling; + const blocker = container.querySelector("[data-slot='control-overlay-blocker']"); expect(blocker).toBeTruthy(); expect((blocker as HTMLElement).style.pointerEvents).toBe("auto"); }); diff --git a/apps/extension/src/content/__tests__/RecordOverlay.test.tsx b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx new file mode 100644 index 0000000..9ed5e5a --- /dev/null +++ b/apps/extension/src/content/__tests__/RecordOverlay.test.tsx @@ -0,0 +1,37 @@ +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RecordOverlay } from "../RecordOverlay"; + +describe("RecordOverlay", () => { + afterEach(() => { + cleanup(); + }); + + it("renders the bottom pill with orange finish CTA and no logo", () => { + const onFinish = vi.fn(); + const { container } = render(); + + const pill = container.querySelector("[data-slot='record-overlay-pill']"); + expect(pill).toBeTruthy(); + expect(pill?.querySelector("img")).toBeNull(); + expect((pill as HTMLElement).style.borderRadius).toBe("9999px"); + expect((pill as HTMLElement).style.backgroundColor).toBe("#fff"); + + const finish = container.querySelector("[data-slot='record-overlay-finish']"); + expect(finish).toBeTruthy(); + expect((finish as HTMLElement).style.backgroundColor).toBe("#f97316"); + expect((finish as HTMLElement).style.borderRadius).toBe("9999px"); + + fireEvent.click(finish!); + expect(onFinish).toHaveBeenCalledTimes(1); + }); + + it("shows a pulsing recording indicator and no full-screen glow layer", () => { + const { container } = render(); + expect(container.querySelector("[data-slot='record-overlay']")).toBeNull(); + + const indicator = container.querySelector("[data-slot='record-overlay-indicator']"); + expect(indicator).toBeTruthy(); + expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse"); + }); +}); diff --git a/apps/extension/src/content/__tests__/overlay-controller.test.ts b/apps/extension/src/content/__tests__/overlay-controller.test.ts index c0b397a..7e8fd93 100644 --- a/apps/extension/src/content/__tests__/overlay-controller.test.ts +++ b/apps/extension/src/content/__tests__/overlay-controller.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { OverlayController } from "../overlay-controller"; +import { OverlayController, shouldShowAgentControlOverlay } from "../overlay-controller"; describe("OverlayController", () => { it("resets agent overlays without clearing user-tab borrow requests", () => { @@ -31,6 +31,7 @@ describe("OverlayController", () => { expect(state.controlVisible).toBe(false); expect(state.activeSessionId).toBeNull(); expect(state.activeHelp).toBeNull(); + expect(state.activeRecord).toBeNull(); expect(state.automationBypassCount).toBe(0); }); @@ -69,4 +70,50 @@ describe("OverlayController", () => { expect(previous?.id).toBe("help-1"); expect(controller.snapshot().activeHelp?.id).toBe("help-2"); }); + + it("tracks active record request state", () => { + const controller = new OverlayController(); + + controller.activateAgentSession("sess-1"); + controller.setAgentRecordRequest({ + id: "rec-1", + onFinish: vi.fn(), + }); + + expect(controller.snapshot().activeRecord?.id).toBe("rec-1"); + }); + + it("hides agent control overlay while recording is active", () => { + const controller = new OverlayController(); + controller.activateAgentSession("sess-1"); + expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true); + + controller.setAgentRecordRequest({ + id: "rec-1", + onFinish: vi.fn(), + }); + expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false); + expect(controller.snapshot().controlVisible).toBe(true); + }); + + it("keeps control hidden after record ends until session overlays reset", () => { + const controller = new OverlayController(); + controller.activateAgentSession("sess-1"); + controller.setAgentRecordRequest({ + id: "rec-1", + onFinish: vi.fn(), + }); + controller.setAutomationBypass(true); + controller.setAutomationBypass(true); + + controller.clearAgentRecordRequest("rec-1"); + expect(controller.snapshot().activeRecord).toBeNull(); + expect(controller.snapshot().suppressControlAfterRecord).toBe(true); + expect(controller.snapshot().automationBypassCount).toBe(0); + expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false); + + controller.resetAgentOverlays("sess-1"); + expect(controller.snapshot().suppressControlAfterRecord).toBe(false); + expect(controller.snapshot().controlVisible).toBe(false); + }); }); diff --git a/apps/extension/src/content/__tests__/record-capture.test.ts b/apps/extension/src/content/__tests__/record-capture.test.ts new file mode 100644 index 0000000..38ac5d7 --- /dev/null +++ b/apps/extension/src/content/__tests__/record-capture.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge"; +import { handleRecordContentMessage, startRecordCapture } from "../record-capture"; + +vi.stubGlobal("chrome", { + runtime: { + sendMessage: vi.fn(() => Promise.resolve()), + }, +}); + +describe("handleRecordContentMessage stop/cancel", () => { + it("ignores STOP when no recording is active", () => { + const dispose = vi.fn(); + const onStop = vi.fn(); + const setActiveRequestId = vi.fn(); + const setCapture = vi.fn(); + const sendResponse = vi.fn(); + + const needsAsync = handleRecordContentMessage( + { type: RECORD_STOP, requestId: "rec-stale" }, + { + activeRequestId: null, + capture: { dispose }, + setActiveRequestId, + setCapture, + onStart: vi.fn(), + onStop, + }, + sendResponse, + ); + + expect(needsAsync).toBe(false); + expect(dispose).not.toHaveBeenCalled(); + expect(onStop).not.toHaveBeenCalled(); + expect(setActiveRequestId).not.toHaveBeenCalled(); + expect(setCapture).not.toHaveBeenCalled(); + expect(sendResponse).not.toHaveBeenCalled(); + }); + + it("ignores STOP for a mismatched requestId", () => { + const dispose = vi.fn(); + const onStop = vi.fn(); + + const needsAsync = handleRecordContentMessage( + { type: RECORD_STOP, requestId: "rec-other" }, + { + activeRequestId: "rec-1", + capture: { dispose }, + setActiveRequestId: vi.fn(), + setCapture: vi.fn(), + onStart: vi.fn(), + onStop, + }, + vi.fn(), + ); + + expect(needsAsync).toBe(false); + expect(dispose).not.toHaveBeenCalled(); + expect(onStop).not.toHaveBeenCalled(); + }); +}); + +describe("record-capture semantic", () => { + let steps: RecordStepPayload[]; + + beforeEach(() => { + steps = []; + document.body.innerHTML = ` + + + +
+
建议项
+
+ `; + }); + + it("commits final fill value and records semantic click", () => { + const capture = startRecordCapture("rec-1", (step) => steps.push(step)); + const input = document.querySelector("input")!; + const button = document.querySelector("button")!; + + input.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + input.value = "hello"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + + button.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + + capture.dispose(); + + expect(steps).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + op: "fill", + value: "hello", + target: expect.objectContaining({ name: "查询", tag: "input" }), + }), + expect.objectContaining({ + op: "click", + target: expect.objectContaining({ name: "搜索", role: "button" }), + }), + ]), + ); + expect(JSON.stringify(steps)).not.toMatch(/@e\d+/); + expect(steps.some((s) => "selector" in s)).toBe(false); + }); + + it("ignores autocomplete suggestion clicks while a fill session is open", () => { + const capture = startRecordCapture("rec-2", (step) => steps.push(step)); + const input = document.querySelector("input")!; + const option = document.querySelector('[role="option"]')!; + + input.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + input.value = "hel"; + input.dispatchEvent(new Event("input", { bubbles: true })); + option.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + input.value = "hello"; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + capture.dispose(); + + expect(steps.filter((s) => s.op === "click")).toHaveLength(0); + expect(steps.some((s) => s.op === "fill" && s.value === "hello")).toBe(true); + }); + + it("records Enter press but not bare typing keys", () => { + 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 })); + capture.dispose(); + expect(steps.filter((s) => s.op === "press")).toEqual([ + expect.objectContaining({ op: "press", key: "Enter" }), + ]); + }); + + it("does not record clicks on anonymous layout divs", () => { + document.body.innerHTML = ` +
page chrome
+ + `; + const capture = startRecordCapture("rec-3", (step) => steps.push(step)); + document + .querySelector("#chrome")! + .dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + document + .querySelector("button")! + .dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 })); + capture.dispose(); + + expect(steps).toHaveLength(1); + expect(steps[0]).toMatchObject({ + op: "click", + target: { name: "下一步", role: "button" }, + }); + }); +}); diff --git a/apps/extension/src/content/overlay-controller.ts b/apps/extension/src/content/overlay-controller.ts index 15077a6..a0f6560 100644 --- a/apps/extension/src/content/overlay-controller.ts +++ b/apps/extension/src/content/overlay-controller.ts @@ -1,13 +1,21 @@ import type { BorrowRequestData } from "./BorrowConfirmationOverlay"; import type { HelpRequestData } from "./HelpRequestOverlay"; +import type { RecordRequestData } from "./RecordOverlay"; export interface OverlayState { borrowRequests: BorrowRequestData[]; activeHelp: HelpRequestData | null; + activeRecord: RecordRequestData | null; controlVisible: boolean; interrupting: boolean; activeSessionId: string | null; automationBypassCount: number; + /** + * After record Finish/Stop clears `activeRecord`, the session is still + * alive until CLI `session.stop`. Suppress the control mask for that gap + * so 「Agent 正在控制」does not flash between RecordOverlay and teardown. + */ + suppressControlAfterRecord: boolean; } type MutableOverlayState = Omit; @@ -20,9 +28,11 @@ export class OverlayController { private state: MutableOverlayState = { borrowRequests: [], activeHelp: null, + activeRecord: null, interrupting: false, activeSessionId: null, automationBypassCount: 0, + suppressControlAfterRecord: false, }; snapshot(): OverlayState { @@ -70,6 +80,25 @@ export class OverlayController { this.state.activeHelp = null; } + setAgentRecordRequest(request: RecordRequestData): RecordRequestData | null { + const previous = this.state.activeRecord; + this.state.activeRecord = request; + this.state.suppressControlAfterRecord = false; + return previous; + } + + clearAgentRecordRequest(requestId?: string): void { + if (!this.state.activeRecord) return; + if (requestId && this.state.activeRecord.id !== requestId) return; + this.state.activeRecord = null; + // Hide control until session teardown — see OverlayState.suppressControlAfterRecord. + this.state.suppressControlAfterRecord = true; + // Record start/rearm may have stacked automation-bypass refs; drop them so a + // stray ControlOverlay cannot sit with pointer-events:none (page usable, + // Interrupt unclickable). + this.state.automationBypassCount = 0; + } + setAutomationBypass(enabled: boolean): void { if (enabled) { this.state.automationBypassCount += 1; @@ -86,10 +115,22 @@ export class OverlayController { this.state = { ...this.state, activeHelp: null, + activeRecord: null, interrupting: false, activeSessionId: null, automationBypassCount: 0, + suppressControlAfterRecord: false, }; return previousHelp; } } + +/** Control mask ("Agent 正在控制") must hide while help/record overlays own the chrome. */ +export function shouldShowAgentControlOverlay(state: OverlayState): boolean { + return ( + state.controlVisible && + !state.suppressControlAfterRecord && + state.activeHelp === null && + state.activeRecord === null + ); +} diff --git a/apps/extension/src/content/record-capture.ts b/apps/extension/src/content/record-capture.ts new file mode 100644 index 0000000..12bf6af --- /dev/null +++ b/apps/extension/src/content/record-capture.ts @@ -0,0 +1,537 @@ +import { describeEventTarget, describeTarget, type TargetDescriptor } from "@/lib/describe-target"; +import { + isRecordCancelMessage, + isRecordStartMessage, + isRecordStopMessage, + RECORD_CANCEL, + RECORD_START, + RECORD_STEP, + RECORD_STOP, + type RecordCancelMessage, + type RecordStartAck, + type RecordStartMessage, + type RecordStepPayload, + type RecordStopAck, + type RecordStopMessage, +} from "@/lib/record-bridge"; +import { shouldRecordPress } from "@/lib/trace-reducer"; + +const pendingStepSends = new Map>>(); +const failedStepDeliveries = new Set(); +const knownRecordRequests = new Set(); + +function sendRecordStep(requestId: string, step: RecordStepPayload): void { + const payload = { type: RECORD_STEP, requestId, step }; + const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then( + () => true, + () => { + failedStepDeliveries.add(requestId); + return false; + }, + ); + const sends = pendingStepSends.get(requestId) ?? new Set>(); + sends.add(pending); + pendingStepSends.set(requestId, sends); + void pending.then(() => { + sends.delete(pending); + if (sends.size === 0) pendingStepSends.delete(requestId); + }); +} + +export interface RecordCaptureController { + dispose(): void; +} + +interface FillSession { + element: FillableElement; + target: TargetDescriptor; + baselineValue: string; + lastValue: string; +} + +type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement; + +function eventTarget(event: Event): EventTarget | null { + return event.composedPath()[0] ?? event.target; +} + +function isOverlayTarget(target: EventTarget | null): boolean { + if (!(target instanceof Node)) return false; + const root = document.documentElement; + let node: Node | null = target; + while (node && node !== root) { + if (node instanceof Element && node.hasAttribute("data-bsk-overlay")) { + return true; + } + const rootNode: Node | Document | ShadowRoot = node.getRootNode(); + if (rootNode instanceof ShadowRoot) { + const host: Element = rootNode.host; + if (host.hasAttribute("data-bsk-overlay")) { + return true; + } + node = host; + } else { + node = node.parentNode; + } + } + return false; +} + +function isTextFillable(el: Element): el is FillableElement { + if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) { + return true; + } + if (el instanceof HTMLTextAreaElement) return true; + if (!(el instanceof HTMLInputElement)) return false; + const type = el.type.toLowerCase(); + return type !== "checkbox" && type !== "radio" && type !== "file" && type !== "button"; +} + +function fillableFromTarget(target: EventTarget | null): FillableElement | null { + if (!(target instanceof Element)) return null; + if (isTextFillable(target)) return target; + const el = target.closest('input,textarea,[contenteditable]:not([contenteditable="false"])'); + if (el && isTextFillable(el)) return el; + return null; +} + +/** Clicks on search chrome that only focus the nearby input should not become steps. */ +function nearbyFillableFromSearchChrome(target: Element): FillableElement | null { + const container = target.closest( + '[id*="chat-input"], [id*="search"], [class*="search"], form, [role="search"]', + ); + if (!container) return null; + const fillable = container.querySelector( + 'textarea, input[type="search"], input[name="q"], #chat-textarea', + ); + if ( + fillable instanceof HTMLElement && + isTextFillable(fillable) && + fillable !== target && + !fillable.contains(target) + ) { + return fillable; + } + return null; +} + +function fillableValue(el: FillableElement): string { + if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + return el.value; + } + return el.textContent ?? ""; +} + +/** Clicks that only pick an autocomplete/suggestion value — not a semantic submit action. */ +function isInputCompletionClick(target: EventTarget | null, session: FillSession | null): boolean { + if (!session || !(target instanceof Element)) return false; + if (session.element.contains(target)) return false; + + const option = target.closest('[role="option"]'); + if (option?.closest('[role="listbox"]')) return true; + + const suggestionRoot = target.closest( + [ + '[id*="Sug"]', + '[id*="sug"]', + '[class*="suggest"]', + '[class*="autocomplete"]', + '[class*="typeahead"]', + "[data-autocomplete]", + ].join(", "), + ); + if (suggestionRoot && !suggestionRoot.contains(session.element)) return true; + + return false; +} + +function scheduleInputCompletionCommit( + sessionElement: FillableElement, + syncFillSessionValue: (el: FillableElement) => void, + commitFillSession: () => void, + hasSessionFor: (el: FillableElement) => boolean, +): void { + const syncAndCommit = () => { + if (!hasSessionFor(sessionElement)) return; + syncFillSessionValue(sessionElement); + commitFillSession(); + }; + syncFillSessionValue(sessionElement); + queueMicrotask(syncAndCommit); + setTimeout(syncAndCommit, 0); +} + +export function startRecordCapture( + _requestId: string, + sendStep: (step: RecordStepPayload) => void, +): RecordCaptureController { + const emitStep = (step: RecordStepPayload) => { + sendStep({ page_url: location.href, ...step }); + }; + let fillSession: FillSession | null = null; + let composing = false; + let lastUrl = location.href; + let keyboardActivation: { target: EventTarget | null; recordedAt: number } | undefined; + let generatedControlClick: Element | null = null; + let navigationActionPending = false; + let navigationActionVersion = 0; + const committedValues = new WeakMap(); + + const markNavigationAction = () => { + navigationActionPending = true; + const version = ++navigationActionVersion; + setTimeout(() => { + if (navigationActionVersion === version) { + navigationActionPending = false; + } + }, 0); + }; + + const emitFill = (session: FillSession) => { + if (session.lastValue === session.baselineValue) return; + const isPassword = + session.element instanceof HTMLInputElement && session.element.type === "password"; + const value = isPassword ? "***" : session.lastValue; + emitStep({ + op: "fill", + target: session.target, + value, + ...(isPassword ? { redacted: true } : {}), + }); + }; + + const commitFillSession = () => { + if (!fillSession || composing) return; + const session = fillSession; + fillSession = null; + emitFill(session); + committedValues.set(session.element, session.lastValue); + }; + + const syncFillSessionValue = (el: FillableElement) => { + if (!fillSession || fillSession.element !== el) return; + fillSession.lastValue = fillableValue(el); + }; + + const ensureFillSession = (el: FillableElement) => { + if (fillSession?.element === el) return; + if (fillSession) commitFillSession(); + const currentValue = fillableValue(el); + const baselineValue = committedValues.get(el) ?? currentValue; + fillSession = { + element: el, + target: describeTarget(el), + baselineValue, + lastValue: currentValue, + }; + }; + + const emitNavigateIfChanged = (causedByAction?: boolean) => { + if (location.href === lastUrl) return; + commitFillSession(); + lastUrl = location.href; + emitStep({ + op: "navigate", + url: location.href, + ...(causedByAction !== undefined ? { navigation_caused_by_action: causedByAction } : {}), + }); + }; + + const emitClick = (event: MouseEvent) => { + // Only record clicks an LLM can re-identify (named interactive controls). + const target = describeEventTarget(eventTarget(event)); + if (!target) return; + markNavigationAction(); + emitStep({ + op: "click", + target, + expects_navigation: true, + }); + }; + + const onClick = (event: MouseEvent) => { + if (event.button !== 0) return; + const target = eventTarget(event); + if (isOverlayTarget(target)) return; + if (event.detail === 0 && generatedControlClick !== null && target === generatedControlClick) { + generatedControlClick = null; + return; + } + if ( + event.detail === 0 && + keyboardActivation?.target === target && + Date.now() - keyboardActivation.recordedAt < 500 + ) { + keyboardActivation = undefined; + return; + } + + const label = target instanceof Element ? target.closest("label") : null; + const nestedInteractive = + target instanceof Element ? target.closest("a,button,input,select,textarea") : null; + if (label instanceof HTMLLabelElement && !nestedInteractive) { + commitFillSession(); + generatedControlClick = label.control; + emitClick(event); + return; + } + + const fillable = fillableFromTarget(target); + if (fillable) { + ensureFillSession(fillable); + return; + } + + if (target instanceof Element) { + const nearbyFillable = nearbyFillableFromSearchChrome(target); + if (nearbyFillable) { + ensureFillSession(nearbyFillable); + return; + } + } + + if (isInputCompletionClick(target, fillSession)) { + markNavigationAction(); + const sessionElement = fillSession!.element; + scheduleInputCompletionCommit( + sessionElement, + syncFillSessionValue, + commitFillSession, + (el) => fillSession?.element === el, + ); + return; + } + + commitFillSession(); + if (target instanceof Element && target.closest("select")) return; + emitClick(event); + }; + + const onFocusIn = (event: FocusEvent) => { + const target = fillableFromTarget(eventTarget(event)); + if (target) ensureFillSession(target); + }; + + const onFocusOut = (event: FocusEvent) => { + const target = fillableFromTarget(eventTarget(event)); + if (!target) return; + if (!fillSession || fillSession.element !== target) return; + syncFillSessionValue(target); + commitFillSession(); + }; + + const onInput = (event: Event) => { + const target = fillableFromTarget(eventTarget(event)); + if (!target) return; + if (composing) return; + ensureFillSession(target); + syncFillSessionValue(target); + }; + + const onCompositionStart = () => { + composing = true; + }; + + const onCompositionEnd = (event: CompositionEvent) => { + composing = false; + const target = fillableFromTarget(eventTarget(event)); + if (target) { + ensureFillSession(target); + syncFillSessionValue(target); + } + }; + + const onChange = (event: Event) => { + commitFillSession(); + const target = eventTarget(event); + if (target instanceof HTMLSelectElement) { + const values = Array.from(target.selectedOptions).map((opt) => opt.value); + const labels = Array.from(target.selectedOptions).map((opt) => + (opt.label || opt.textContent || opt.value).trim(), + ); + const desc = describeTarget(target); + markNavigationAction(); + emitStep({ + op: "select", + target: desc, + values, + labels, + expects_navigation: true, + }); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (isOverlayTarget(eventTarget(event))) return; + const target = eventTarget(event); + const fillable = fillableFromTarget(target); + if (fillable) { + ensureFillSession(fillable); + syncFillSessionValue(fillable); + } + const modifiers = [ + ...(event.altKey ? (["alt"] as const) : []), + ...(event.ctrlKey ? (["ctrl"] as const) : []), + ...(event.metaKey ? (["meta"] as const) : []), + ...(event.shiftKey ? (["shift"] as const) : []), + ]; + if (!shouldRecordPress(event.key, modifiers)) { + return; + } + markNavigationAction(); + if (event.key === "Enter" || event.key === " ") { + const submitTarget = + event.key === "Enter" && fillable + ? fillable + .closest("form") + ?.querySelector( + 'button:not([type]),button[type="submit"],input[type="submit"],input[type="image"]', + ) + : null; + keyboardActivation = { + target: submitTarget ?? target, + recordedAt: Date.now(), + }; + } + if (fillable) { + commitFillSession(); + } + const desc = describeEventTarget(target); + if (!desc && !event.key) return; + emitStep({ + op: "press", + key: event.key, + ...(desc ? { target: desc } : {}), + ...(modifiers.length ? { modifiers } : {}), + expects_navigation: event.key === "Enter", + }); + }; + + document.addEventListener("click", onClick, true); + document.addEventListener("focusin", onFocusIn, true); + document.addEventListener("focusout", onFocusOut, true); + document.addEventListener("input", onInput, true); + document.addEventListener("compositionstart", onCompositionStart, true); + document.addEventListener("compositionend", onCompositionEnd, true); + document.addEventListener("change", onChange, true); + document.addEventListener("keydown", onKeyDown, true); + + const urlObserver = new MutationObserver(() => emitNavigateIfChanged()); + urlObserver.observe(document, { subtree: true, childList: true }); + const onUrlEvent = () => emitNavigateIfChanged(); + window.addEventListener("hashchange", onUrlEvent); + window.addEventListener("popstate", onUrlEvent); + window.addEventListener("pagehide", commitFillSession); + + const originalPushState = history.pushState; + const originalReplaceState = history.replaceState; + history.pushState = function (...args: Parameters) { + originalPushState.apply(this, args); + emitNavigateIfChanged(navigationActionPending ? true : undefined); + }; + history.replaceState = function (...args: Parameters) { + originalReplaceState.apply(this, args); + emitNavigateIfChanged(navigationActionPending ? true : undefined); + }; + + return { + dispose() { + commitFillSession(); + document.removeEventListener("click", onClick, true); + document.removeEventListener("focusin", onFocusIn, true); + document.removeEventListener("focusout", onFocusOut, true); + document.removeEventListener("input", onInput, true); + document.removeEventListener("compositionstart", onCompositionStart, true); + document.removeEventListener("compositionend", onCompositionEnd, true); + document.removeEventListener("change", onChange, true); + document.removeEventListener("keydown", onKeyDown, true); + urlObserver.disconnect(); + window.removeEventListener("hashchange", onUrlEvent); + window.removeEventListener("popstate", onUrlEvent); + window.removeEventListener("pagehide", commitFillSession); + history.pushState = originalPushState; + history.replaceState = originalReplaceState; + }, + }; +} + +export type RecordContentMessage = RecordStartMessage | RecordStopMessage | RecordCancelMessage; + +export function isRecordContentMessage(msg: unknown): msg is RecordContentMessage { + return isRecordStartMessage(msg) || isRecordStopMessage(msg) || isRecordCancelMessage(msg); +} + +export function handleRecordContentMessage( + message: RecordContentMessage, + state: { + activeRequestId: string | null; + capture: RecordCaptureController | null; + setActiveRequestId(id: string | null): void; + setCapture(capture: RecordCaptureController | null): void; + onStart(requestId: string): void; + onStop(): void; + }, + sendResponse?: (response: RecordStartAck | RecordStopAck) => void, +): boolean { + if (isRecordStartMessage(message)) { + if (!knownRecordRequests.has(message.requestId)) { + knownRecordRequests.add(message.requestId); + failedStepDeliveries.delete(message.requestId); + } + state.capture?.dispose(); + state.setCapture( + startRecordCapture(message.requestId, (step) => { + sendRecordStep(message.requestId, step); + }), + ); + state.setActiveRequestId(message.requestId); + state.onStart(message.requestId); + sendResponse?.({ ok: true }); + return sendResponse !== undefined; + } + + if (isRecordStopMessage(message) || isRecordCancelMessage(message)) { + // Require an active recording that matches this requestId — otherwise a + // stray STOP/CANCEL (e.g. after teardown) would still run finishStop and + // clear overlay state even though nothing was capturing. + if (!state.activeRequestId || state.activeRequestId !== message.requestId) { + return false; + } + state.capture?.dispose(); + state.setCapture(null); + const finishStop = () => { + state.onStop(); + state.setActiveRequestId(null); + }; + if (isRecordStopMessage(message) && sendResponse) { + const pending = [...(pendingStepSends.get(message.requestId) ?? [])]; + void Promise.all(pending).then((delivered) => { + finishStop(); + const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId); + if (succeeded) { + failedStepDeliveries.delete(message.requestId); + knownRecordRequests.delete(message.requestId); + } + sendResponse( + succeeded + ? { ok: true } + : { + ok: false, + error: "failed to deliver one or more recorded steps", + }, + ); + }); + return true; + } + if (isRecordCancelMessage(message)) { + failedStepDeliveries.delete(message.requestId); + knownRecordRequests.delete(message.requestId); + } + finishStop(); + return false; + } + + return false; +} + +export { RECORD_CANCEL, RECORD_START, RECORD_STEP, RECORD_STOP }; diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index c1bafb5..26fd7b0 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -8,6 +8,7 @@ import { } from "@/lib/instance-id"; import { startKeepalive } from "@/lib/keepalive"; import { + OVERLAY_AUTOMATION_BYPASS, OVERLAY_MSG_INTERRUPT, OVERLAY_MSG_WHO_AM_I, type OverlayInterruptRequest, @@ -27,6 +28,11 @@ import { requestBorrowConfirmation, } from "@/tools/borrow-confirmation"; import { ToolDispatcher } from "@/tools/dispatcher"; +import { + attachRecordFinishListener, + attachRecordQueryListener, + attachRecordStepListener, +} from "@/tools/record"; import { detectBrowserMeta } from "@/transport/handshake"; import type { Transport } from "@/transport/transport"; import { WSTransport } from "@/transport/ws-transport"; @@ -67,6 +73,27 @@ export default defineBackground(() => { }), }); dispatcher.start(); + const recordDeps = { + tabsApi: chrome.tabs, + sendToTab: (tabId: number, msg: Parameters[1]) => + chrome.tabs.sendMessage(tabId, msg), + bypassOverlay: async (tabId: number, enabled: boolean) => { + try { + await chrome.tabs.sendMessage(tabId, { + type: OVERLAY_AUTOMATION_BYPASS, + enabled, + }); + } catch { + // Content script may be unavailable on restricted pages. + } + }, + }; + // Message listeners stay up (cheap; fire only for record message types). + // Tab / webNavigation observation attaches lazily while a recording is + // active — see ensureBrowserObservationListeners in tools/record.ts. + attachRecordStepListener(); + attachRecordFinishListener(recordDeps); + attachRecordQueryListener(recordDeps); if (typeof chrome.notifications?.onClicked?.addListener === "function") { attachBorrowNotificationClickHandler({ onClicked: chrome.notifications.onClicked, diff --git a/apps/extension/src/entrypoints/content.ts b/apps/extension/src/entrypoints/content.ts index b9e9dee..d721d70 100644 --- a/apps/extension/src/entrypoints/content.ts +++ b/apps/extension/src/entrypoints/content.ts @@ -6,7 +6,13 @@ import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay"; import { ControlOverlay } from "@/content/ControlOverlay"; import { HelpRequestOverlay } from "@/content/HelpRequestOverlay"; import overlayCss from "@/content/overlay.css?inline"; -import { OverlayController } from "@/content/overlay-controller"; +import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller"; +import { RecordOverlay } from "@/content/RecordOverlay"; +import { + handleRecordContentMessage, + isRecordContentMessage, + type RecordCaptureController, +} from "@/content/record-capture"; import { HELP_RESPONSE, type HelpCancelMessage, @@ -24,6 +30,12 @@ import { type OverlayWhoAmIResponse, } from "@/lib/overlay-bridge"; import { sendInterrupt } from "@/lib/overlay-interrupt-client"; +import { + RECORD_FINISH, + RECORD_QUERY, + type RecordStartAck, + type RecordStopAck, +} from "@/lib/record-bridge"; import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag"; import type { BorrowCancelMessage, @@ -45,6 +57,8 @@ export default defineContentScript({ const overlays = new OverlayController(); let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null = null; + let recordCapture: RecordCaptureController | null = null; + let activeRecordRequestId: string | null = null; let reactRoot: ReactDOM.Root | null = null; let overlayHost: HTMLElement | null = null; let hostLossReported = false; @@ -87,12 +101,13 @@ export default defineContentScript({ requests: overlayState.borrowRequests, }), React.createElement(ControlOverlay, { - visible: overlayState.controlVisible && overlayState.activeHelp === null, + visible: shouldShowAgentControlOverlay(overlayState), interrupting: overlayState.interrupting, automationBypass: overlayState.automationBypassCount > 0, onInterrupt: handleInterrupt, }), React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }), + React.createElement(RecordOverlay, { request: overlayState.activeRecord }), ), ), ); @@ -104,6 +119,9 @@ export default defineContentScript({ activeHelpRespond?.("cancelled"); activeHelpRespond = null; } + recordCapture?.dispose(); + recordCapture = null; + activeRecordRequestId = null; renderOverlay(); } @@ -140,6 +158,42 @@ export default defineContentScript({ _sender: chrome.runtime.MessageSender, sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void, ) => { + if (isRecordContentMessage(message)) { + const needsAsync = handleRecordContentMessage( + message, + { + activeRequestId: activeRecordRequestId, + capture: recordCapture, + setActiveRequestId: (id) => { + activeRecordRequestId = id; + }, + setCapture: (capture) => { + recordCapture = capture; + }, + onStart: (requestId) => { + overlays.setAgentRecordRequest({ + id: requestId, + onFinish: () => { + void chrome.runtime.sendMessage({ + type: RECORD_FINISH, + requestId, + }); + }, + }); + renderOverlay(); + }, + onStop: () => { + overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined); + renderOverlay(); + }, + }, + sendResponse as unknown as + | ((response: RecordStartAck | RecordStopAck) => void) + | undefined, + ); + return needsAsync; + } + if ( message && typeof message === "object" && @@ -228,23 +282,59 @@ export default defineContentScript({ return false; }; - async function mountOverlayIfAgent(): Promise { + async function syncAgentOverlay(): Promise { if (!(await anySessionLive())) return; try { const reply = (await chrome.runtime.sendMessage({ kind: OVERLAY_MSG_WHO_AM_I, })) as OverlayWhoAmIResponse | undefined; if (!reply?.sessionId) return; + + // Query / re-arm recording *before* activating the control overlay so + // record start (navigate → content load) does not flash + // 「Agent 正在控制」before RecordOverlay mounts. + let recordQuery: { active?: boolean; requestId?: string } | undefined; + try { + recordQuery = (await chrome.runtime.sendMessage({ + type: RECORD_QUERY, + })) as { active?: boolean; requestId?: string } | undefined; + } catch (err) { + console.debug("[bsk overlay] record query failed", err); + } + + if ( + recordQuery?.active && + typeof recordQuery.requestId === "string" && + overlays.snapshot().activeRecord === null + ) { + const requestId = recordQuery.requestId; + overlays.setAgentRecordRequest({ + id: requestId, + onFinish: () => { + void chrome.runtime.sendMessage({ + type: RECORD_FINISH, + requestId, + }); + }, + }); + } + overlays.activateAgentSession(reply.sessionId); renderOverlay(); } catch (err) { - console.debug("[bsk overlay] who_am_i failed", err); + console.debug("[bsk overlay] syncAgentOverlay failed", err); } } + const onPageShow = (event: PageTransitionEvent) => { + if (event.persisted) void syncAgentOverlay(); + }; + ui.mount(); chrome.runtime.onMessage.addListener(onMessage); - void mountOverlayIfAgent(); + void syncAgentOverlay(); + + window.addEventListener("pageshow", onPageShow); const hostObserver = new MutationObserver(() => { const connected = overlayHost?.isConnected ?? false; @@ -254,6 +344,7 @@ export default defineContentScript({ remountInProgress = true; try { ui.mount(); + void syncAgentOverlay(); } finally { remountInProgress = false; } @@ -268,6 +359,11 @@ export default defineContentScript({ ctx.onInvalidated(() => { hostObserver.disconnect(); chrome.runtime.onMessage.removeListener(onMessage); + window.removeEventListener("pageshow", onPageShow); + // Restore history hooks / remove capture listeners before the CS unloads. + recordCapture?.dispose(); + recordCapture = null; + activeRecordRequestId = null; }); }, }); diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index 0f59e46..155e7b6 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(); + + 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: /操作录制/ })); - 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.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,84 @@ 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/); + // Button label stays static; a transient toast confirms the copy. + expect(copyButton.textContent).toContain("复制录制指令"); + await waitFor(() => expect(screen.getByRole("status").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..6aee921 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,15 @@ 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(""); + // 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)"); @@ -44,17 +57,22 @@ export function App() { return () => query.removeEventListener("change", onChange); }, []); - useEffect(() => { - setLabelDraft(snapshot.label); - }, [snapshot.label]); - useEffect(() => { setCopiedInstanceId(false); + setCopiedTick(0); }, [snapshot.instanceId]); - const onLabelBlur = () => { - if (labelDraft !== snapshot.label) setLabel(labelDraft); - }; + // 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 daemonVersion = snapshot.handshake?.version ?? "—"; @@ -68,134 +86,302 @@ 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); + setCopiedTick((tick) => tick + 1); + }; + + 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 && ( -

setView(view === "record" ? "features" : "main")} + data-slot="popup-back" > - {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} - +

+ {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" + /> +
+