/** * Composable for managing Web Editor TX (Transaction) state in Sidepanel. * * Responsibilities: * - Listen to WEB_EDITOR_TX_CHANGED messages from background * - Persist and recover state from chrome.storage.session * - Manage excluded element keys for selective Apply * - Provide reactive state for AgentChat chips UI * * Architecture: * - The composable should be initialized ONCE at the AgentChat.vue level * - It is then provided via Vue's provide/inject to child components * - This prevents duplicate event listener registration */ import { computed, onMounted, onUnmounted, ref, type InjectionKey } from 'vue'; import { BACKGROUND_MESSAGE_TYPES } from '@/common/message-types'; import type { ElementChangeSummary, SelectedElementSummary, WebEditorElementKey, WebEditorSelectionChangedPayload, WebEditorTxChangedPayload, WebEditorTxChangeAction, } from '@/common/web-editor-types'; // ============================================================================= // Constants // ============================================================================= const WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX = 'web-editor-v2-tx-changed-'; const WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX = 'web-editor-v2-excluded-keys-'; const WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX = 'web-editor-v2-selection-'; const VALID_TX_ACTIONS = new Set([ 'push', 'merge', 'undo', 'redo', 'clear', 'rollback', ]); // ============================================================================= // Internal Helpers // ============================================================================= function isValidTabId(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value) && value > 0; } function buildTxSessionKey(tabId: number): string { return `${WEB_EDITOR_TX_CHANGED_SESSION_KEY_PREFIX}${tabId}`; } function buildExcludedKeysSessionKey(tabId: number): string { return `${WEB_EDITOR_EXCLUDED_KEYS_SESSION_KEY_PREFIX}${tabId}`; } function buildSelectionSessionKey(tabId: number): string { return `${WEB_EDITOR_SELECTION_SESSION_KEY_PREFIX}${tabId}`; } /** * Normalize and validate selection changed payload from storage or message. * Returns null if the payload is invalid. */ function normalizeSelectionPayload(raw: unknown): WebEditorSelectionChangedPayload | null { if (!raw || typeof raw !== 'object') return null; const obj = raw as Record; const tabId = Number(obj.tabId); if (!Number.isFinite(tabId) || tabId <= 0) return null; // Selected can be null (deselection) or an object const selectedRaw = obj.selected; let selected: SelectedElementSummary | null = null; if (selectedRaw && typeof selectedRaw === 'object') { const sel = selectedRaw as Record; const elementKey = typeof sel.elementKey === 'string' ? sel.elementKey.trim() : ''; if (!elementKey) return null; // Invalid selection selected = { elementKey, locator: sel.locator as SelectedElementSummary['locator'], label: typeof sel.label === 'string' ? sel.label : '', fullLabel: typeof sel.fullLabel === 'string' ? sel.fullLabel : '', tagName: typeof sel.tagName === 'string' ? sel.tagName : '', updatedAt: typeof sel.updatedAt === 'number' ? sel.updatedAt : Date.now(), }; } return { tabId, selected, pageUrl: typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined, }; } /** * Normalize and validate TX changed payload from storage or message. * Returns null if the payload is invalid. */ function normalizeTxChangedPayload(raw: unknown): WebEditorTxChangedPayload | null { if (!raw || typeof raw !== 'object') return null; const obj = raw as Record; const tabId = Number(obj.tabId); if (!Number.isFinite(tabId) || tabId <= 0) return null; const actionRaw = typeof obj.action === 'string' ? obj.action : ''; if (!VALID_TX_ACTIONS.has(actionRaw as WebEditorTxChangeAction)) return null; const action = actionRaw as WebEditorTxChangeAction; // Filter elements to ensure minimal validity (elementKey must be a non-empty string) const rawElements = Array.isArray(obj.elements) ? obj.elements : []; const elements = rawElements.filter( (e): e is ElementChangeSummary => e && typeof e === 'object' && typeof (e as any).elementKey === 'string' && (e as any).elementKey, ); const undoCountRaw = Number(obj.undoCount); const redoCountRaw = Number(obj.redoCount); const undoCount = Number.isFinite(undoCountRaw) && undoCountRaw >= 0 ? undoCountRaw : 0; const redoCount = Number.isFinite(redoCountRaw) && redoCountRaw >= 0 ? redoCountRaw : 0; const hasApplicableChanges = Boolean(obj.hasApplicableChanges); const pageUrl = typeof obj.pageUrl === 'string' ? obj.pageUrl : undefined; return { tabId, action, elements, undoCount, redoCount, hasApplicableChanges, pageUrl, }; } /** * Normalize and deduplicate excluded keys array from storage. * Filters out invalid entries and removes duplicates. */ function normalizeExcludedKeys(raw: unknown): WebEditorElementKey[] { if (!Array.isArray(raw)) return []; const result: WebEditorElementKey[] = []; const seen = new Set(); for (const item of raw) { const key = String(item ?? '').trim(); if (!key || seen.has(key)) continue; seen.add(key); result.push(key); } return result; } /** * Persist excluded keys to session storage (per-tab). * Best-effort: silently ignores failures. */ async function persistExcludedKeys( tabId: number, keys: readonly WebEditorElementKey[], ): Promise { if (!isValidTabId(tabId)) return; try { if (typeof chrome === 'undefined' || !chrome.storage?.session?.set) return; const storageKey = buildExcludedKeysSessionKey(tabId); await chrome.storage.session.set({ [storageKey]: [...keys] }); } catch (error) { console.error('[useWebEditorTxState] Failed to persist excluded keys:', error); } } /** * Default implementation for getting active tab ID. */ async function getActiveTabIdDefault(): Promise { try { if (typeof chrome === 'undefined' || !chrome.tabs?.query) return null; const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); const tabId = tabs?.[0]?.id; return typeof tabId === 'number' ? tabId : null; } catch { return null; } } /** * Get current window ID for filtering tab activation events. * This prevents processing tab switches from other Chrome windows. */ async function getCurrentWindowId(): Promise { try { if (typeof chrome === 'undefined' || !chrome.windows?.getCurrent) return null; const win = await chrome.windows.getCurrent(); return typeof win?.id === 'number' ? win.id : null; } catch { return null; } } // ============================================================================= // Public API // ============================================================================= export interface UseWebEditorTxStateOptions { /** * Optional override for resolving the "current tab" in sidepanel. * Defaults to chrome.tabs.query({ active: true, currentWindow: true }). */ getActiveTabId?: () => Promise; /** * If provided, skips querying the active tab on mount. */ initialTabId?: number | null; } export function useWebEditorTxState(options: UseWebEditorTxStateOptions = {}) { // ========================================================================== // State // ========================================================================== /** Current tab ID being tracked */ const tabId = ref( isValidTabId(options.initialTabId) ? options.initialTabId : null, ); /** Current TX state from web-editor */ const txState = ref(null); /** Currently selected element (for context, may not have edits) */ const selectedElement = ref(null); /** Page URL from selection (may differ from txState.pageUrl if selection is newer) */ const selectionPageUrl = ref(null); /** Excluded element keys (user-deselected elements) */ const excludedKeys = ref([]); // ========================================================================== // Computed // ========================================================================== /** All elements from TX state */ const allElements = computed(() => txState.value?.elements ?? []); /** Set of excluded keys for O(1) lookup */ const excludedKeySet = computed(() => new Set(excludedKeys.value)); /** Elements that will be applied (not excluded) */ const applicableElements = computed(() => { const set = excludedKeySet.value; return allElements.value.filter((e) => !set.has(e.elementKey)); }); /** Elements that are excluded by user */ const excludedElements = computed(() => { const set = excludedKeySet.value; return allElements.value.filter((e) => set.has(e.elementKey)); }); /** Whether there are applicable changes to send to Agent */ const hasChanges = computed(() => applicableElements.value.length > 0); /** Whether there is a selected element */ const hasSelection = computed(() => selectedElement.value !== null); /** * Whether the selected element is also in the edits list. * Used to decide if we need a separate "selection-only" chip. */ const isSelectionInEdits = computed(() => { const sel = selectedElement.value; if (!sel) return false; return allElements.value.some((e) => e.elementKey === sel.elementKey); }); /** Whether to show the web editor section (has edits OR has selection) */ const hasContent = computed( () => hasChanges.value || hasSelection.value || allElements.value.length > 0, ); // ========================================================================== // Actions // ========================================================================== /** * Toggle an element's excluded state. * Automatically persists to session storage. */ function toggleExclude(elementKey: WebEditorElementKey): void { const key = String(elementKey ?? '').trim(); if (!key) return; const current = excludedKeys.value; const idx = current.indexOf(key); if (idx >= 0) { // Remove from excluded list excludedKeys.value = [...current.slice(0, idx), ...current.slice(idx + 1)]; } else { // Add to excluded list excludedKeys.value = [...current, key]; } // Persist to session storage if (isValidTabId(tabId.value)) { void persistExcludedKeys(tabId.value, excludedKeys.value); } } /** * Clear all excluded elements. * Automatically persists to session storage. */ function clearExcluded(): void { excludedKeys.value = []; // Persist to session storage if (isValidTabId(tabId.value)) { void persistExcludedKeys(tabId.value, excludedKeys.value); } } /** * Remove excluded keys that no longer exist in the current TX state. * This prevents stale keys when elements are undone/cleared. */ function pruneStaleExcludedKeys(elements: readonly ElementChangeSummary[] | null): void { if (!elements || !isValidTabId(tabId.value)) return; const validKeys = new Set(elements.map((e) => e.elementKey)); const prunedKeys = excludedKeys.value.filter((k) => validKeys.has(k)); // Only update if there are stale keys to remove if (prunedKeys.length === excludedKeys.value.length) return; excludedKeys.value = prunedKeys; void persistExcludedKeys(tabId.value, prunedKeys); } /** Sequence counter to prevent stale async updates */ let refreshSeq = 0; /** * Refresh TX state from session storage for a specific tab. * Also restores excluded keys from storage. * On tab change, immediately clears state to prevent cross-tab pollution. */ async function refreshFromStorage(targetTabId: number): Promise { if (!isValidTabId(targetTabId)) { tabId.value = null; txState.value = null; excludedKeys.value = []; selectedElement.value = null; selectionPageUrl.value = null; return; } // On tab change, immediately clear state to prevent UI showing stale data const isTabChange = tabId.value !== targetTabId; if (isTabChange) { txState.value = null; excludedKeys.value = []; selectedElement.value = null; selectionPageUrl.value = null; } tabId.value = targetTabId; const seq = ++refreshSeq; const txKey = buildTxSessionKey(targetTabId); const excludedKey = buildExcludedKeysSessionKey(targetTabId); const selectionKey = buildSelectionSessionKey(targetTabId); try { if (typeof chrome === 'undefined' || !chrome.storage?.session?.get) { txState.value = null; excludedKeys.value = []; selectedElement.value = null; selectionPageUrl.value = null; return; } // Fetch TX state, excluded keys, and selection in one call const result = (await chrome.storage.session.get([ txKey, excludedKey, selectionKey, ])) as Record; // Check for stale async response if (seq !== refreshSeq) return; // Update TX state const nextTxState = normalizeTxChangedPayload(result?.[txKey]); txState.value = nextTxState; // Restore excluded keys from storage excludedKeys.value = normalizeExcludedKeys(result?.[excludedKey]); // Restore selection from storage const nextSelection = normalizeSelectionPayload(result?.[selectionKey]); selectedElement.value = nextSelection?.selected ?? null; selectionPageUrl.value = nextSelection?.pageUrl ?? null; // Prune stale excluded keys based on current elements pruneStaleExcludedKeys(nextTxState?.elements ?? null); } catch (error) { console.error('[useWebEditorTxState] Failed to refresh from session storage:', error); // On error, ensure clean state to prevent showing stale data txState.value = null; excludedKeys.value = []; selectedElement.value = null; selectionPageUrl.value = null; } } // ========================================================================== // Message Listeners // ========================================================================== /** * Handle runtime messages from background. */ const onRuntimeMessage = ( message: unknown, _sender: chrome.runtime.MessageSender, _sendResponse: (response?: unknown) => void, ): void => { const msg = message && typeof message === 'object' ? (message as Record) : null; if (!msg) return; // Handle TX changed messages if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_TX_CHANGED) { const next = normalizeTxChangedPayload(msg.payload); if (!next) return; // Only process messages for the current tab if (!isValidTabId(tabId.value)) return; if (next.tabId !== tabId.value) return; txState.value = next; // Prune excluded keys that no longer exist (e.g., after undo/clear) pruneStaleExcludedKeys(next.elements); return; } // Handle selection changed messages if (msg.type === BACKGROUND_MESSAGE_TYPES.WEB_EDITOR_SELECTION_CHANGED) { const next = normalizeSelectionPayload(msg.payload); if (!next) return; // Only process messages for the current tab if (!isValidTabId(tabId.value)) return; if (next.tabId !== tabId.value) return; selectedElement.value = next.selected; // Store pageUrl from selection for context building selectionPageUrl.value = next.pageUrl ?? null; return; } }; /** * Handle session storage changes (fallback for cold start). * Only handles TX state changes; excluded keys are managed explicitly. */ const onSessionChanged = (changes: { [key: string]: chrome.storage.StorageChange }): void => { if (!isValidTabId(tabId.value)) return; const txKey = buildTxSessionKey(tabId.value); const change = changes?.[txKey]; if (!change) return; if (change.newValue === undefined) { txState.value = null; // Clear excluded keys when TX state is cleared pruneStaleExcludedKeys([]); return; } const next = normalizeTxChangedPayload(change.newValue); txState.value = next; // Prune stale excluded keys pruneStaleExcludedKeys(next?.elements ?? []); }; /** Cleanup function for storage listener */ let removeStorageListener: (() => void) | null = null; /** Cleanup function for tab activated listener */ let removeTabActivatedListener: (() => void) | null = null; /** Cached window ID to filter tab activation events from other windows */ let currentWindowId: number | null = null; /** * Handle tab activation events. * Updates tabId and loads TX state when user switches to a different tab. * * Note: currentWindowId filtering is best-effort. If getCurrentWindowId() fails, * events from all windows will be processed (acceptable fallback behavior). */ const onTabActivated = (activeInfo: chrome.tabs.TabActiveInfo): void => { try { // Ignore events from other windows (best-effort filter) if (currentWindowId !== null && activeInfo.windowId !== currentWindowId) return; const nextTabId = activeInfo.tabId; if (!isValidTabId(nextTabId)) return; // Skip if already tracking this tab if (nextTabId === tabId.value) return; // Load TX state for the newly activated tab void refreshFromStorage(nextTabId); } catch (error) { console.error('[useWebEditorTxState] Failed to handle tab activation:', error); } }; // ========================================================================== // Lifecycle // ========================================================================== onMounted(async () => { // Register runtime message listener try { if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.addListener) { chrome.runtime.onMessage.addListener(onRuntimeMessage); } } catch (error) { console.error('Failed to register WebEditor TX runtime listener:', error); } // Register session storage listener try { if (typeof chrome !== 'undefined' && chrome.storage?.session?.onChanged?.addListener) { // Prefer session-specific listener if available chrome.storage.session.onChanged.addListener(onSessionChanged); removeStorageListener = () => { try { chrome.storage.session.onChanged.removeListener(onSessionChanged); } catch {} }; } else if (typeof chrome !== 'undefined' && chrome.storage?.onChanged?.addListener) { // Fallback to generic storage listener with area filter const onChanged = ( changes: { [key: string]: chrome.storage.StorageChange }, areaName: chrome.storage.AreaName, ) => { if (areaName !== 'session') return; onSessionChanged(changes); }; chrome.storage.onChanged.addListener(onChanged); removeStorageListener = () => { try { chrome.storage.onChanged.removeListener(onChanged); } catch {} }; } } catch (error) { console.error('Failed to register WebEditor TX storage listener:', error); } // Cache current window ID for filtering tab activation events currentWindowId = await getCurrentWindowId(); // Register tab activation listener to track tab switches try { if (typeof chrome !== 'undefined' && chrome.tabs?.onActivated?.addListener) { chrome.tabs.onActivated.addListener(onTabActivated); removeTabActivatedListener = () => { try { chrome.tabs.onActivated.removeListener(onTabActivated); } catch {} }; } } catch (error) { console.error('[useWebEditorTxState] Failed to register tab activation listener:', error); } // Initialize tab ID if not provided const getActiveTabId = options.getActiveTabId ?? getActiveTabIdDefault; if (!isValidTabId(tabId.value)) { const active = await getActiveTabId().catch(() => null); if (isValidTabId(active)) { tabId.value = active; } } // Load initial state from storage if (isValidTabId(tabId.value)) { await refreshFromStorage(tabId.value); } }); onUnmounted(() => { // Clean up runtime message listener try { if (typeof chrome !== 'undefined' && chrome.runtime?.onMessage?.removeListener) { chrome.runtime.onMessage.removeListener(onRuntimeMessage); } } catch {} // Clean up storage listener removeStorageListener?.(); removeStorageListener = null; // Clean up tab activation listener removeTabActivatedListener?.(); removeTabActivatedListener = null; }); // ========================================================================== // Return // ========================================================================== return { // State tabId, txState, excludedKeys, selectedElement, selectionPageUrl, // UI State (computed) allElements, hasChanges, hasSelection, isSelectionInEdits, hasContent, applicableElements, excludedElements, // Actions toggleExclude, clearExcluded, refreshFromStorage, }; } // ============================================================================= // Type Exports & Injection Key // ============================================================================= /** * Return type of useWebEditorTxState composable. * Used for type-safe provide/inject. */ export type WebEditorTxStateReturn = ReturnType; /** * Injection key for providing WebEditorTxState to child components. * Use this with Vue's provide/inject pattern to avoid duplicate listener registration. * * @example * // In AgentChat.vue (parent) * const webEditorTx = useWebEditorTxState(); * provide(WEB_EDITOR_TX_STATE_INJECTION_KEY, webEditorTx); * * // In WebEditorChanges.vue (child) * const tx = inject(WEB_EDITOR_TX_STATE_INJECTION_KEY); */ export const WEB_EDITOR_TX_STATE_INJECTION_KEY: InjectionKey = Symbol('web-editor-tx-state');