/** * Element Picker Content Script * * Renders the Element Picker Panel UI (Quick Panel style) and forwards UI events * to background while a chrome_request_element_selection session is active. * * This script only runs in the top frame and handles: * - Displaying the element picker panel UI * - Forwarding user actions (cancel, confirm, etc.) to background * - Receiving state updates from background */ import { createElementPickerController, type ElementPickerController, type ElementPickerUiState, } from '@/shared/element-picker'; import { BACKGROUND_MESSAGE_TYPES, TOOL_MESSAGE_TYPES } from '@/common/message-types'; import type { PickedElement } from 'chrome-mcp-shared'; // ============================================================ // Message Types // ============================================================ interface UiShowMessage { action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW; sessionId: string; requests: Array<{ id: string; name: string; description?: string }>; activeRequestId: string | null; deadlineTs: number; } interface UiUpdateMessage { action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE; sessionId: string; activeRequestId: string | null; selections: Record; deadlineTs: number; errorMessage: string | null; } interface UiHideMessage { action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE; sessionId: string; } interface UiPingMessage { action: typeof TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING; } type PickerMessage = UiPingMessage | UiShowMessage | UiUpdateMessage | UiHideMessage; // ============================================================ // Content Script Definition // ============================================================ export default defineContentScript({ matches: [''], runAt: 'document_idle', main() { // Only mount UI in the top frame if (window.top !== window) return; let controller: ElementPickerController | null = null; let currentSessionId: string | null = null; /** * Ensure the controller is created and configured. */ function ensureController(): ElementPickerController { if (controller) return controller; controller = createElementPickerController({ onCancel: () => { if (!currentSessionId) return; void chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, sessionId: currentSessionId, event: 'cancel', }); }, onConfirm: () => { if (!currentSessionId) return; void chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, sessionId: currentSessionId, event: 'confirm', }); }, onSetActiveRequest: (requestId: string) => { if (!currentSessionId) return; void chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, sessionId: currentSessionId, event: 'set_active_request', requestId, }); }, onClearSelection: (requestId: string) => { if (!currentSessionId) return; void chrome.runtime.sendMessage({ type: BACKGROUND_MESSAGE_TYPES.ELEMENT_PICKER_UI_EVENT, sessionId: currentSessionId, event: 'clear_selection', requestId, }); }, }); return controller; } /** * Handle incoming messages from background. */ function handleMessage( message: unknown, _sender: chrome.runtime.MessageSender, sendResponse: (response?: unknown) => void, ): boolean | void { const msg = message as PickerMessage | undefined; if (!msg?.action) return false; // Respond to ping (used by background to check if UI script is ready) if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_PING) { sendResponse({ success: true }); return true; } // Show the picker panel if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_SHOW) { const showMsg = msg as UiShowMessage; currentSessionId = typeof showMsg.sessionId === 'string' ? showMsg.sessionId : null; if (!currentSessionId) { sendResponse({ success: false, error: 'Missing sessionId' }); return true; } const ctrl = ensureController(); const initialState: ElementPickerUiState = { sessionId: currentSessionId, requests: Array.isArray(showMsg.requests) ? showMsg.requests : [], activeRequestId: showMsg.activeRequestId ?? null, selections: {}, deadlineTs: typeof showMsg.deadlineTs === 'number' ? showMsg.deadlineTs : Date.now(), errorMessage: null, }; ctrl.show(initialState); sendResponse({ success: true }); return true; } // Update the picker panel state if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_UPDATE) { const updateMsg = msg as UiUpdateMessage; if (!currentSessionId || updateMsg.sessionId !== currentSessionId) { sendResponse({ success: false, error: 'Session mismatch' }); return true; } controller?.update({ sessionId: currentSessionId, activeRequestId: updateMsg.activeRequestId ?? null, selections: updateMsg.selections || {}, deadlineTs: updateMsg.deadlineTs, errorMessage: updateMsg.errorMessage ?? null, }); sendResponse({ success: true }); return true; } // Hide the picker panel if (msg.action === TOOL_MESSAGE_TYPES.ELEMENT_PICKER_UI_HIDE) { const hideMsg = msg as UiHideMessage; // Best-effort hide even if session mismatches if (currentSessionId && hideMsg.sessionId !== currentSessionId) { // Log but don't fail console.warn('[ElementPicker] Session mismatch on hide, hiding anyway'); } controller?.hide(); currentSessionId = null; sendResponse({ success: true }); return true; } return false; } // Register message listener chrome.runtime.onMessage.addListener(handleMessage); // Cleanup on page unload window.addEventListener('unload', () => { chrome.runtime.onMessage.removeListener(handleMessage); controller?.dispose(); controller = null; currentSessionId = null; }); }, });