diff --git a/.gitignore b/.gitignore index 75c402b..623a217 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ # misc .DS_Store *.pem +*.bak # debug npm-debug.log* diff --git a/assets/chrome.png b/assets/chrome.png new file mode 100644 index 0000000..d01b5c2 Binary files /dev/null and b/assets/chrome.png differ diff --git a/package.json b/package.json index 0638714..854e0e4 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "tailwindcss": "3.4.1", - "util": "^0.12.5", - "uuid": "^13.0.0" + "util": "^0.12.5" }, "devDependencies": { "@babel/preset-env": "^7.26.9", @@ -63,8 +62,13 @@ }, "manifest": { "permissions": [ + "tabs", + "history", + "activeTab", + "scripting", "storage", - "cookies" + "cookies", + "tabGroups" ], "host_permissions": [ "https://*/*", @@ -72,4 +76,4 @@ ] }, "type": "module" -} \ No newline at end of file +} diff --git a/src/background.ts b/src/background.ts index c86f56f..313abbf 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,3 +1,331 @@ +const CLUSTER_COLORS = [ + 'blue', 'red', 'yellow', 'green', 'pink', + 'purple', 'cyan', 'orange', 'grey' +] as const; + +async function createTabGroupsFromClusters(clusters: [number, number[], string][]) { + try { + // Sort clusters by size (largest first) + clusters.sort((a, b) => b[1].length - a[1].length); + + for (const [clusterIndex, [clusterId, tabIds, label]] of clusters.entries()) { + if (tabIds.length === 0) continue; + + // Create a group for this cluster + const groupId = await chrome.tabs.group({ tabIds }); + + // Update group with cluster name and color + const color = CLUSTER_COLORS[clusterIndex % CLUSTER_COLORS.length]; + await chrome.tabGroups.update(groupId, { + title: `${label} (${tabIds.length})`, // Use the actual cluster label + color: color, + collapsed: tabIds.length > 5 + }); + + } + } catch (error) { + console.error('Error creating tab groups:', error); + throw error; + } +} +async function fetchClustersFromMantis(spaceId: string, tabsMap: Map): Promise<[number, number[], string][]> { + return new Promise((resolve, reject) => { + try { + const backendUrl = process.env.PLASMO_PUBLIC_MANTIS_API || 'http://localhost:8000'; + const wsUrl = backendUrl.replace('http', 'ws') + `/ws/space/${spaceId}/`; + + // Connecting to WebSocket + + const ws = new WebSocket(wsUrl); + let clustersReceived = false; + let pointsWithClustersReceived = false; + let pointsWithMetadataReceived = false; + + const clusterLabels = new Map(); // Map cluster ID to label + const pointToClusterMap = new Map(); // ← ADD THIS: Map point ID to cluster ID + const clusterGroups = new Map(); // Map cluster ID to tab IDs + + + ws.addEventListener('message', (event) => { + const data = JSON.parse(event.data); + + // Collect cluster labels (only update with real names, not UUIDs) + if (data.type === 'cluster' && data.clusters) { + + // Recieved Cluster Labels + + data.clusters.forEach((cluster: any) => { + const label = cluster.label?.trim(); + + if (label && !label.startsWith('Cluster ')) { + clusterLabels.set(cluster.id, label); + } + }); + + clustersReceived = true; + } + + // First points message: Get cluster assignments (has cluster field) + if (data.type === 'points' && data.points && data.points[0]?.cluster && !pointsWithClustersReceived) { + // Processing points with cluster assignments + + data.points.forEach((point: any) => { + if (point.cluster && point.id) { + pointToClusterMap.set(point.id, point.cluster); + } + }); + + pointsWithClustersReceived = true; + } + + // Later points message: Get tab_id metadata (has metadata.tab_id field) + if (data.type === 'points' && data.points && data.points[0]?.metadata?.tab_id && !pointsWithMetadataReceived) { + // Processing points with tab IDs + + data.points.forEach((point: any) => { + const tabId = parseInt(point.metadata.tab_id); + const pointId = point.id; + const clusterId = pointToClusterMap.get(pointId); + + if (clusterId && tabId) { + if (!clusterGroups.has(clusterId)) { + clusterGroups.set(clusterId, []); + } + clusterGroups.get(clusterId)!.push(tabId); + } + }); + + pointsWithMetadataReceived = true; + } + + if (data.type === 'finished') { + // WebSocket finished loading data + ws.close(); + + if (clustersReceived && pointsWithClustersReceived && pointsWithMetadataReceived && clusterGroups.size > 0) { + const result: [number, number[], string][] = Array.from(clusterGroups.entries()).map( + ([clusterId, tabIds]) => { + const label = clusterLabels.get(clusterId) || `Cluster ${clusterId}`; + return [clusterId as any, tabIds, label]; + } + ); + + result.sort((a, b) => b[1].length - a[1].length); + + resolve(result); + } else { + reject(new Error(`Missing data: clusters=${clustersReceived}, pointsWithClusters=${pointsWithClustersReceived}, pointsWithMetadata=${pointsWithMetadataReceived}, groups=${clusterGroups.size}`)); + } + } + }); + + ws.addEventListener('error', (error) => { + console.error('💥 WebSocket error:', error); + ws.close(); + reject(new Error('WebSocket connection failed')); + }); + + + setTimeout(() => { + if (!clustersReceived || !pointsWithClustersReceived || !pointsWithMetadataReceived) { + ws.close(); + reject(new Error('WebSocket timeout')); + } + }, 30000); + + } catch (error) { + console.error('💥 Error setting up WebSocket:', error); + reject(error); + } + }); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (request.action === "fetchClusters") { + fetchClustersFromMantis(request.spaceId, new Map(request.tabsMap)) + .then((clusterGroups: [number, number[], string][]) => { // Add the string type for label + createTabGroupsFromClusters(clusterGroups) + .then(() => sendResponse({ success: true })) + .catch(error => sendResponse({ success: false, error: error.message })); + }) + .catch(error => sendResponse({ success: false, error: error.message })); + + return true; // Keep message channel open for async response + } +}); + + +// This is used to get all tabs in the browser, and some of their content +// Add timeout wrapper function +async function withTimeout(promise: Promise, timeoutMs: number, defaultValue: T): Promise { + return Promise.race([ + promise, + new Promise((resolve) => setTimeout(() => resolve(defaultValue), timeoutMs)) + ]); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + // Handle getTabsWithContent request + if (request.action === "getTabsWithContent") { + chrome.tabs.query({}, async (tabs) => { + if (chrome.runtime.lastError) { + sendResponse({ error: chrome.runtime.lastError.message }); + return; + } + + // Process Tabs + + const tabsWithContentPromises = tabs.map(async (tab, index) => { + const tabData = { ...tab, pageContent: '' }; + + try { + if (tab.id && tab.url && !tab.url.startsWith('chrome://') && !tab.url.startsWith('chrome-extension://')) { + // Add 5 second timeout per tab + const results = await withTimeout( + chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: getPageContent, + }), + 5000, // 5 second timeout + null + ); + + if (results && results[0] && results[0].result) { + tabData.pageContent = results[0].result; + } else { + tabData.pageContent = `Content from ${new URL(tab.url).hostname} - timed out`; + } + } + } catch (error) { + console.warn(`⚠️ Could not get content for tab ${tab.id}:`, error.message); + tabData.pageContent = `Content from ${tab.url ? new URL(tab.url).hostname : 'unknown site'} - unable to read page content`; + } + + + return tabData; + }); + + const tabsWithContent = await Promise.all(tabsWithContentPromises); + + sendResponse({ tabs: tabsWithContent }); + }); + return true; + } + + + return false; +}); + +// This is for filtering text nodes in the page content extraction +// It feels less appropriate to have this logic here, +// but this function was long enough to warrant its own helper +const acceptNode = (node, excludedTags = ['script', 'style', 'noscript', 'iframe', 'object'], minTextLength = 3) => { + // Skip script, style, and other non-visible content + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + + const tagName = parent.tagName.toLowerCase(); + if (excludedTags.includes(tagName)) { + return NodeFilter.FILTER_REJECT; + } + + // Skip if parent is hidden + const style = window.getComputedStyle(parent); + if (style.display === 'none' || style.visibility === 'hidden') { + return NodeFilter.FILTER_REJECT; + } + + // Only accept text nodes with meaningful content + const text = node.textContent?.trim() || ''; + if (text.length < minTextLength) return NodeFilter.FILTER_REJECT; + + return NodeFilter.FILTER_ACCEPT; +}; + +// This gets the page content from a tab. +function getPageContent() { + try { + const title = document.title || ''; + const url = window.location.href; + const domain = window.location.hostname; + + // Get ALL visible text from the page + let allText = ''; + + // Method 1: Try to get all text from body + if (document.body) { + // Get all text content, which automatically excludes HTML tags + allText = document.body.innerText || document.body.textContent || ''; + } + + // If body approach fails, try document-wide text extraction + if (!allText || allText.length < 100) { + // Get all text nodes in the document + const walker = document.createTreeWalker( + document.body || document.documentElement, + NodeFilter.SHOW_TEXT, + { acceptNode } + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + const text = node.textContent?.trim(); + if (text && text.length > 2) { + textNodes.push(text); + } + } + + allText = textNodes.join(' '); + } + + // Clean up the text + allText = allText + .replace(/\s+/g, ' ') // Replace multiple whitespace with single space + .trim(); + + // Take a reasonable sample of the text (first 300 chars) + const textSample = allText.substring(0, 300); + + // Combine title and text content + let result = ''; + if (title && title.trim()) { + result += `${title.trim()}. `; + } + + if (textSample && textSample.length > 10) { + // Remove title from content if it's repeated + let contentText = textSample; + if (title && textSample.toLowerCase().startsWith(title.toLowerCase())) { + contentText = textSample.substring(title.length).trim(); + if (contentText.startsWith('.') || contentText.startsWith('-')) { + contentText = contentText.substring(1).trim(); + } + } + + if (contentText.length > 10) { + result += contentText; + } + } + + // Generic fallback if no meaningful content found + if (!result.trim() || result.trim().length < 20) { + result = `Content from ${domain} - ${title || url.split('/').pop() || 'webpage'}`; + } + + return result || `Page from ${domain}`; + + } catch (error) { + console.error('Error extracting page content:', error); + + // Simple fallback + const domain = window.location.hostname; + const title = document.title || ''; + + return title || `Content from ${domain}`; + } +} + // This is used to register cookies in the browser chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === "setCookie") { diff --git a/src/connection_manager.tsx b/src/connection_manager.tsx index 2e48d7a..37cec77 100644 --- a/src/connection_manager.tsx +++ b/src/connection_manager.tsx @@ -6,9 +6,10 @@ import { GoogleScholarConnection } from "./connections/googleScholar/connection" import { WikipediaSegmentConnection } from "./connections/wikipediaSegment/connection"; import { GmailConnection } from "./connections/Gmail/connection"; import { LinkedInConnection } from "./connections/Linkedin/connection"; +import { ChromeTabsConnection } from "./connections/chromeTabs/connection"; -export const CONNECTIONS = [GmailConnection, WikipediaSegmentConnection, WikipediaReferencesConnection, GoogleConnection, PubmedConnection, GoogleDocsConnection, GoogleScholarConnection,LinkedInConnection]; +export const CONNECTIONS = [GmailConnection, WikipediaSegmentConnection, WikipediaReferencesConnection, GoogleConnection, PubmedConnection, GoogleDocsConnection, GoogleScholarConnection,LinkedInConnection, ChromeTabsConnection]; export const searchConnections = (url: string, ) => { const connections = CONNECTIONS.filter(connection => connection.trigger(url)); diff --git a/src/connections/Linkedin/connection.tsx b/src/connections/Linkedin/connection.tsx index 63422d8..4917301 100644 --- a/src/connections/Linkedin/connection.tsx +++ b/src/connections/Linkedin/connection.tsx @@ -3,7 +3,7 @@ import { GenerationProgress } from "../types"; import { getSpacePortal, registerAuthCookies, reqSpaceCreation } from "../../driver"; import wikiIcon from "data-base64:../../../assets/wiki.png"; -import { v4 as uuidv4 } from 'uuid'; +import { getUuidV4 } from "../../driver"; @@ -65,7 +65,7 @@ const createSpace = async ( const company = row[companyIdx]; const url = linkIdx !== -1 ? row[linkIdx] : ""; result.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: `Applied Job: ${title}`, text: `Applied to ${title} at ${company}`, link: url, @@ -122,7 +122,7 @@ const createSpace = async ( const name = document.querySelector("h1.text-heading-xlarge")?.textContent?.trim() || "Unknown Name"; const headline = document.querySelector(".text-body-medium.break-words")?.textContent?.trim() || ""; extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: name, text: sanitize(headline), link: window.location.href, @@ -139,7 +139,7 @@ const createSpace = async ( const about = aboutSection?.innerText?.trim(); if (about) { extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: "About", text: sanitize(about), link: window.location.href, @@ -161,7 +161,7 @@ const createSpace = async ( const description = entry.innerText?.trim(); if (jobTitle && description) { extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: `Experience: ${jobTitle}`, text: sanitize(description), link: window.location.href, @@ -184,7 +184,7 @@ const createSpace = async ( const eduDetails = entry.innerText?.trim(); if (school && eduDetails) { extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: `Education: ${school}`, text: sanitize(eduDetails), link: window.location.href, @@ -206,7 +206,7 @@ const createSpace = async ( if (!seen.has(connectionUrl)) { seen.add(connectionUrl); extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: `Connection: ${connectionName}`, text: `Connected with ${connectionName}`, link: connectionUrl, @@ -232,7 +232,7 @@ if (activitySection) { const postContent = card.textContent?.trim().replace(/\s+/g, " ") || "LinkedIn Activity"; extractedData.push({ - uuid: uuidv4(), + uuid: getUuidV4(), title: `Activity: ${postContent.slice(0, 40)}...`, text: postContent, link: postUrl, @@ -269,7 +269,7 @@ const getMessagesFromIframe = async (): Promise => { : "https://www.linkedin.com/messaging/"; return { - uuid: uuidv4(), + uuid: getUuidV4(), title: `Message with ${name}`, text: `${timestamp} - ${snippet}`, link: threadUrl, @@ -312,7 +312,7 @@ const getFollowedCompanies = async (): Promise => { const link = (card.querySelector("a") as HTMLAnchorElement)?.href || ""; return { - uuid: uuidv4(), + uuid: getUuidV4(), title: `Following: ${name}`, text: subtitle, link, diff --git a/src/connections/chromeTabs/connection.tsx b/src/connections/chromeTabs/connection.tsx new file mode 100644 index 0000000..a50cef2 --- /dev/null +++ b/src/connections/chromeTabs/connection.tsx @@ -0,0 +1,542 @@ +import type { MantisConnection, injectUIType, onMessageType, registerListenersType, setProgressType, establishLogSocketType } from "../types"; +import { GenerationProgress } from "../types"; + +import chromeIcon from "data-base64:../../../assets/chrome.png"; +import { reqSpaceCreation } from "../../driver"; + + +interface TabWithContent extends chrome.tabs.Tab { + pageContent?: string; + dataIndex?: number; +} + +class DatasetTooSmallError extends Error { + constructor(public dataCount: number, message?: string) { + super(message || `Dataset too small: ${dataCount} items`); + this.name = 'DatasetTooSmallError'; + } +} + +class NoTabsFoundError extends Error { + constructor(message?: string) { + super(message || 'No tabs found'); + this.name = 'NoTabsFoundError'; + } +} + +const trigger = (url: string) => { + return url.includes("google.com/search"); +} +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 3000; + +const getTabsWithContentViaMessage = (): Promise => { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ action: "getTabsWithContent" }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else if (response.error) { + reject(new Error(response.error)); + } else { + resolve(response.tabs || []); + } + }); + }); +}; + + +const createSpace = async (injectUI: injectUIType, setProgress: setProgressType, onMessage: onMessageType, registerListeners: registerListenersType, establishLogSocket: establishLogSocketType) => { + setProgress(GenerationProgress.GATHERING_DATA); + + const extractedData = []; + const tabsMap = new Map(); + + try { + const tabs = await getTabsWithContentViaMessage(); + + if (!tabs || tabs.length === 0) { + throw new NoTabsFoundError(); + } + + tabs.forEach((tab, index) => { + if (tab.title && tab.url && tab.id) { + let domain = ''; + try { + domain = new URL(tab.url).hostname; + } catch (e) { + domain = 'unknown'; + } + + let pageContent = tab.pageContent || `Page from ${domain}`; + + extractedData.push({ + title: tab.title, + semantic_title: `${tab.active ? 'Active' : 'Background'} tab: ${tab.title}`, + link: tab.url, + snippet: `Tab ${index + 1}: ${pageContent}`, + tab_id: tab.id.toString() // ← ADD THIS: Store the actual Chrome tab ID + }); + + tabsMap.set(tab.id, tab.id); // ← CHANGE THIS: Map Chrome tab ID to itself + } + }); + + setProgress(GenerationProgress.CREATING_SPACE); + + const spaceData = await createSpaceWithAutoRetry(extractedData, establishLogSocket, `Chrome Tabs Space (${tabs.length} tabs)`); + + setProgress(GenerationProgress.INJECTING_UI); + + const spaceId = spaceData.space_id; + + const createdWidget = await injectUI(spaceId, onMessage, registerListeners); + + setProgress(GenerationProgress.COMPLETED); + + showOrganizationPrompt(spaceId, tabsMap); + + return { spaceId, createdWidget }; + + } catch (error) { + console.error('Error in Chrome Tabs connection:', error); + + if (error instanceof DatasetTooSmallError) { + showDatasetTooSmallError(error.dataCount); + return null; + } + + if (error instanceof NoTabsFoundError) { + showNoTabsError(); + return null; + } + + const errorMessage = error.message || error.toString(); + if (errorMessage.includes('Dataset too small') || + errorMessage.includes('minimum 100 rows are required')) { + showDatasetTooSmallError(extractedData.length); + return null; + } + + throw error; + } +} + + +const showOrganizationPrompt = (spaceId: string, tabsMap: Map) => { + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; + `; + + const modal = document.createElement('div'); + modal.style.cssText = ` + background: white; + border-radius: 16px; + padding: 32px; + max-width: 480px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.3s ease; + `; + + modal.innerHTML = ` + +
+
📁
+

