From 121fbf40920a7cb65f5ef42e6d445725ba0facbc Mon Sep 17 00:00:00 2001 From: Douglas Date: Thu, 2 Apr 2026 16:45:32 +0100 Subject: [PATCH] refector ui ux --- extensions/chrome_extension/background.js | 235 ++++++++++++-- extensions/chrome_extension/content.js | 295 ++++++++++++++++++ extensions/chrome_extension/manifest.json | 22 ++ extensions/chrome_extension/overlay/aurora.js | 144 +++++++++ extensions/chrome_extension/overlay/bridge.js | 35 +++ extensions/chrome_extension/overlay/cursor.js | 157 ++++++++++ extensions/chrome_extension/overlay/events.js | 213 +++++++++++++ .../chrome_extension/overlay/highlight.js | 264 ++++++++++++++++ extensions/chrome_extension/overlay/motion.js | 147 +++++++++ extensions/chrome_extension/overlay/store.js | 70 +++++ .../chrome_extension/overlay/summary.js | 174 +++++++++++ 11 files changed, 1730 insertions(+), 26 deletions(-) create mode 100644 extensions/chrome_extension/content.js create mode 100644 extensions/chrome_extension/overlay/aurora.js create mode 100644 extensions/chrome_extension/overlay/bridge.js create mode 100644 extensions/chrome_extension/overlay/cursor.js create mode 100644 extensions/chrome_extension/overlay/events.js create mode 100644 extensions/chrome_extension/overlay/highlight.js create mode 100644 extensions/chrome_extension/overlay/motion.js create mode 100644 extensions/chrome_extension/overlay/store.js create mode 100644 extensions/chrome_extension/overlay/summary.js diff --git a/extensions/chrome_extension/background.js b/extensions/chrome_extension/background.js index 7829dee8..fb8ede4b 100644 --- a/extensions/chrome_extension/background.js +++ b/extensions/chrome_extension/background.js @@ -237,13 +237,37 @@ async function handleServerMessage(message) { }); break; - case 'ACTION': + case 'ACTION': { broadcastToPopup({ type: 'ACTION', action: message.action, detail: message.detail, }); + const actionTabId = message.tabId || getDefaultTabId(); + const actionDetail = message.detail || message.action || ''; + // Forward summary text to overlay + sendOverlayEvent(actionTabId, { + type: 'OVERLAY_SUMMARY', + text: + actionDetail.length > 60 + ? message.action || actionDetail.slice(0, 60) + : actionDetail, + }); + // Extract element ref from detail (e.g. "ref=e46, text=...") and resolve via CDP + const refMatch = actionDetail.match(/ref=(e\d+)/); + if (refMatch && actionTabId) { + resolveElementRect(refMatch[1], actionTabId).then((rect) => { + if (rect) { + sendOverlayEvent(actionTabId, { + type: 'OVERLAY_CURSOR_MOVE', + x: rect.cx, + y: rect.cy, + }); + } + }); + } break; + } case 'ACTION_COMPLETE': broadcastToPopup({ @@ -251,28 +275,63 @@ async function handleServerMessage(message) { success: message.success, result: message.result, }); + // Notify overlay that action is done + sendOverlayEvent(message.tabId || getDefaultTabId(), { + type: 'OVERLAY_AGENT_STEP', + stepId: message.id || '', + state: message.success ? 'done' : 'error', + summary: message.success ? 'Done' : 'Action failed', + }); break; case 'CDP_COMMAND': { // Execute CDP command via chrome.debugger, routed by tabId const targetTabId = message.tabId || getDefaultTabId(); try { - // Check if we should highlight before this action + // Highlight before this action — prefer overlay content script, fallback to CDP if (message.highlight && message.highlight.selector) { - await highlightElement( - message.highlight.selector, - message.highlight.duration || 1500, - targetTabId - ); + const overlayHighlighted = await sendOverlayEvent(targetTabId, { + type: 'OVERLAY_HIGHLIGHT', + selector: message.highlight.selector, + duration: message.highlight.duration || 1500, + }); + if (!overlayHighlighted) { + // Fallback to CDP-injected highlight + await highlightElement( + message.highlight.selector, + message.highlight.duration || 1500, + targetTabId + ); + } + if (message.highlight.summary) { + sendOverlayEvent(targetTabId, { + type: 'OVERLAY_SUMMARY', + text: message.highlight.summary, + }); + } // Small delay to let user see the highlight - await new Promise((resolve) => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 100)); } - const result = await executeCdpCommand( - message.method, - message.params || {}, - targetTabId - ); + // Extract cursor position from CDP commands that have coordinates + const params = message.params || {}; + const method = message.method || ''; + if ( + method === 'Input.dispatchMouseEvent' && + params.x != null && + params.y != null + ) { + if (params.type === 'mousePressed') { + sendOverlayEvent(targetTabId, { + type: 'OVERLAY_CURSOR_MOVE', + x: params.x, + y: params.y, + }); + await new Promise((resolve) => setTimeout(resolve, 80)); + } + } + + const result = await executeCdpCommand(method, params, targetTabId); // Send result back to server with tabId sendToServer({ @@ -357,20 +416,38 @@ async function handleServerMessage(message) { break; } - case 'TASK_COMPLETE': + case 'TASK_COMPLETE': { broadcastToPopup({ type: 'TASK_COMPLETE', result: message.result, }); + // Hide all overlay — agent session ended + const completeTabId = message.tabId || getDefaultTabId(); + sendOverlayEvent(completeTabId, { + type: 'OVERLAY_STATE', + auroraVisible: false, + cursorVisible: false, + summaryText: '', + }); // Don't detach all tabs on task complete - let server manage tab lifecycle break; + } - case 'TASK_ERROR': + case 'TASK_ERROR': { broadcastToPopup({ type: 'TASK_ERROR', error: message.error, }); + // Hide all overlay — agent session ended with error + const errorTabId = message.tabId || getDefaultTabId(); + sendOverlayEvent(errorTabId, { + type: 'OVERLAY_STATE', + auroraVisible: false, + cursorVisible: false, + summaryText: '', + }); break; + } case 'STREAM_TEXT': { @@ -422,21 +499,35 @@ async function handleServerMessage(message) { break; case 'HIGHLIGHT': { - // Highlight an element on the page - console.log('Received HIGHLIGHT message:', message); + // Resolve element position via CDP, move cursor there, then highlight const hlTabId = message.tabId || getDefaultTabId(); try { - const highlightResult = await highlightElement( + // Resolve element rect in page main world + const rect = await resolveElementRect(message.selector, hlTabId); + if (rect) { + // Move overlay cursor to element center + sendOverlayEvent(hlTabId, { + type: 'OVERLAY_CURSOR_MOVE', + x: rect.cx, + y: rect.cy, + }); + // Show highlight rect via overlay + sendOverlayEvent(hlTabId, { + type: 'OVERLAY_HIGHLIGHT_RECT', + rect: rect, + duration: message.duration || 2000, + }); + } + // Also run the existing CDP highlight (red ring) + await highlightElement( message.selector, message.duration || 2000, hlTabId ); - console.log('Highlight completed:', highlightResult); sendToServer({ type: 'HIGHLIGHT_RESULT', id: message.id, success: true, - result: highlightResult, tabId: hlTabId, }); } catch (error) { @@ -476,6 +567,78 @@ async function enableCdpDomains(tabId) { } } +// Resolve element ref to bounding rect via CDP (runs in page main world) +// Returns { x, y, width, height, cx, cy } or null +async function resolveElementRect(selector, tabId) { + const targetTabId = tabId || getDefaultTabId(); + if (!targetTabId) return null; + + const script = ` + (function() { + const sel = ${JSON.stringify(selector)}; + let element = null; + + // Method 1: ariaSnapshot + if (typeof __ariaSnapshot !== 'undefined' && __ariaSnapshot.getElementByRef) { + try { element = __ariaSnapshot.getElementByRef(sel, document.body); } catch(e) {} + } + + // Method 2: _ariaRef DOM walk + if (!element) { + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false); + let node; + while (node = walker.nextNode()) { + if (node._ariaRef && node._ariaRef.ref === sel) { element = node; break; } + } + } + + // Method 3: data attributes + if (!element) { + const refNum = sel.replace(/^e/, ''); + const selectors = [ + '[data-ref="' + sel + '"]', '[data-ref="' + refNum + '"]', + '[ref="' + sel + '"]', '[aria-ref="' + sel + '"]', + '[data-camel-ref="' + sel + '"]', '[data-camel-ref="' + refNum + '"]' + ]; + for (const s of selectors) { + try { element = document.querySelector(s); if (element) break; } catch(e) {} + } + } + + // Method 4: CSS selector + if (!element && (sel.includes('[') || sel.includes('.') || sel.includes('#'))) { + try { element = document.querySelector(sel); } catch(e) {} + } + + if (!element) return null; + + const rect = element.getBoundingClientRect(); + return { + x: rect.left, y: rect.top, width: rect.width, height: rect.height, + cx: rect.left + rect.width / 2, cy: rect.top + rect.height / 2 + }; + })(); + `; + + try { + const result = await executeCdpCommand( + 'Runtime.evaluate', + { + expression: script, + returnByValue: true, + }, + targetTabId + ); + + if (result && result.result && result.result.value) { + return result.result.value; + } + } catch (e) { + console.log('resolveElementRect failed:', e.message); + } + return null; +} + // Highlight element on page with animation async function highlightElement(selector, duration = 600, tabId = null) { const targetTabId = tabId || getDefaultTabId(); @@ -573,9 +736,9 @@ async function highlightElement(selector, duration = 600, tabId = null) { style.id = '__agent_highlight_styles__'; style.textContent = \` @keyframes __agent_pulse__ { - 0% { box-shadow: 0 0 0 4px rgba(255, 68, 68, 1), 0 0 15px rgba(255, 68, 68, 0.7); } - 50% { box-shadow: 0 0 0 6px rgba(255, 68, 68, 0.7), 0 0 25px rgba(255, 68, 68, 0.5); } - 100% { box-shadow: 0 0 0 4px rgba(255, 68, 68, 1), 0 0 15px rgba(255, 68, 68, 0.7); } + 0% { box-shadow: 0 0 0 4px rgba(21, 93, 252, 1), 0 0 15px rgba(21, 93, 252, 0.7); } + 50% { box-shadow: 0 0 0 6px rgba(21, 93, 252, 0.7), 0 0 25px rgba(21, 93, 252, 0.5); } + 100% { box-shadow: 0 0 0 4px rgba(21, 93, 252, 1), 0 0 15px rgba(21, 93, 252, 0.7); } } @keyframes __agent_ripple__ { 0% { transform: scale(0.8); opacity: 1; } @@ -601,9 +764,9 @@ async function highlightElement(selector, duration = 600, tabId = null) { left: \${rect.left - 8}px; width: \${rect.width + 16}px; height: \${rect.height + 16}px; - border: 4px solid #ff4444; + border: 4px solid #155DFC; border-radius: 8px; - background: rgba(255, 68, 68, 0.15); + background: rgba(21, 93, 252, 0.15); pointer-events: none; z-index: 2147483647; animation: __agent_pulse__ 0.2s ease-in-out infinite; @@ -620,7 +783,7 @@ async function highlightElement(selector, duration = 600, tabId = null) { left: \${rect.left + rect.width/2 - 25}px; width: 50px; height: 50px; - border: 3px solid #ff4444; + border: 3px solid #155DFC; border-radius: 50%; pointer-events: none; z-index: 2147483646; @@ -841,6 +1004,19 @@ function broadcastToPopup(message) { }); } +// Send overlay event to content script on a specific tab +// Returns true if message was delivered, false if no content script listening +async function sendOverlayEvent(tabId, event) { + if (!tabId) return false; + try { + await chrome.tabs.sendMessage(tabId, event); + return true; + } catch (e) { + // Content script not injected on this tab (restricted page, etc.) + return false; + } +} + // Listen for debugger events - forward from ALL attached tabs chrome.debugger.onEvent.addListener((source, method, params) => { if (attachedTabs.has(source.tabId)) { @@ -1044,6 +1220,13 @@ async function executeTask(task, tabId, url) { message: 'Sending task to AI...', }); + // Show aurora overlay — agent session started + sendOverlayEvent(tabId, { + type: 'OVERLAY_STATE', + enabled: true, + auroraVisible: true, + }); + sendToServer({ type: 'START_TASK', task: task, diff --git a/extensions/chrome_extension/content.js b/extensions/chrome_extension/content.js new file mode 100644 index 00000000..cb9718f4 --- /dev/null +++ b/extensions/chrome_extension/content.js @@ -0,0 +1,295 @@ +// Content script — Shadow DOM overlay mount + orchestrator +// Runs after all overlay/*.js modules are loaded by manifest + +// Prevent double injection +if (document.getElementById('eigent-agent-overlay')) { + // already injected +} else if ( + window.location.href.startsWith('chrome://') || + window.location.href.startsWith('chrome-extension://') || + window.location.href.startsWith('edge://') || + window.location.href.startsWith('about:') +) { + // skip restricted pages +} else { + console.log('[Eigent Cursor] Initializing content script...'); + + // Verify all modules loaded + const modules = { + OverlayStore, + OverlayEvents, + OverlayMotion, + OverlayAurora, + OverlayCursor, + OverlaySummary, + OverlayHighlight, + }; + for (const [name, mod] of Object.entries(modules)) { + if (!mod) { + console.error(`[Eigent Cursor] Module ${name} not loaded!`); + } else { + console.log(`[Eigent Cursor] Module ${name} OK`); + } + } + + // Create overlay host — full viewport, no interaction blocking + const host = document.createElement('div'); + host.id = 'eigent-agent-overlay'; + host.style.cssText = + 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 2147483646; pointer-events: none; overflow: visible;'; + document.documentElement.appendChild(host); + + // Attach Shadow DOM for style isolation + const shadow = host.attachShadow({ mode: 'open' }); + + // Inject all overlay styles into shadow root + const styleEl = document.createElement('style'); + styleEl.textContent = [ + OverlayAurora.getStyles(), + OverlayCursor.getStyles(), + OverlaySummary.getStyles(), + OverlayHighlight.getStyles(), + ].join('\n'); + shadow.appendChild(styleEl); + + console.log( + '[Eigent Cursor] Styles injected, length:', + styleEl.textContent.length + ); + + // Detect reduced motion preference + const reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + OverlayStore.update({ reducedMotion }); + window + .matchMedia('(prefers-reduced-motion: reduce)') + .addEventListener('change', (e) => { + OverlayStore.update({ reducedMotion: e.matches }); + }); + + // Initialize all overlay layers (order matters for z-stacking) + OverlayAurora.init(shadow); + OverlayHighlight.init(shadow); + OverlayCursor.init(shadow); + OverlaySummary.init(shadow); + + console.log('[Eigent Cursor] All layers initialized'); + console.log( + '[Eigent Cursor] Shadow root children:', + shadow.childNodes.length + ); + console.log( + '[Eigent Cursor] Host element in DOM:', + !!document.getElementById('eigent-agent-overlay') + ); + + // Start listening for messages from background + OverlayEvents.listen(); + OverlayEvents.notifyReady(); + + // --- DOM Target Resolution (Milestone 2) --- + const OverlayDomResolver = { + resolve(target) { + const element = OverlayHighlight.resolveElement(target); + if (!element) { + return { found: false, target }; + } + + const rect = element.getBoundingClientRect(); + + // Scroll into view if off-screen + if ( + rect.top < 0 || + rect.bottom > window.innerHeight || + rect.left < 0 || + rect.right > window.innerWidth + ) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center', + }); + // Re-measure after scroll + return new Promise((resolve) => { + setTimeout(() => { + const newRect = element.getBoundingClientRect(); + resolve({ + found: true, + target, + rect: { + x: newRect.left, + y: newRect.top, + width: newRect.width, + height: newRect.height, + }, + center: { + x: newRect.left + newRect.width / 2, + y: newRect.top + newRect.height / 2, + }, + }); + }, 400); + }); + } + + return { + found: true, + target, + rect: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + center: { + x: rect.left + rect.width / 2, + y: rect.top + rect.height / 2, + }, + }; + }, + }; + + // Expose resolver globally for events.js + window.__eigentDomResolver = OverlayDomResolver; + + // --- Scripted Demo Mode --- + window.__eigentOverlayDemo = async function () { + const store = OverlayStore; + const motion = OverlayMotion; + const cursor = OverlayCursor; + const highlight = OverlayHighlight; + + console.log('[Eigent Demo] Starting overlay demo...'); + + // Step 1: Show aurora (simulates agent session start) + store.update({ aurora: { visible: true }, enabled: true }); + await sleep(800); + + // Step 2: Show cursor at top-left area + store.update({ cursor: { x: 100, y: 100, visible: true, state: 'idle' } }); + motion.setPosition(100, 100); + await sleep(600); + + // Step 3: Look for a search input or first input on page + const searchInput = + document.querySelector('input[type="search"]') || + document.querySelector('input[type="text"]') || + document.querySelector('input:not([type="hidden"])') || + document.querySelector('textarea'); + + if (searchInput) { + const rect = searchInput.getBoundingClientRect(); + const targetX = rect.left + rect.width / 2; + const targetY = rect.top + rect.height / 2; + + // Move cursor to search input + store.update({ + summary: { text: 'Looking for search field\u2026', visible: true }, + }); + await motion.moveCursor(targetX, targetY); + await motion.settle(180); + + // Highlight target + highlight.highlightSelector( + 'input[type="search"], input[type="text"], input:not([type="hidden"]), textarea', + 2500 + ); + store.update({ cursor: { state: 'hovering' } }); + await sleep(800); + + // Click + store.update({ + summary: { text: 'Typing search query\u2026', visible: true }, + }); + cursor.animateClick(); + await sleep(1200); + } else { + // Fallback: move to center of page + store.update({ summary: { text: 'Scanning page\u2026', visible: true } }); + await motion.moveCursor(window.innerWidth / 2, window.innerHeight / 3); + await sleep(1000); + } + + // Step 4: Find a button or link + const button = + document.querySelector('button:not([disabled])') || + document.querySelector('a[href]') || + document.querySelector('[role="button"]'); + + if (button) { + const rect = button.getBoundingClientRect(); + const targetX = rect.left + rect.width / 2; + const targetY = rect.top + rect.height / 2; + + store.update({ + summary: { + text: + 'Clicking ' + (button.textContent || 'button').trim().slice(0, 30), + visible: true, + }, + }); + await motion.moveCursor(targetX, targetY); + await motion.settle(150); + + highlight.highlightSelector(button.tagName.toLowerCase(), 2000); + cursor.animateClick(); + await sleep(1500); + } + + // Step 5: Done + store.update({ + summary: { text: 'Done', visible: true }, + cursor: { state: 'complete' }, + }); + await sleep(2000); + + // Fade out everything (simulates agent session end) + store.update({ + summary: { visible: false }, + cursor: { visible: false }, + highlight: null, + aurora: { visible: false }, + }); + + console.log('[Eigent Demo] Demo complete.'); + }; + + function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // Listen for custom events from the MAIN world bridge (overlay/bridge.js) + document.addEventListener('eigent-cursor-demo', () => { + console.log('[Eigent Cursor] Demo event received from bridge'); + if (typeof window.__eigentOverlayDemo === 'function') { + window.__eigentOverlayDemo(); + } + }); + + document.addEventListener('eigent-cursor-cmd', (e) => { + const detail = e.detail || {}; + switch (detail.cmd) { + case 'showCursor': + OverlayStore.update({ + cursor: { + x: detail.x || 200, + y: detail.y || 200, + visible: true, + state: 'idle', + }, + }); + OverlayMotion.setPosition(detail.x || 200, detail.y || 200); + break; + case 'hideCursor': + OverlayStore.update({ cursor: { visible: false } }); + break; + case 'summary': + OverlayStore.update({ + summary: { text: detail.text || '', visible: !!detail.text }, + }); + break; + } + }); + + console.log('[Eigent Cursor] Content script loaded, overlay mounted.'); +} // end else block diff --git a/extensions/chrome_extension/manifest.json b/extensions/chrome_extension/manifest.json index c83da054..71f0751c 100644 --- a/extensions/chrome_extension/manifest.json +++ b/extensions/chrome_extension/manifest.json @@ -19,6 +19,28 @@ "action": { "default_title": "Open ADGM Co-work Agent" }, + "content_scripts": [ + { + "matches": [""], + "js": [ + "overlay/store.js", + "overlay/events.js", + "overlay/motion.js", + "overlay/aurora.js", + "overlay/cursor.js", + "overlay/summary.js", + "overlay/highlight.js", + "content.js" + ], + "run_at": "document_idle" + }, + { + "matches": [""], + "js": ["overlay/bridge.js"], + "run_at": "document_idle", + "world": "MAIN" + } + ], "background": { "service_worker": "background.js" }, diff --git a/extensions/chrome_extension/overlay/aurora.js b/extensions/chrome_extension/overlay/aurora.js new file mode 100644 index 00000000..2a716e17 --- /dev/null +++ b/extensions/chrome_extension/overlay/aurora.js @@ -0,0 +1,144 @@ +// Aurora gradient corner effect — soft ambient glow at all 4 corners + +const OverlayAurora = (() => { + let container = null; + let blobs = []; + let reducedMotion = false; + + // Each corner: a triangle of color that fades diagonally to transparent. + // The opposite corner of the rectangle is fully transparent. + const BLOB_CONFIGS = [ + { + corner: 'top-left', + css: 'top: 0; left: 0; width: 40vw; height: 50vh;', + gradient: `linear-gradient(135deg, rgba(150,130,255,0.9) 0%, rgba(120,180,255,0.4) 25%, transparent 50%)`, + delay: '0s', + }, + { + corner: 'top-right', + css: 'top: 0; right: 0; width: 40vw; height: 50vh;', + gradient: `linear-gradient(225deg, rgba(120,170,255,0.9) 0%, rgba(150,120,255,0.4) 25%, transparent 50%)`, + delay: '-2s', + }, + { + corner: 'bottom-left', + css: 'bottom: 0; left: 0; width: 40vw; height: 50vh;', + gradient: `linear-gradient(45deg, rgba(120,190,255,0.9) 0%, rgba(155,130,255,0.4) 25%, transparent 50%)`, + delay: '-4s', + }, + { + corner: 'bottom-right', + css: 'bottom: 0; right: 0; width: 40vw; height: 50vh;', + gradient: `linear-gradient(315deg, rgba(255,130,210,0.9) 0%, rgba(150,120,255,0.4) 25%, transparent 50%)`, + delay: '-6s', + }, + ]; + + function getStyles() { + return ` + .eigent-aurora-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 2147483646; + opacity: 0; + transition: opacity 0.8s ease; + } + + .eigent-aurora-container--visible { + opacity: 1; + } + + .eigent-aurora-blob { + position: absolute; + will-change: opacity; + animation: eigent-aurora-glow 8s ease-in-out infinite; + } + + @keyframes eigent-aurora-glow { + 0%, 100% { opacity: var(--aurora-intensity); } + 50% { opacity: calc(var(--aurora-intensity) * 0.5); } + } + `; + } + + function init(shadowRoot) { + reducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' + ).matches; + window + .matchMedia('(prefers-reduced-motion: reduce)') + .addEventListener('change', (e) => { + reducedMotion = e.matches; + updateAnimation(); + }); + + container = document.createElement('div'); + container.className = 'eigent-aurora-container'; + + const state = OverlayStore.getState(); + const intensity = state.aurora.intensity || 0.7; + + BLOB_CONFIGS.forEach((config) => { + const blob = document.createElement('div'); + blob.className = 'eigent-aurora-blob'; + blob.style.cssText = ` + ${config.css} + background: ${config.gradient}; + --aurora-intensity: ${intensity}; + animation-delay: ${config.delay}; + ${reducedMotion ? 'animation: none; opacity: ' + intensity + ';' : ''} + `; + blobs.push(blob); + container.appendChild(blob); + }); + + shadowRoot.appendChild(container); + + OverlayStore.subscribe((state, changed) => { + if (changed.aurora !== undefined || changed.enabled !== undefined) { + updateVisibility(); + } + }); + + updateVisibility(); + } + + function updateVisibility() { + if (!container) return; + const state = OverlayStore.getState(); + const visible = state.enabled && state.aurora.visible; + container.classList.toggle('eigent-aurora-container--visible', visible); + + const intensity = state.aurora.intensity || 0.7; + blobs.forEach((blob) => { + blob.style.setProperty('--aurora-intensity', String(intensity)); + }); + } + + function updateAnimation() { + const state = OverlayStore.getState(); + const intensity = state.aurora.intensity || 0.7; + blobs.forEach((blob) => { + if (reducedMotion) { + blob.style.animation = 'none'; + blob.style.opacity = String(intensity); + } else { + blob.style.animation = ''; + } + }); + } + + function destroy() { + if (container) { + container.remove(); + container = null; + blobs = []; + } + } + + return { init, getStyles, destroy }; +})(); diff --git a/extensions/chrome_extension/overlay/bridge.js b/extensions/chrome_extension/overlay/bridge.js new file mode 100644 index 00000000..0950dd95 --- /dev/null +++ b/extensions/chrome_extension/overlay/bridge.js @@ -0,0 +1,35 @@ +// Bridge script — runs in the MAIN world (page context) +// Exposes __eigentOverlayDemo to DevTools console by dispatching +// a custom DOM event that the content script (isolated world) listens for. + +window.__eigentOverlayDemo = function () { + document.dispatchEvent(new CustomEvent('eigent-cursor-demo')); + console.log('[Eigent] Demo triggered'); +}; + +window.__eigentOverlay = { + demo: function () { + document.dispatchEvent(new CustomEvent('eigent-cursor-demo')); + }, + showCursor: function (x, y) { + document.dispatchEvent( + new CustomEvent('eigent-cursor-cmd', { + detail: { cmd: 'showCursor', x: x, y: y }, + }) + ); + }, + hideCursor: function () { + document.dispatchEvent( + new CustomEvent('eigent-cursor-cmd', { + detail: { cmd: 'hideCursor' }, + }) + ); + }, + summary: function (text) { + document.dispatchEvent( + new CustomEvent('eigent-cursor-cmd', { + detail: { cmd: 'summary', text: text }, + }) + ); + }, +}; diff --git a/extensions/chrome_extension/overlay/cursor.js b/extensions/chrome_extension/overlay/cursor.js new file mode 100644 index 00000000..b5da93d4 --- /dev/null +++ b/extensions/chrome_extension/overlay/cursor.js @@ -0,0 +1,157 @@ +// Agent cursor — custom SVG cursor with state-based animations + +const OverlayCursor = (() => { + let cursorEl = null; + let shadowRoot = null; + + // Custom cursor SVG from design + const CURSOR_SVG = ` + + + + + + + + + + + + + + + + + + + + + + + + `; + + function getStyles() { + return ` + .eigent-cursor { + position: fixed; + top: 0; + left: 0; + width: 32px; + height: 32px; + pointer-events: none; + z-index: 2147483647; + will-change: transform, opacity; + transition: opacity 0.2s ease; + opacity: 0; + } + + .eigent-cursor--visible { + opacity: 1; + } + + .eigent-cursor--idle { + animation: eigent-cursor-pulse 2s ease-in-out infinite; + } + + .eigent-cursor--clicking { + animation: eigent-cursor-click 0.3s ease forwards; + } + + .eigent-cursor--thinking { + animation: eigent-cursor-think 1.2s linear infinite; + } + + @keyframes eigent-cursor-pulse { + 0%, 100% { transform: var(--cursor-translate) scale(1); } + 50% { transform: var(--cursor-translate) scale(1.08); } + } + + @keyframes eigent-cursor-click { + 0% { transform: var(--cursor-translate) scale(1); } + 40% { transform: var(--cursor-translate) scale(0.82); } + 100% { transform: var(--cursor-translate) scale(1); } + } + + @keyframes eigent-cursor-think { + 0% { transform: var(--cursor-translate) rotate(0deg); } + 25% { transform: var(--cursor-translate) rotate(6deg); } + 75% { transform: var(--cursor-translate) rotate(-6deg); } + 100% { transform: var(--cursor-translate) rotate(0deg); } + } + `; + } + + function init(root) { + shadowRoot = root; + + cursorEl = document.createElement('div'); + cursorEl.className = 'eigent-cursor'; + cursorEl.innerHTML = CURSOR_SVG; + + shadowRoot.appendChild(cursorEl); + + OverlayStore.subscribe((state, changed) => { + if (changed.cursor !== undefined || changed.enabled !== undefined) { + render(); + } + }); + + render(); + } + + function render() { + if (!cursorEl) return; + const { cursor, enabled, reducedMotion } = OverlayStore.getState(); + + const visible = enabled && cursor.visible; + cursorEl.classList.toggle('eigent-cursor--visible', visible); + + cursorEl.style.setProperty( + '--cursor-translate', + `translate3d(${cursor.x}px, ${cursor.y}px, 0)` + ); + cursorEl.style.transform = `translate3d(${cursor.x}px, ${cursor.y}px, 0)`; + + const states = [ + 'idle', + 'moving', + 'hovering', + 'clicking', + 'thinking', + 'complete', + 'error', + ]; + for (const s of states) { + cursorEl.classList.toggle(`eigent-cursor--${s}`, cursor.state === s); + } + + if (reducedMotion) { + cursorEl.style.animation = 'none'; + } + } + + function animateClick() { + OverlayStore.update({ cursor: { state: 'clicking' } }); + setTimeout(() => { + OverlayStore.update({ cursor: { state: 'idle' } }); + }, 300); + } + + function show() { + OverlayStore.update({ cursor: { visible: true } }); + } + + function hide() { + OverlayStore.update({ cursor: { visible: false } }); + } + + function destroy() { + if (cursorEl) { + cursorEl.remove(); + cursorEl = null; + } + } + + return { init, getStyles, animateClick, show, hide, destroy }; +})(); diff --git a/extensions/chrome_extension/overlay/events.js b/extensions/chrome_extension/overlay/events.js new file mode 100644 index 00000000..54db4891 --- /dev/null +++ b/extensions/chrome_extension/overlay/events.js @@ -0,0 +1,213 @@ +// Event types and message bridge between background ↔ content script + +const OverlayEvents = (() => { + // Message types: Background → Content + const TYPES = { + AGENT_STEP: 'OVERLAY_AGENT_STEP', + CURSOR_MOVE: 'OVERLAY_CURSOR_MOVE', + HIGHLIGHT: 'OVERLAY_HIGHLIGHT', + SUMMARY: 'OVERLAY_SUMMARY', + STATE: 'OVERLAY_STATE', + RESOLVE_TARGET: 'OVERLAY_RESOLVE_TARGET', + RESOLVE_AND_MOVE: 'OVERLAY_RESOLVE_AND_MOVE', + // Content → Background + READY: 'OVERLAY_READY', + ELEMENT_RESOLVED: 'OVERLAY_ELEMENT_RESOLVED', + }; + + function listen() { + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || !message.type || !message.type.startsWith('OVERLAY_')) { + return; + } + + switch (message.type) { + case TYPES.AGENT_STEP: + handleAgentStep(message); + break; + case 'OVERLAY_DEMO': + if (typeof window.__eigentOverlayDemo === 'function') { + window.__eigentOverlayDemo(); + } + break; + case TYPES.CURSOR_MOVE: + console.log( + '[Eigent Cursor] CURSOR_MOVE received:', + message.x, + message.y + ); + OverlayStore.update({ + cursor: { + x: message.x, + y: message.y, + state: 'moving', + visible: true, + }, + }); + if (typeof OverlayMotion !== 'undefined') { + OverlayMotion.moveCursor(message.x, message.y, message.duration); + } + break; + case TYPES.HIGHLIGHT: + if (typeof OverlayHighlight !== 'undefined') { + OverlayHighlight.highlightSelector( + message.selector, + message.duration + ); + } + break; + case 'OVERLAY_HIGHLIGHT_RECT': + // Highlight at a pre-resolved rect (from CDP resolution) + if (typeof OverlayHighlight !== 'undefined' && message.rect) { + OverlayHighlight.showRect(message.rect); + if (message.duration > 0) { + setTimeout(() => OverlayHighlight.hide(), message.duration); + } + } + break; + case TYPES.SUMMARY: + OverlayStore.update({ + summary: { text: message.text, visible: !!message.text }, + }); + break; + case TYPES.STATE: { + const stateUpdate = {}; + if (message.enabled !== undefined) { + stateUpdate.enabled = message.enabled; + } + if (message.auroraVisible !== undefined) { + stateUpdate.aurora = { visible: message.auroraVisible }; + } + if (message.cursorVisible !== undefined) { + stateUpdate.cursor = { visible: message.cursorVisible }; + } + if (message.summaryText !== undefined) { + stateUpdate.summary = { + text: message.summaryText, + visible: !!message.summaryText, + }; + } + OverlayStore.update(stateUpdate); + break; + } + case TYPES.RESOLVE_AND_MOVE: { + // Resolve element by ARIA ref and move cursor to its center + const ref = message.ref; + console.log('[Eigent Cursor] RESOLVE_AND_MOVE for ref:', ref); + const el = resolveElementByRef(ref); + if (el) { + const rect = el.getBoundingClientRect(); + const cx = rect.left + rect.width / 2; + const cy = rect.top + rect.height / 2; + console.log('[Eigent Cursor] Resolved ref', ref, 'to', cx, cy); + OverlayStore.update({ + cursor: { visible: true, state: 'moving' }, + }); + if (typeof OverlayMotion !== 'undefined') { + OverlayMotion.moveCursor(cx, cy); + } + } else { + console.log('[Eigent Cursor] Could not resolve ref:', ref); + } + break; + } + case TYPES.RESOLVE_TARGET: + if (typeof OverlayDomResolver !== 'undefined') { + const result = OverlayDomResolver.resolve(message.target); + sendResponse(result); + } + return true; // async response + } + }); + } + + function handleAgentStep(msg) { + if (msg.summary) { + OverlayStore.update({ summary: { text: msg.summary, visible: true } }); + } + if (msg.cursor) { + OverlayStore.update({ cursor: { visible: true } }); + if (typeof OverlayMotion !== 'undefined') { + OverlayMotion.moveCursor(msg.cursor.x, msg.cursor.y); + } + } + if (msg.highlight) { + OverlayStore.update({ highlight: msg.highlight }); + } + if (msg.state === 'done' || msg.state === 'error') { + setTimeout(() => { + OverlayStore.update({ + cursor: { state: msg.state === 'done' ? 'complete' : 'error' }, + summary: { visible: false }, + highlight: null, + }); + }, 1200); + } + } + + // Resolve an element by ARIA ref (e.g. "e46") — same methods as the CDP highlight system + function resolveElementByRef(ref) { + if (!ref) return null; + + // Method 1: __ariaSnapshot.getElementByRef + if ( + typeof __ariaSnapshot !== 'undefined' && + __ariaSnapshot.getElementByRef + ) { + try { + const el = __ariaSnapshot.getElementByRef(ref, document.body); + if (el) return el; + } catch (e) {} + } + + // Method 2: Walk DOM for _ariaRef property + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + null, + false + ); + let node; + while ((node = walker.nextNode())) { + if (node._ariaRef && node._ariaRef.ref === ref) { + return node; + } + } + + // Method 3: data attributes + const refNum = ref.replace(/^e/, ''); + const selectors = [ + `[data-ref="${ref}"]`, + `[data-ref="${refNum}"]`, + `[ref="${ref}"]`, + `[aria-ref="${ref}"]`, + `[data-camel-ref="${ref}"]`, + `[data-camel-ref="${refNum}"]`, + ]; + for (const sel of selectors) { + try { + const el = document.querySelector(sel); + if (el) return el; + } catch (e) {} + } + + // Method 4: Try as CSS selector directly + if (ref.includes('[') || ref.includes('.') || ref.includes('#')) { + try { + return document.querySelector(ref); + } catch (e) {} + } + + return null; + } + + function sendToBackground(type, data = {}) { + chrome.runtime.sendMessage({ type, ...data }).catch(() => {}); + } + + function notifyReady() { + sendToBackground(TYPES.READY); + } + + return { TYPES, listen, sendToBackground, notifyReady }; +})(); diff --git a/extensions/chrome_extension/overlay/highlight.js b/extensions/chrome_extension/overlay/highlight.js new file mode 100644 index 00000000..7cf00f79 --- /dev/null +++ b/extensions/chrome_extension/overlay/highlight.js @@ -0,0 +1,264 @@ +// Target element highlight — Atlas-style blue glow + +const OverlayHighlight = (() => { + let highlightEl = null; + let shadowRoot = null; + let scrollListener = null; + let resizeListener = null; + let currentTarget = null; // DOM element being tracked + let updateFrame = null; + + function getStyles() { + return ` + .eigent-highlight { + position: fixed; + top: 0; + left: 0; + pointer-events: none; + z-index: 2147483646; + border: 2px solid rgba(120, 180, 255, 0.6); + border-radius: 8px; + background: rgba(120, 180, 255, 0.08); + box-shadow: 0 0 12px rgba(120, 180, 255, 0.3), inset 0 0 8px rgba(120, 180, 255, 0.1); + will-change: transform, opacity, width, height; + opacity: 0; + transition: opacity 0.2s ease, transform 0.15s ease, width 0.15s ease, height 0.15s ease; + } + + .eigent-highlight--visible { + opacity: 1; + } + + .eigent-highlight--pulse { + animation: eigent-highlight-pulse 1.4s ease-in-out infinite; + } + + @keyframes eigent-highlight-pulse { + 0%, 100% { + box-shadow: 0 0 12px rgba(120, 180, 255, 0.3), inset 0 0 8px rgba(120, 180, 255, 0.1); + } + 50% { + box-shadow: 0 0 20px rgba(120, 180, 255, 0.5), inset 0 0 12px rgba(120, 180, 255, 0.15); + } + } + `; + } + + function init(root) { + shadowRoot = root; + + highlightEl = document.createElement('div'); + highlightEl.className = 'eigent-highlight'; + shadowRoot.appendChild(highlightEl); + + // Track scroll and resize for position updates + scrollListener = () => updatePosition(); + resizeListener = () => updatePosition(); + window.addEventListener('scroll', scrollListener, { + passive: true, + capture: true, + }); + window.addEventListener('resize', resizeListener, { passive: true }); + + OverlayStore.subscribe((state, changed) => { + if (changed.highlight !== undefined) { + if (state.highlight) { + showRect(state.highlight); + } else { + hide(); + } + } + if (changed.enabled !== undefined && !state.enabled) { + hide(); + } + }); + } + + // Show highlight at a specific rect { x, y, width, height } + function showRect(rect) { + if (!highlightEl) return; + const padding = 6; + highlightEl.style.transform = `translate3d(${rect.x - padding}px, ${rect.y - padding}px, 0)`; + highlightEl.style.width = `${rect.width + padding * 2}px`; + highlightEl.style.height = `${rect.height + padding * 2}px`; + highlightEl.classList.add( + 'eigent-highlight--visible', + 'eigent-highlight--pulse' + ); + } + + // Highlight a DOM element by selector — resolves and tracks it + function highlightSelector(selector, duration = 2000) { + const element = resolveElement(selector); + if (!element) { + console.warn('[Eigent Cursor] Highlight target not found:', selector); + hide(); + return null; + } + + currentTarget = element; + updatePosition(); + highlightEl.classList.add( + 'eigent-highlight--visible', + 'eigent-highlight--pulse' + ); + + // Update store with rect + const rect = element.getBoundingClientRect(); + OverlayStore.update({ + highlight: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + }); + + // Auto-hide after duration + if (duration > 0) { + setTimeout(() => { + if (currentTarget === element) { + hide(); + } + }, duration); + } + + return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; + } + + // Resolve element from various descriptor types + function resolveElement(descriptor) { + if (typeof descriptor === 'string') { + // Try CSS selector + try { + const el = document.querySelector(descriptor); + if (el && isVisible(el)) return el; + } catch (e) {} + + // Try text content match + return findByText(descriptor); + } + + if (typeof descriptor === 'object') { + // Try selector first + if (descriptor.selector) { + try { + const el = document.querySelector(descriptor.selector); + if (el && isVisible(el)) return el; + } catch (e) {} + } + // Try ARIA label + if (descriptor.ariaLabel) { + const el = document.querySelector( + `[aria-label="${CSS.escape(descriptor.ariaLabel)}"]` + ); + if (el && isVisible(el)) return el; + } + // Try role + text + if (descriptor.role) { + const candidates = document.querySelectorAll( + `[role="${descriptor.role}"]` + ); + for (const el of candidates) { + if ( + descriptor.text && + el.textContent.includes(descriptor.text) && + isVisible(el) + ) { + return el; + } + if (!descriptor.text && isVisible(el)) return el; + } + } + // Try text + if (descriptor.text) { + return findByText(descriptor.text); + } + } + + return null; + } + + function findByText(text) { + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT + ); + let node; + while ((node = walker.nextNode())) { + if (node.children.length === 0 || node.childNodes.length === 1) { + const content = (node.textContent || '').trim(); + if (content === text || content.includes(text)) { + if (isVisible(node)) return node; + } + } + } + return null; + } + + function isVisible(el) { + const style = getComputedStyle(el); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' + ) { + return false; + } + const rect = el.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function updatePosition() { + if (!currentTarget || !highlightEl) return; + if (updateFrame) cancelAnimationFrame(updateFrame); + + updateFrame = requestAnimationFrame(() => { + if (!currentTarget) return; + const rect = currentTarget.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + hide(); + return; + } + const padding = 6; + highlightEl.style.transform = `translate3d(${rect.left - padding}px, ${rect.top - padding}px, 0)`; + highlightEl.style.width = `${rect.width + padding * 2}px`; + highlightEl.style.height = `${rect.height + padding * 2}px`; + }); + } + + function hide() { + if (!highlightEl) return; + highlightEl.classList.remove( + 'eigent-highlight--visible', + 'eigent-highlight--pulse' + ); + currentTarget = null; + // Only update store if highlight isn't already null (avoid re-entrant loop) + if (OverlayStore.getState().highlight !== null) { + OverlayStore.update({ highlight: null }); + } + } + + function destroy() { + if (scrollListener) + window.removeEventListener('scroll', scrollListener, { capture: true }); + if (resizeListener) window.removeEventListener('resize', resizeListener); + if (updateFrame) cancelAnimationFrame(updateFrame); + if (highlightEl) { + highlightEl.remove(); + highlightEl = null; + } + currentTarget = null; + } + + return { + init, + getStyles, + highlightSelector, + showRect, + hide, + resolveElement, + destroy, + }; +})(); diff --git a/extensions/chrome_extension/overlay/motion.js b/extensions/chrome_extension/overlay/motion.js new file mode 100644 index 00000000..9f648771 --- /dev/null +++ b/extensions/chrome_extension/overlay/motion.js @@ -0,0 +1,147 @@ +// Animation utilities — spring easing, cursor motion, tab visibility + +const OverlayMotion = (() => { + let cursorAnimFrame = null; + let cursorCurrentX = 0; + let cursorCurrentY = 0; + let tabVisible = true; + + // Track tab visibility to pause animations + document.addEventListener('visibilitychange', () => { + tabVisible = !document.hidden; + }); + + // Easing functions + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function easeOutQuart(t) { + return 1 - Math.pow(1 - t, 4); + } + + // Spring approximation — overdamp for smooth settle + function springEase(t) { + const c4 = (2 * Math.PI) / 3; + return t === 0 + ? 0 + : t === 1 + ? 1 + : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + } + + // Compute duration based on distance (300ms min, 900ms max) + function durationForDistance(x1, y1, x2, y2) { + const dist = Math.hypot(x2 - x1, y2 - y1); + return Math.min(900, Math.max(300, dist * 1.2)); + } + + // Animate a value from → to using requestAnimationFrame + function animate({ + from, + to, + duration, + easing = easeOutCubic, + onUpdate, + onComplete, + }) { + const start = performance.now(); + let frame; + + function tick(now) { + const elapsed = now - start; + const progress = Math.min(elapsed / duration, 1); + const easedProgress = easing(progress); + + // Interpolate + if (typeof from === 'number') { + onUpdate(from + (to - from) * easedProgress); + } else { + // Object with x, y + onUpdate({ + x: from.x + (to.x - from.x) * easedProgress, + y: from.y + (to.y - from.y) * easedProgress, + }); + } + + if (progress < 1 && tabVisible) { + frame = requestAnimationFrame(tick); + } else if (progress >= 1) { + if (onComplete) onComplete(); + } + } + + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + } + + // Move cursor to target position with smooth animation + function moveCursor(targetX, targetY, customDuration) { + if (cursorAnimFrame) { + cancelAnimationFrame(cursorAnimFrame); + cursorAnimFrame = null; + } + + const duration = + customDuration || + durationForDistance(cursorCurrentX, cursorCurrentY, targetX, targetY); + const startX = cursorCurrentX; + const startY = cursorCurrentY; + const startTime = performance.now(); + + return new Promise((resolve) => { + function tick(now) { + const elapsed = now - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = easeOutQuart(progress); + + cursorCurrentX = startX + (targetX - startX) * eased; + cursorCurrentY = startY + (targetY - startY) * eased; + + // Update store (cursor module reads from store) + OverlayStore.update({ + cursor: { + x: cursorCurrentX, + y: cursorCurrentY, + state: progress < 1 ? 'moving' : 'idle', + }, + }); + + if (progress < 1 && tabVisible) { + cursorAnimFrame = requestAnimationFrame(tick); + } else { + cursorAnimFrame = null; + resolve(); + } + } + + cursorAnimFrame = requestAnimationFrame(tick); + }); + } + + // Settle delay — pause before click action + function settle(ms = 150) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + function getCurrentPosition() { + return { x: cursorCurrentX, y: cursorCurrentY }; + } + + function setPosition(x, y) { + cursorCurrentX = x; + cursorCurrentY = y; + } + + return { + animate, + moveCursor, + settle, + easeOutCubic, + easeOutQuart, + springEase, + getCurrentPosition, + setPosition, + durationForDistance, + }; +})(); diff --git a/extensions/chrome_extension/overlay/store.js b/extensions/chrome_extension/overlay/store.js new file mode 100644 index 00000000..47206fcc --- /dev/null +++ b/extensions/chrome_extension/overlay/store.js @@ -0,0 +1,70 @@ +// Overlay state store — simple pub/sub, no framework +// Shared across all overlay modules via content script injection order + +const OverlayStore = (() => { + const state = { + enabled: true, + reducedMotion: false, + aurora: { visible: false, intensity: 0.7 }, + cursor: { x: 0, y: 0, state: 'idle', visible: false }, + summary: { text: '', visible: false }, + highlight: null, // { x, y, width, height } or null + }; + + const listeners = new Set(); + let notifying = false; + let pendingUpdate = null; + + function getState() { + return state; + } + + function update(partial) { + // Apply state changes immediately + for (const key of Object.keys(partial)) { + if ( + typeof partial[key] === 'object' && + partial[key] !== null && + !Array.isArray(partial[key]) + ) { + state[key] = { ...state[key], ...partial[key] }; + } else { + state[key] = partial[key]; + } + } + + // Re-entrancy guard: if a subscriber calls update(), batch it + if (notifying) { + pendingUpdate = pendingUpdate || {}; + Object.assign(pendingUpdate, partial); + return; + } + + notifying = true; + try { + for (const fn of listeners) { + try { + fn(state, partial); + } catch (e) { + console.error('[Eigent Cursor] Store listener error:', e); + } + } + } finally { + notifying = false; + } + + // Flush any updates that were queued during notification + if (pendingUpdate) { + const queued = pendingUpdate; + pendingUpdate = null; + update(queued); + } + } + + function subscribe(fn) { + listeners.add(fn); + return () => listeners.delete(fn); + } + + return { getState, update, subscribe }; +})(); diff --git a/extensions/chrome_extension/overlay/summary.js b/extensions/chrome_extension/overlay/summary.js new file mode 100644 index 00000000..a1ca0aa0 --- /dev/null +++ b/extensions/chrome_extension/overlay/summary.js @@ -0,0 +1,174 @@ +// Floating action summary bubble — attached near cursor + +const OverlaySummary = (() => { + let bubbleEl = null; + let currentText = ''; + let followTimer = null; + let bubbleX = 0; + let bubbleY = 0; + + const OFFSET_X = 20; // px right of cursor + const OFFSET_Y = 28; // px below cursor + const MAX_CHARS = 64; + const FOLLOW_LAG = 0; // no lag, follow cursor immediately + + function getStyles() { + return ` + .eigent-summary { + position: fixed; + top: 0; + left: 0; + pointer-events: none; + z-index: 2147483647; + will-change: transform, opacity; + max-width: 320px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font: 13px/1.3 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + color: #fff; + background: #155DFC; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-radius: 9999px; + padding: 8px 18px; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); + opacity: 0; + transition: opacity 0.18s ease; + } + + .eigent-summary--visible { + opacity: 1; + } + + .eigent-summary--entering { + transform: translate3d(var(--summary-x), var(--summary-y), 0) translateY(4px); + } + + .eigent-summary--active { + transform: translate3d(var(--summary-x), var(--summary-y), 0); + } + `; + } + + function init(shadowRoot) { + bubbleEl = document.createElement('div'); + bubbleEl.className = 'eigent-summary'; + shadowRoot.appendChild(bubbleEl); + + OverlayStore.subscribe((state, changed) => { + if (changed.summary !== undefined) { + updateText(state.summary); + } + if (changed.cursor !== undefined) { + scheduleFollow(state.cursor); + } + if (changed.enabled !== undefined && !state.enabled) { + hide(); + } + }); + } + + function updateText(summary) { + if (!bubbleEl) return; + + if (!summary.visible || !summary.text) { + hide(); + return; + } + + const text = + summary.text.length > MAX_CHARS + ? summary.text.slice(0, MAX_CHARS - 1) + '\u2026' + : summary.text; + + if (text !== currentText) { + // Fade out, swap, fade in + bubbleEl.classList.remove('eigent-summary--active'); + bubbleEl.classList.add('eigent-summary--entering'); + + setTimeout( + () => { + bubbleEl.textContent = text; + currentText = text; + // Position at center-top if cursor not visible + const { cursor } = OverlayStore.getState(); + if (!cursor.visible) { + positionCenterTop(); + } + bubbleEl.classList.add('eigent-summary--visible'); + requestAnimationFrame(() => { + bubbleEl.classList.remove('eigent-summary--entering'); + bubbleEl.classList.add('eigent-summary--active'); + }); + }, + currentText ? 120 : 0 + ); + } + } + + function scheduleFollow(cursor) { + if (cursor.visible) { + // Cursor is active (clicking action) — follow it + setPosition(cursor.x + OFFSET_X, cursor.y + OFFSET_Y); + } else { + // No cursor (non-click action) — center top of screen, 10vh from top + positionCenterTop(); + } + } + + function positionCenterTop() { + if (!bubbleEl) return; + const vw = window.innerWidth; + const rect = bubbleEl.getBoundingClientRect(); + const w = rect.width || 200; + const x = (vw - w) / 2; + const y = window.innerHeight * 0.1; // 10vh from top + bubbleX = x; + bubbleY = y; + bubbleEl.style.setProperty('--summary-x', `${bubbleX}px`); + bubbleEl.style.setProperty('--summary-y', `${bubbleY}px`); + bubbleEl.style.transform = `translate3d(${bubbleX}px, ${bubbleY}px, 0)`; + } + + function setPosition(x, y) { + if (!bubbleEl) return; + bubbleX = x; + bubbleY = y; + + // Clamp to viewport + const vw = window.innerWidth; + const vh = window.innerHeight; + const rect = bubbleEl.getBoundingClientRect(); + const w = rect.width || 200; + const h = rect.height || 30; + + if (bubbleX + w > vw - 8) bubbleX = vw - w - 8; + if (bubbleY + h > vh - 8) bubbleY = vh - h - 8; + if (bubbleX < 8) bubbleX = 8; + if (bubbleY < 8) bubbleY = 8; + + bubbleEl.style.setProperty('--summary-x', `${bubbleX}px`); + bubbleEl.style.setProperty('--summary-y', `${bubbleY}px`); + bubbleEl.style.transform = `translate3d(${bubbleX}px, ${bubbleY}px, 0)`; + } + + function hide() { + if (!bubbleEl) return; + bubbleEl.classList.remove( + 'eigent-summary--visible', + 'eigent-summary--active' + ); + currentText = ''; + } + + function destroy() { + if (followTimer) clearTimeout(followTimer); + if (bubbleEl) { + bubbleEl.remove(); + bubbleEl = null; + } + } + + return { init, getStyles, hide, destroy }; +})();