+ Organize Your Tabs? +

+

+ Would you like to automatically group your tabs based on the clusters in your Mantis space? +

+
+ + +
+
+ `; + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + const yesButton = modal.querySelector('#organize-yes') as HTMLButtonElement; + const noButton = modal.querySelector('#organize-no') as HTMLButtonElement; + + yesButton.addEventListener('mouseenter', () => { + yesButton.style.transform = 'translateY(-2px)'; + }); + yesButton.addEventListener('mouseleave', () => { + yesButton.style.transform = 'translateY(0)'; + }); + + noButton.addEventListener('mouseenter', () => { + noButton.style.transform = 'translateY(-2px)'; + }); + noButton.addEventListener('mouseleave', () => { + noButton.style.transform = 'translateY(0)'; + }); + + yesButton.addEventListener('click', async () => { + yesButton.innerHTML = '⏳ Organizing...'; + yesButton.disabled = true; + noButton.disabled = true; + + try { + await organizeTabsIntoGroups(spaceId, tabsMap); + overlay.style.opacity = '0'; + setTimeout(() => overlay.remove(), 300); + } catch (error) { + console.error('Failed to organize tabs:', error); + modal.innerHTML = ` +
+
⚠️
+

Failed to Organize

+

+ ${error.message || 'An error occurred while organizing tabs.'} +

+ +
+ `; + modal.querySelector('#close-error').addEventListener('click', () => { + overlay.style.opacity = '0'; + setTimeout(() => overlay.remove(), 300); + }); + } + }); + + noButton.addEventListener('click', () => { + overlay.style.opacity = '0'; + setTimeout(() => overlay.remove(), 300); + }); + + // Close on overlay click + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { + overlay.style.opacity = '0'; + setTimeout(() => overlay.remove(), 300); + } + }); +}; + +const organizeTabsIntoGroups = async (spaceId: string, tabsMap: Map) => { + await new Promise(resolve => setTimeout(resolve, 2000)); + + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage({ + action: 'fetchClusters', + spaceId: spaceId, + tabsMap: Array.from(tabsMap.entries()) // Convert Map to Array for serialization + }, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else if (response?.success) { + resolve(true); + } else { + reject(new Error(response?.error || 'Unknown error')); + } + }); + }); +}; + + +// New function for automatic retry +const createSpaceWithAutoRetry = async (extractedData: { title: string; semantic_title: string; link: string; snippet: string; tab_id: string; }[], establishLogSocket: establishLogSocketType, title: string, maxRetries = MAX_RETRIES) => { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + + if (attempt > 1) { + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + } + + return await reqSpaceCreation(extractedData, { + "title": "title", + "semantic_title": "semantic", + "link": "links", + "snippet": "semantic", + "tab_id": "numeric" // ← ADD THIS: Tell Mantis this is a numeric field + }, establishLogSocket, title); + + } catch (error) { + const errorMessage = error.message || error.toString(); + + if (errorMessage.includes('Dataset too small') || + errorMessage.includes('minimum 100 rows are required')) { + throw new DatasetTooSmallError(extractedData.length, errorMessage); + } + + if ((errorMessage.includes('504') || + errorMessage.includes('timeout') || + errorMessage.includes('Gateway Time-out')) && + attempt < maxRetries) { + + continue; + } + + throw error; + } + } +}; + +// Error handlers +// Base styles for error notifications +const getBaseErrorStyles = () => ({ + container: ` + position: fixed; + top: 20px; + right: 20px; + color: white; + padding: 20px; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + z-index: 10000; + max-width: 400px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + `, + header: 'display: flex; align-items: center; margin-bottom: 12px;', + title: 'font-size: 16px;', + message: 'margin: 0 0 12px 0; line-height: 1.4; font-size: 14px;', + button: ` + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + ` +}); + +// Generic error notification creator +const createErrorNotification = (config: { + background: string; + title: string; + message: string; + buttonText: string; +}) => { + const styles = getBaseErrorStyles(); + const errorDiv = document.createElement('div'); + + errorDiv.style.cssText = ` + ${styles.container} + background: ${config.background}; + `; + + // Create header container + const headerDiv = document.createElement('div'); + headerDiv.style.cssText = styles.header; + + const title = document.createElement('strong'); + title.style.cssText = styles.title; + title.textContent = config.title; + headerDiv.appendChild(title); + + // Create message paragraph + const message = document.createElement('p'); + message.style.cssText = styles.message; + message.textContent = config.message; + + // Create button + const button = document.createElement('button'); + button.style.cssText = styles.button; + button.textContent = config.buttonText; + + // Add event listener for button click + button.addEventListener('click', () => errorDiv.remove()); + + // Assemble the error div + errorDiv.appendChild(headerDiv); + errorDiv.appendChild(message); + errorDiv.appendChild(button); + + document.body.appendChild(errorDiv); +}; + +const showDatasetTooSmallError = (dataCount: number) => { + createErrorNotification({ + background: 'linear-gradient(135deg, #ff6b6b, #ee5a52)', + title: 'Not Enough Data', + message: `We found ${dataCount} tabs, but need more to create a meaningful space (recommended: ~70-100).`, + buttonText: 'Got it' + }); +}; + +const showNoTabsError = () => { + createErrorNotification({ + background: 'linear-gradient(135deg, #ff9500, #ff6b35)', + title: 'No Tabs Found', + message: 'Unable to gather enough tab information. Please ensure the extension has permissions and that you have at least 3 tabs open.', + buttonText: 'OK' + }); +}; + +const injectUI = async ( + space_id: string, + onMessage: onMessageType, + registerListeners: registerListenersType, + tabsMap?: Map +) => { + // If no tabsMap provided (non-ChromeTabs connection), return early + if (!tabsMap) return null; + + // Create a floating button container + const buttonContainer = document.createElement('div'); + buttonContainer.style.cssText = ` + position: fixed; + bottom: 24px; + right: 24px; + z-index: 10000; + `; + + // Create the organize button + const organizeButton = document.createElement('button'); + organizeButton.style.cssText = ` + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border: none; + padding: 14px 24px; + border-radius: 28px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + transition: all 0.3s ease; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + display: flex; + align-items: center; + gap: 8px; + `; + + organizeButton.innerHTML = ` + 📁 + Organize Tabs by Clusters + `; + + // Hover effects + organizeButton.addEventListener('mouseenter', () => { + organizeButton.style.transform = 'translateY(-2px)'; + organizeButton.style.boxShadow = '0 6px 20px rgba(102, 126, 234, 0.5)'; + }); + + organizeButton.addEventListener('mouseleave', () => { + organizeButton.style.transform = 'translateY(0)'; + organizeButton.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.4)'; + }); + + // Click handler + let isOrganizing = false; + organizeButton.addEventListener('click', async () => { + if (isOrganizing) return; + + isOrganizing = true; + organizeButton.disabled = true; + organizeButton.innerHTML = ` + + Organizing... + `; + + try { + await organizeTabsIntoGroups(space_id, tabsMap); + + // Success state + organizeButton.style.background = 'linear-gradient(135deg, #11998e 0%, #38ef7d 100%)'; + organizeButton.innerHTML = ` + + Tabs Organized! + `; + + // Hide button after success + setTimeout(() => { + buttonContainer.style.opacity = '0'; + buttonContainer.style.transition = 'opacity 0.5s ease'; + setTimeout(() => buttonContainer.remove(), 500); + }, 2000); + + } catch (error) { + console.error('Failed to organize tabs:', error); + + // Error state + organizeButton.style.background = 'linear-gradient(135deg, #ee0979 0%, #ff6a00 100%)'; + organizeButton.innerHTML = ` + ⚠️ + Failed to Organize + `; + + setTimeout(() => { + isOrganizing = false; + organizeButton.disabled = false; + organizeButton.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'; + organizeButton.innerHTML = ` + 📁 + Organize Tabs by Clusters + `; + }, 3000); + } + }); + + buttonContainer.appendChild(organizeButton); + document.body.appendChild(buttonContainer); + + return buttonContainer; +} + +export const ChromeTabsConnection: MantisConnection = { + name: "Chrome Tabs", + description: "Analyzes all your currently open browser tabs", + icon: chromeIcon, + trigger: trigger, + createSpace: createSpace, + injectUI: injectUI, +} \ No newline at end of file