ContextKeeper v1.0.0 — Persistent AI coding session context Chrome extension
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.verdict
|
||||
.venv/
|
||||
.DS_Store
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "ContextKeeper — Stateful AI Context",
|
||||
"version": "1.0.0",
|
||||
"description": "Capture, persist, and restore AI coding session context across sessions. Never repeat yourself to an AI again.",
|
||||
"permissions": ["storage", "activeTab", "scripting"],
|
||||
"host_permissions": [
|
||||
"https://chat.openai.com/*",
|
||||
"https://chatgpt.com/*",
|
||||
"https://claude.ai/*",
|
||||
"https://cursor.sh/*",
|
||||
"https://aistudio.google.com/*",
|
||||
"https://gemini.google.com/*",
|
||||
"https://copilot.microsoft.com/*",
|
||||
"https://github.com/copilot/*"
|
||||
],
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "src/background.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://chat.openai.com/*",
|
||||
"https://chatgpt.com/*",
|
||||
"https://claude.ai/*",
|
||||
"https://cursor.sh/*",
|
||||
"https://aistudio.google.com/*",
|
||||
"https://gemini.google.com/*",
|
||||
"https://copilot.microsoft.com/*",
|
||||
"https://github.com/copilot/*"
|
||||
],
|
||||
"js": ["src/content.ts"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
}
|
||||
Generated
+2429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "stateful-ai-agent-gap",
|
||||
"version": "1.0.0",
|
||||
"description": "Persistent context layer for AI coding tools — never lose your session context again",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||
"@types/chrome": "^0.0.270",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const fs = require('fs');
|
||||
const zlib = require('zlib');
|
||||
|
||||
function createPNG(width, height, r, g, b) {
|
||||
const lineSize = width * 3 + 1;
|
||||
const rawSize = height * lineSize;
|
||||
const raw = Buffer.alloc(rawSize);
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
raw[y * lineSize] = 0;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const offset = y * lineSize + 1 + x * 3;
|
||||
raw[offset] = r;
|
||||
raw[offset + 1] = g;
|
||||
raw[offset + 2] = b;
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = zlib.deflateSync(raw);
|
||||
|
||||
function chunk(type, data) {
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = Buffer.alloc(4);
|
||||
const crcData = Buffer.concat([typeBuf, data]);
|
||||
crc.writeUInt32BE(zlib.crc32(crcData), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crc]);
|
||||
}
|
||||
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 2;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||
const ihdrChunk = chunk('IHDR', ihdr);
|
||||
const idatChunk = chunk('IDAT', compressed);
|
||||
const iendChunk = chunk('IEND', Buffer.alloc(0));
|
||||
|
||||
return Buffer.concat([sig, ihdrChunk, idatChunk, iendChunk]);
|
||||
}
|
||||
|
||||
fs.mkdirSync('icons', { recursive: true });
|
||||
fs.writeFileSync('icons/icon16.png', createPNG(16, 16, 16, 185, 129));
|
||||
fs.writeFileSync('icons/icon48.png', createPNG(48, 48, 16, 185, 129));
|
||||
fs.writeFileSync('icons/icon128.png', createPNG(128, 128, 16, 185, 129));
|
||||
console.log('Icons generated');
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Background Service Worker
|
||||
* Handles context capture/restore messaging and badge updates
|
||||
*/
|
||||
const PLATFORM_PATTERNS = {
|
||||
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
|
||||
claude: /claude\.ai/i,
|
||||
cursor: /cursor\.sh/i,
|
||||
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
|
||||
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
|
||||
};
|
||||
function detectPlatform(url) {
|
||||
for (const [name, pattern] of Object.entries(PLATFORM_PATTERNS)) {
|
||||
if (pattern.test(url))
|
||||
return name;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
async function getSessions() {
|
||||
const result = await chrome.storage.local.get('sessions');
|
||||
return result.sessions || [];
|
||||
}
|
||||
async function saveSessions(sessions) {
|
||||
await chrome.storage.local.set({ sessions });
|
||||
}
|
||||
async function updateBadge(tabId, hasContext) {
|
||||
const text = hasContext ? '✓' : '';
|
||||
const color = hasContext ? '#10b981' : '#6b7280';
|
||||
await chrome.action.setBadgeText({ text, tabId });
|
||||
await chrome.action.setBadgeBackgroundColor({ color, tabId });
|
||||
}
|
||||
// Message handlers
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (message.type === 'CAPTURE_CONTEXT') {
|
||||
const { title, url, context } = message.payload;
|
||||
const platform = detectPlatform(url);
|
||||
const sessions = await getSessions();
|
||||
const newSession = {
|
||||
id: crypto.randomUUID(),
|
||||
title: title || 'Untitled Session',
|
||||
url,
|
||||
platform,
|
||||
context,
|
||||
timestamp: Date.now(),
|
||||
charCount: context.length,
|
||||
};
|
||||
// Deduplicate by URL+title — keep newest
|
||||
const filtered = sessions.filter((s) => !(s.url === url && s.title === title));
|
||||
filtered.unshift(newSession);
|
||||
// Keep last 50 sessions
|
||||
const trimmed = filtered.slice(0, 50);
|
||||
await saveSessions(trimmed);
|
||||
if (sender.tab?.id) {
|
||||
await updateBadge(sender.tab.id, true);
|
||||
}
|
||||
sendResponse({ success: true, id: newSession.id, total: trimmed.length });
|
||||
}
|
||||
if (message.type === 'GET_CONTEXT') {
|
||||
const sessions = await getSessions();
|
||||
const { url, title } = message.payload;
|
||||
const match = sessions.find((s) => s.url === url && s.title === title);
|
||||
sendResponse({ success: true, found: !!match, context: match?.context || null });
|
||||
}
|
||||
if (message.type === 'LIST_SESSIONS') {
|
||||
const sessions = await getSessions();
|
||||
sendResponse({ success: true, sessions });
|
||||
}
|
||||
if (message.type === 'DELETE_SESSION') {
|
||||
const sessions = await getSessions();
|
||||
const filtered = sessions.filter((s) => s.id !== message.payload.id);
|
||||
await saveSessions(filtered);
|
||||
sendResponse({ success: true, total: filtered.length });
|
||||
}
|
||||
if (message.type === 'RESTORE_CONTEXT') {
|
||||
// Content script will handle actual injection; background just confirms
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
if (message.type === 'CLEAR_ALL') {
|
||||
await chrome.storage.local.remove('sessions');
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
})();
|
||||
return true; // Keep channel open for async
|
||||
});
|
||||
// Update badge on tab change
|
||||
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!tab.url)
|
||||
return;
|
||||
const platform = detectPlatform(tab.url);
|
||||
if (platform === 'unknown') {
|
||||
await chrome.action.setBadgeText({ text: '', tabId });
|
||||
return;
|
||||
}
|
||||
const sessions = await getSessions();
|
||||
const hasContext = sessions.some((s) => s.url === tab.url);
|
||||
await updateBadge(tabId, hasContext);
|
||||
}
|
||||
catch {
|
||||
// Tab may have closed
|
||||
}
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, _changeInfo, tab) => {
|
||||
if (!tab.url || !tab.active)
|
||||
return;
|
||||
const platform = detectPlatform(tab.url);
|
||||
if (platform === 'unknown') {
|
||||
await chrome.action.setBadgeText({ text: '', tabId });
|
||||
return;
|
||||
}
|
||||
const sessions = await getSessions();
|
||||
const hasContext = sessions.some((s) => s.url === tab.url);
|
||||
await updateBadge(tabId, hasContext);
|
||||
});
|
||||
// Export for testing
|
||||
export { detectPlatform, getSessions, saveSessions, updateBadge };
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Background Service Worker
|
||||
* Handles context capture/restore messaging and badge updates
|
||||
*/
|
||||
|
||||
interface SavedSession {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
platform: string;
|
||||
context: string;
|
||||
timestamp: number;
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
const PLATFORM_PATTERNS: Record<string, RegExp> = {
|
||||
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
|
||||
claude: /claude\.ai/i,
|
||||
cursor: /cursor\.sh/i,
|
||||
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
|
||||
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
|
||||
};
|
||||
|
||||
function detectPlatform(url: string): string {
|
||||
for (const [name, pattern] of Object.entries(PLATFORM_PATTERNS)) {
|
||||
if (pattern.test(url)) return name;
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
async function getSessions(): Promise<SavedSession[]> {
|
||||
const result = await chrome.storage.local.get('sessions');
|
||||
return (result.sessions as SavedSession[]) || [];
|
||||
}
|
||||
|
||||
async function saveSessions(sessions: SavedSession[]): Promise<void> {
|
||||
await chrome.storage.local.set({ sessions });
|
||||
}
|
||||
|
||||
async function updateBadge(tabId: number, hasContext: boolean): Promise<void> {
|
||||
const text = hasContext ? '✓' : '';
|
||||
const color = hasContext ? '#10b981' : '#6b7280';
|
||||
await chrome.action.setBadgeText({ text, tabId });
|
||||
await chrome.action.setBadgeBackgroundColor({ color, tabId });
|
||||
}
|
||||
|
||||
// Message handlers
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
(async () => {
|
||||
if (message.type === 'CAPTURE_CONTEXT') {
|
||||
const { title, url, context } = message.payload;
|
||||
const platform = detectPlatform(url);
|
||||
const sessions = await getSessions();
|
||||
|
||||
const newSession: SavedSession = {
|
||||
id: crypto.randomUUID(),
|
||||
title: title || 'Untitled Session',
|
||||
url,
|
||||
platform,
|
||||
context,
|
||||
timestamp: Date.now(),
|
||||
charCount: context.length,
|
||||
};
|
||||
|
||||
// Deduplicate by URL+title — keep newest
|
||||
const filtered = sessions.filter(
|
||||
(s) => !(s.url === url && s.title === title)
|
||||
);
|
||||
filtered.unshift(newSession);
|
||||
|
||||
// Keep last 50 sessions
|
||||
const trimmed = filtered.slice(0, 50);
|
||||
await saveSessions(trimmed);
|
||||
|
||||
if (sender.tab?.id) {
|
||||
await updateBadge(sender.tab.id, true);
|
||||
}
|
||||
|
||||
sendResponse({ success: true, id: newSession.id, total: trimmed.length });
|
||||
}
|
||||
|
||||
if (message.type === 'GET_CONTEXT') {
|
||||
const sessions = await getSessions();
|
||||
const { url, title } = message.payload;
|
||||
const match = sessions.find(
|
||||
(s) => s.url === url && s.title === title
|
||||
);
|
||||
sendResponse({ success: true, found: !!match, context: match?.context || null });
|
||||
}
|
||||
|
||||
if (message.type === 'LIST_SESSIONS') {
|
||||
const sessions = await getSessions();
|
||||
sendResponse({ success: true, sessions });
|
||||
}
|
||||
|
||||
if (message.type === 'DELETE_SESSION') {
|
||||
const sessions = await getSessions();
|
||||
const filtered = sessions.filter((s) => s.id !== message.payload.id);
|
||||
await saveSessions(filtered);
|
||||
sendResponse({ success: true, total: filtered.length });
|
||||
}
|
||||
|
||||
if (message.type === 'RESTORE_CONTEXT') {
|
||||
// Content script will handle actual injection; background just confirms
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
|
||||
if (message.type === 'CLEAR_ALL') {
|
||||
await chrome.storage.local.remove('sessions');
|
||||
sendResponse({ success: true });
|
||||
}
|
||||
})();
|
||||
|
||||
return true; // Keep channel open for async
|
||||
});
|
||||
|
||||
// Update badge on tab change
|
||||
chrome.tabs.onActivated.addListener(async ({ tabId }) => {
|
||||
try {
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
if (!tab.url) return;
|
||||
const platform = detectPlatform(tab.url);
|
||||
if (platform === 'unknown') {
|
||||
await chrome.action.setBadgeText({ text: '', tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = await getSessions();
|
||||
const hasContext = sessions.some((s) => s.url === tab.url);
|
||||
await updateBadge(tabId, hasContext);
|
||||
} catch {
|
||||
// Tab may have closed
|
||||
}
|
||||
});
|
||||
|
||||
chrome.tabs.onUpdated.addListener(async (tabId, _changeInfo, tab) => {
|
||||
if (!tab.url || !tab.active) return;
|
||||
const platform = detectPlatform(tab.url);
|
||||
if (platform === 'unknown') {
|
||||
await chrome.action.setBadgeText({ text: '', tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
const sessions = await getSessions();
|
||||
const hasContext = sessions.some((s) => s.url === tab.url);
|
||||
await updateBadge(tabId, hasContext);
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
export { detectPlatform, getSessions, saveSessions, updateBadge };
|
||||
export type { SavedSession };
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Content Script — Injected into AI tool pages
|
||||
* Captures conversation context and can restore it on request
|
||||
*/
|
||||
import { PlatformDetector } from './lib/detector';
|
||||
import { ContextExtractor } from './lib/extractor';
|
||||
const detector = new PlatformDetector();
|
||||
const extractor = new ContextExtractor();
|
||||
let captureInterval = null;
|
||||
function getPageUrl() {
|
||||
return window.location.href.split('?')[0];
|
||||
}
|
||||
function getPageTitle() {
|
||||
return document.title;
|
||||
}
|
||||
async function captureContext() {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform)
|
||||
return;
|
||||
const context = extractor.extract(platform);
|
||||
if (!context || context.trim().length < 50)
|
||||
return; // Skip empty/short context
|
||||
try {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'CAPTURE_CONTEXT',
|
||||
payload: {
|
||||
title: getPageTitle(),
|
||||
url: getPageUrl(),
|
||||
context,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Extension may be reloading
|
||||
}
|
||||
}
|
||||
function checkForRestorableContext() {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform)
|
||||
return;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'GET_CONTEXT',
|
||||
payload: {
|
||||
title: getPageTitle(),
|
||||
url: getPageUrl(),
|
||||
},
|
||||
}, (response) => {
|
||||
if (response?.found && response.context) {
|
||||
showRestoreNotification(response.context);
|
||||
}
|
||||
});
|
||||
}
|
||||
function showRestoreNotification(context) {
|
||||
// Remove existing notification
|
||||
const existing = document.getElementById('contextkeeper-restore');
|
||||
if (existing)
|
||||
existing.remove();
|
||||
const el = document.createElement('div');
|
||||
el.id = 'contextkeeper-restore';
|
||||
el.innerHTML = `
|
||||
<div style="
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 999999;
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 360px;
|
||||
border: 1px solid #374151;
|
||||
animation: ck-slide-in 0.3s ease-out;
|
||||
">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">
|
||||
🧠 ContextKeeper
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #d1d5db; margin-bottom: 12px; line-height: 1.5;">
|
||||
Saved context found for this conversation (${context.length.toLocaleString()} chars). Restore it?
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="ck-restore-yes" style="
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
">Restore</button>
|
||||
<button id="ck-restore-no" style="
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes ck-slide-in {
|
||||
from { transform: translateX(120%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
document.body.appendChild(el);
|
||||
el.querySelector('#ck-restore-yes')?.addEventListener('click', () => {
|
||||
restoreContextToPage(context);
|
||||
el.remove();
|
||||
});
|
||||
el.querySelector('#ck-restore-no')?.addEventListener('click', () => {
|
||||
el.remove();
|
||||
});
|
||||
// Auto-dismiss after 30 seconds
|
||||
setTimeout(() => el.remove(), 30000);
|
||||
}
|
||||
function restoreContextToPage(context) {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform)
|
||||
return;
|
||||
// Try to paste into the input field
|
||||
const inputSelectors = {
|
||||
chatgpt: ['#prompt-textarea', 'textarea[placeholder*="Message"]', 'textarea[data-id="root"]', 'div[contenteditable="true"]'],
|
||||
claude: ['div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'div[role="textbox"]'],
|
||||
cursor: ['textarea', 'div[contenteditable="true"]'],
|
||||
gemini: ['textarea[placeholder*="Ask"]', 'div[contenteditable="true"]'],
|
||||
copilot: ['textarea', 'div[contenteditable="true"]'],
|
||||
};
|
||||
const selectors = inputSelectors[platform] || ['textarea', 'div[contenteditable="true"]'];
|
||||
for (const selector of selectors) {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el)
|
||||
continue;
|
||||
if (el instanceof HTMLTextAreaElement) {
|
||||
el.value = context;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.focus();
|
||||
}
|
||||
else if (el.isContentEditable) {
|
||||
el.textContent = context;
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
el.focus();
|
||||
}
|
||||
break; // Stop after first successful injection
|
||||
}
|
||||
}
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.type === 'REQUEST_CAPTURE') {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) {
|
||||
sendResponse({ success: false, reason: 'not_ai_platform' });
|
||||
return true;
|
||||
}
|
||||
const context = extractor.extract(platform);
|
||||
if (!context || context.length < 50) {
|
||||
sendResponse({ success: false, reason: 'no_content' });
|
||||
return true;
|
||||
}
|
||||
captureContext().then(() => {
|
||||
sendResponse({ success: true, charCount: context.length });
|
||||
}).catch(() => {
|
||||
sendResponse({ success: false, reason: 'capture_failed' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (message.type === 'INJECT_CONTEXT') {
|
||||
const { context } = message.payload;
|
||||
restoreContextToPage(context);
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
}
|
||||
else {
|
||||
init();
|
||||
}
|
||||
function init() {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform)
|
||||
return;
|
||||
// Check for restorable context on page load
|
||||
setTimeout(checkForRestorableContext, 2000);
|
||||
// Periodic capture every 30 seconds (only if content changed)
|
||||
let lastContext = '';
|
||||
captureInterval = setInterval(() => {
|
||||
const current = extractor.extract(platform);
|
||||
if (current && current !== lastContext && current.length > 50) {
|
||||
lastContext = current;
|
||||
captureContext();
|
||||
}
|
||||
}, 30000);
|
||||
// Capture before page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
captureContext();
|
||||
});
|
||||
}
|
||||
// Cleanup on navigation (SPA navigation)
|
||||
let currentUrl = window.location.href;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href;
|
||||
if (captureInterval) {
|
||||
clearInterval(captureInterval);
|
||||
captureInterval = null;
|
||||
}
|
||||
init();
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Content Script — Injected into AI tool pages
|
||||
* Captures conversation context and can restore it on request
|
||||
*/
|
||||
|
||||
import { PlatformDetector } from './lib/detector';
|
||||
import { ContextExtractor } from './lib/extractor';
|
||||
|
||||
const detector = new PlatformDetector();
|
||||
const extractor = new ContextExtractor();
|
||||
|
||||
let captureInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function getPageUrl(): string {
|
||||
return window.location.href.split('?')[0];
|
||||
}
|
||||
|
||||
function getPageTitle(): string {
|
||||
return document.title;
|
||||
}
|
||||
|
||||
async function captureContext(): Promise<void> {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) return;
|
||||
|
||||
const context = extractor.extract(platform);
|
||||
if (!context || context.trim().length < 50) return; // Skip empty/short context
|
||||
|
||||
try {
|
||||
await chrome.runtime.sendMessage({
|
||||
type: 'CAPTURE_CONTEXT',
|
||||
payload: {
|
||||
title: getPageTitle(),
|
||||
url: getPageUrl(),
|
||||
context,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Extension may be reloading
|
||||
}
|
||||
}
|
||||
|
||||
function checkForRestorableContext(): void {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) return;
|
||||
|
||||
chrome.runtime.sendMessage(
|
||||
{
|
||||
type: 'GET_CONTEXT',
|
||||
payload: {
|
||||
title: getPageTitle(),
|
||||
url: getPageUrl(),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
if (response?.found && response.context) {
|
||||
showRestoreNotification(response.context);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function showRestoreNotification(context: string): void {
|
||||
// Remove existing notification
|
||||
const existing = document.getElementById('contextkeeper-restore');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.id = 'contextkeeper-restore';
|
||||
el.innerHTML = `
|
||||
<div style="
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 999999;
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
padding: 16px 20px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 360px;
|
||||
border: 1px solid #374151;
|
||||
animation: ck-slide-in 0.3s ease-out;
|
||||
">
|
||||
<div style="font-weight: 600; margin-bottom: 8px; font-size: 14px;">
|
||||
🧠 ContextKeeper
|
||||
</div>
|
||||
<div style="font-size: 13px; color: #d1d5db; margin-bottom: 12px; line-height: 1.5;">
|
||||
Saved context found for this conversation (${context.length.toLocaleString()} chars). Restore it?
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button id="ck-restore-yes" style="
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
">Restore</button>
|
||||
<button id="ck-restore-no" style="
|
||||
background: #374151;
|
||||
color: #d1d5db;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
@keyframes ck-slide-in {
|
||||
from { transform: translateX(120%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.appendChild(el);
|
||||
|
||||
el.querySelector('#ck-restore-yes')?.addEventListener('click', () => {
|
||||
restoreContextToPage(context);
|
||||
el.remove();
|
||||
});
|
||||
|
||||
el.querySelector('#ck-restore-no')?.addEventListener('click', () => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Auto-dismiss after 30 seconds
|
||||
setTimeout(() => el.remove(), 30000);
|
||||
}
|
||||
|
||||
function restoreContextToPage(context: string): void {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) return;
|
||||
|
||||
// Try to paste into the input field
|
||||
const inputSelectors: Record<string, string[]> = {
|
||||
chatgpt: ['#prompt-textarea', 'textarea[placeholder*="Message"]', 'textarea[data-id="root"]', 'div[contenteditable="true"]'],
|
||||
claude: ['div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'div[role="textbox"]'],
|
||||
cursor: ['textarea', 'div[contenteditable="true"]'],
|
||||
gemini: ['textarea[placeholder*="Ask"]', 'div[contenteditable="true"]'],
|
||||
copilot: ['textarea', 'div[contenteditable="true"]'],
|
||||
};
|
||||
|
||||
const selectors = inputSelectors[platform] || ['textarea', 'div[contenteditable="true"]'];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const el = document.querySelector(selector) as HTMLElement;
|
||||
if (!el) continue;
|
||||
|
||||
if (el instanceof HTMLTextAreaElement) {
|
||||
el.value = context;
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
el.focus();
|
||||
} else if (el.isContentEditable) {
|
||||
el.textContent = context;
|
||||
el.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
el.focus();
|
||||
}
|
||||
|
||||
break; // Stop after first successful injection
|
||||
}
|
||||
}
|
||||
|
||||
// Handle messages from popup
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
if (message.type === 'REQUEST_CAPTURE') {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) {
|
||||
sendResponse({ success: false, reason: 'not_ai_platform' });
|
||||
return true;
|
||||
}
|
||||
const context = extractor.extract(platform);
|
||||
if (!context || context.length < 50) {
|
||||
sendResponse({ success: false, reason: 'no_content' });
|
||||
return true;
|
||||
}
|
||||
captureContext().then(() => {
|
||||
sendResponse({ success: true, charCount: context.length });
|
||||
}).catch(() => {
|
||||
sendResponse({ success: false, reason: 'capture_failed' });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'INJECT_CONTEXT') {
|
||||
const { context } = message.payload;
|
||||
restoreContextToPage(context);
|
||||
sendResponse({ success: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Initialize
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function init(): void {
|
||||
const platform = detector.detect(window.location.href);
|
||||
if (!platform) return;
|
||||
|
||||
// Check for restorable context on page load
|
||||
setTimeout(checkForRestorableContext, 2000);
|
||||
|
||||
// Periodic capture every 30 seconds (only if content changed)
|
||||
let lastContext = '';
|
||||
captureInterval = setInterval(() => {
|
||||
const current = extractor.extract(platform);
|
||||
if (current && current !== lastContext && current.length > 50) {
|
||||
lastContext = current;
|
||||
captureContext();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Capture before page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
captureContext();
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup on navigation (SPA navigation)
|
||||
let currentUrl = window.location.href;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (window.location.href !== currentUrl) {
|
||||
currentUrl = window.location.href;
|
||||
if (captureInterval) {
|
||||
clearInterval(captureInterval);
|
||||
captureInterval = null;
|
||||
}
|
||||
init();
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Platform Detector — Identifies which AI tool the user is on
|
||||
*/
|
||||
export class PlatformDetector {
|
||||
patterns = {
|
||||
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
|
||||
claude: /claude\.ai/i,
|
||||
cursor: /cursor\.sh/i,
|
||||
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
|
||||
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
|
||||
};
|
||||
detect(url) {
|
||||
for (const [name, pattern] of Object.entries(this.patterns)) {
|
||||
if (pattern.test(url))
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
getPlatformName(platform) {
|
||||
const names = {
|
||||
chatgpt: 'ChatGPT',
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'GitHub Copilot',
|
||||
};
|
||||
return names[platform] || platform;
|
||||
}
|
||||
getSupportedPlatforms() {
|
||||
return Object.keys(this.patterns);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Platform Detector — Identifies which AI tool the user is on
|
||||
*/
|
||||
export class PlatformDetector {
|
||||
private readonly patterns: Record<string, RegExp> = {
|
||||
chatgpt: /chatgpt\.com|chat\.openai\.com/i,
|
||||
claude: /claude\.ai/i,
|
||||
cursor: /cursor\.sh/i,
|
||||
gemini: /gemini\.google\.com|aistudio\.google\.com/i,
|
||||
copilot: /copilot\.microsoft\.com|github\.com\/copilot/i,
|
||||
};
|
||||
|
||||
detect(url: string): string | null {
|
||||
for (const [name, pattern] of Object.entries(this.patterns)) {
|
||||
if (pattern.test(url)) return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getPlatformName(platform: string): string {
|
||||
const names: Record<string, string> = {
|
||||
chatgpt: 'ChatGPT',
|
||||
claude: 'Claude',
|
||||
cursor: 'Cursor',
|
||||
gemini: 'Gemini',
|
||||
copilot: 'GitHub Copilot',
|
||||
};
|
||||
return names[platform] || platform;
|
||||
}
|
||||
|
||||
getSupportedPlatforms(): string[] {
|
||||
return Object.keys(this.patterns);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Context Extractor — Pulls conversation context from AI tool DOMs
|
||||
*/
|
||||
export class ContextExtractor {
|
||||
extract(platform) {
|
||||
switch (platform) {
|
||||
case 'chatgpt':
|
||||
return this.extractChatGPT();
|
||||
case 'claude':
|
||||
return this.extractClaude();
|
||||
case 'cursor':
|
||||
return this.extractCursor();
|
||||
case 'gemini':
|
||||
return this.extractGemini();
|
||||
case 'copilot':
|
||||
return this.extractCopilot();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
extractChatGPT() {
|
||||
// ChatGPT: conversation turns are in [data-testid^="conversation-turn-"]
|
||||
const turns = document.querySelectorAll('[data-testid^="conversation-turn-"]');
|
||||
if (turns.length === 0) {
|
||||
// Fallback: look for article elements or message divs
|
||||
const articles = document.querySelectorAll('article');
|
||||
return this.extractFromElements(articles, 'user', 'assistant');
|
||||
}
|
||||
return this.extractFromElements(turns, 'user', 'assistant');
|
||||
}
|
||||
extractClaude() {
|
||||
// Claude: messages in .message or [data-testid="message"]
|
||||
const messages = document.querySelectorAll('.message, [data-testid="message"]');
|
||||
if (messages.length === 0) {
|
||||
// Try content blocks
|
||||
const contents = document.querySelectorAll('.prose, [data-testid="content"]');
|
||||
return this.extractFromElements(contents, 'human', 'assistant');
|
||||
}
|
||||
return this.extractFromElements(messages, 'human', 'assistant');
|
||||
}
|
||||
extractCursor() {
|
||||
// Cursor: chat messages in the sidebar
|
||||
const messages = document.querySelectorAll('.chat-message, .message-bubble');
|
||||
return this.extractFromElements(messages, 'user', 'assistant');
|
||||
}
|
||||
extractGemini() {
|
||||
// Gemini: message containers
|
||||
const messages = document.querySelectorAll('.message-content, [data-testid="message-content"]');
|
||||
return this.extractFromElements(messages, 'user', 'model');
|
||||
}
|
||||
extractCopilot() {
|
||||
// Copilot: chat turns
|
||||
const turns = document.querySelectorAll('.turn, .chat-turn');
|
||||
return this.extractFromElements(turns, 'user', 'assistant');
|
||||
}
|
||||
extractFromElements(elements, userLabel, assistantLabel) {
|
||||
if (elements.length === 0)
|
||||
return '';
|
||||
const parts = [];
|
||||
let turnIndex = 0;
|
||||
for (const el of Array.from(elements)) {
|
||||
const text = this.getTextContent(el).trim();
|
||||
if (!text || text.length < 3)
|
||||
continue;
|
||||
const role = turnIndex % 2 === 0 ? userLabel : assistantLabel;
|
||||
parts.push(`[${role}]: ${text}`);
|
||||
turnIndex++;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
getTextContent(el) {
|
||||
// Clone to avoid modifying live DOM
|
||||
const clone = el.cloneNode(true);
|
||||
// Remove code copy buttons, timestamps, etc.
|
||||
clone.querySelectorAll('button, .timestamp, .copy-button, [role="button"]').forEach((b) => b.remove());
|
||||
return clone.textContent || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Context Extractor — Pulls conversation context from AI tool DOMs
|
||||
*/
|
||||
export class ContextExtractor {
|
||||
extract(platform: string): string {
|
||||
switch (platform) {
|
||||
case 'chatgpt':
|
||||
return this.extractChatGPT();
|
||||
case 'claude':
|
||||
return this.extractClaude();
|
||||
case 'cursor':
|
||||
return this.extractCursor();
|
||||
case 'gemini':
|
||||
return this.extractGemini();
|
||||
case 'copilot':
|
||||
return this.extractCopilot();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private extractChatGPT(): string {
|
||||
// ChatGPT: conversation turns are in [data-testid^="conversation-turn-"]
|
||||
const turns = document.querySelectorAll('[data-testid^="conversation-turn-"]');
|
||||
if (turns.length === 0) {
|
||||
// Fallback: look for article elements or message divs
|
||||
const articles = document.querySelectorAll('article');
|
||||
return this.extractFromElements(articles, 'user', 'assistant');
|
||||
}
|
||||
return this.extractFromElements(turns, 'user', 'assistant');
|
||||
}
|
||||
|
||||
private extractClaude(): string {
|
||||
// Claude: messages in .message or [data-testid="message"]
|
||||
const messages = document.querySelectorAll('.message, [data-testid="message"]');
|
||||
if (messages.length === 0) {
|
||||
// Try content blocks
|
||||
const contents = document.querySelectorAll('.prose, [data-testid="content"]');
|
||||
return this.extractFromElements(contents, 'human', 'assistant');
|
||||
}
|
||||
return this.extractFromElements(messages, 'human', 'assistant');
|
||||
}
|
||||
|
||||
private extractCursor(): string {
|
||||
// Cursor: chat messages in the sidebar
|
||||
const messages = document.querySelectorAll('.chat-message, .message-bubble');
|
||||
return this.extractFromElements(messages, 'user', 'assistant');
|
||||
}
|
||||
|
||||
private extractGemini(): string {
|
||||
// Gemini: message containers
|
||||
const messages = document.querySelectorAll('.message-content, [data-testid="message-content"]');
|
||||
return this.extractFromElements(messages, 'user', 'model');
|
||||
}
|
||||
|
||||
private extractCopilot(): string {
|
||||
// Copilot: chat turns
|
||||
const turns = document.querySelectorAll('.turn, .chat-turn');
|
||||
return this.extractFromElements(turns, 'user', 'assistant');
|
||||
}
|
||||
|
||||
private extractFromElements(
|
||||
elements: NodeListOf<Element>,
|
||||
userLabel: string,
|
||||
assistantLabel: string
|
||||
): string {
|
||||
if (elements.length === 0) return '';
|
||||
|
||||
const parts: string[] = [];
|
||||
let turnIndex = 0;
|
||||
|
||||
for (const el of Array.from(elements)) {
|
||||
const text = this.getTextContent(el).trim();
|
||||
if (!text || text.length < 3) continue;
|
||||
|
||||
const role = turnIndex % 2 === 0 ? userLabel : assistantLabel;
|
||||
parts.push(`[${role}]: ${text}`);
|
||||
turnIndex++;
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
private getTextContent(el: Element): string {
|
||||
// Clone to avoid modifying live DOM
|
||||
const clone = el.cloneNode(true) as Element;
|
||||
// Remove code copy buttons, timestamps, etc.
|
||||
clone.querySelectorAll('button, .timestamp, .copy-button, [role="button"]').forEach((b) => b.remove());
|
||||
return clone.textContent || '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
width: 380px;
|
||||
min-height: 400px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 28px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1e293b;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.current-page {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.platform-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: #334155;
|
||||
color: #cbd5e1;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.platform-badge.chatgpt { background: #10a37f20; color: #10a37f; border: 1px solid #10a37f40; }
|
||||
.platform-badge.claude { background: #d9775720; color: #d97757; border: 1px solid #d9775740; }
|
||||
.platform-badge.cursor { background: #6366f120; color: #6366f1; border: 1px solid #6366f140; }
|
||||
.platform-badge.gemini { background: #8b5cf620; color: #8b5cf6; border: 1px solid #8b5cf640; }
|
||||
.platform-badge.copilot { background: #0ea5e920; color: #0ea5e9; border: 1px solid #0ea5e940; }
|
||||
|
||||
.page-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: #059669; }
|
||||
|
||||
.btn-secondary {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #475569;
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: #475569; }
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #334155;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.sessions-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sessions-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s;
|
||||
padding: 4px;
|
||||
}
|
||||
.btn-clear:hover { opacity: 1; }
|
||||
|
||||
.sessions-list {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.session-card:hover {
|
||||
border-color: #475569;
|
||||
background: #263348;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #f1f5f9;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.session-platform {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-delete {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.session-card:hover .session-delete { opacity: 1; }
|
||||
.session-delete:hover { color: #ef4444; background: #ef444420; }
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #64748b; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #10b981;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
animation: toast-in 0.2s ease-out;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.confirm-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
.confirm-box {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
width: 280px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f8fafc;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.confirm-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm-btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
.confirm-btn-danger:hover { background: #dc2626; }
|
||||
|
||||
.confirm-btn-cancel {
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.confirm-btn-cancel:hover { background: #475569; }
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>ContextKeeper</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="logo">🧠</div>
|
||||
<div>
|
||||
<div class="title">ContextKeeper</div>
|
||||
<div class="subtitle">Never lose your AI context</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="current-page" class="current-page">
|
||||
<div class="platform-badge" id="platform-badge">—</div>
|
||||
<div class="page-title" id="page-title">No AI tool detected</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" id="actions">
|
||||
<button id="btn-capture" class="btn btn-primary" disabled>
|
||||
💾 Capture Now
|
||||
</button>
|
||||
<button id="btn-restore" class="btn btn-secondary" disabled>
|
||||
🔄 Restore Context
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="sessions-header">
|
||||
<div class="sessions-title">Saved Sessions</div>
|
||||
<button id="btn-clear" class="btn-clear" title="Clear all sessions">🗑</button>
|
||||
</div>
|
||||
|
||||
<div id="sessions-list" class="sessions-list">
|
||||
<div class="empty-state">No saved sessions yet</div>
|
||||
</div>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<script src="popup.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Popup Controller — Manages the extension popup UI
|
||||
*/
|
||||
import { PlatformDetector } from '../lib/detector';
|
||||
const detector = new PlatformDetector();
|
||||
async function getCurrentTab() {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab || null;
|
||||
}
|
||||
function formatTimeAgo(timestamp) {
|
||||
const diff = Date.now() - timestamp;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (mins < 1)
|
||||
return 'just now';
|
||||
if (mins < 60)
|
||||
return `${mins}m ago`;
|
||||
if (hours < 24)
|
||||
return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
function formatChars(n) {
|
||||
if (n >= 1000)
|
||||
return `${(n / 1000).toFixed(1)}k chars`;
|
||||
return `${n} chars`;
|
||||
}
|
||||
function getPlatformColorClass(platform) {
|
||||
return platform.toLowerCase();
|
||||
}
|
||||
async function renderSessions() {
|
||||
const listEl = document.getElementById('sessions-list');
|
||||
const statsEl = document.getElementById('stats');
|
||||
const response = await chrome.runtime.sendMessage({ type: 'LIST_SESSIONS' });
|
||||
const sessions = response?.sessions || [];
|
||||
if (sessions.length === 0) {
|
||||
listEl.innerHTML = '<div class="empty-state">No saved sessions yet</div>';
|
||||
statsEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '';
|
||||
for (const session of sessions) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.innerHTML = `
|
||||
<div class="session-header">
|
||||
<div class="session-title" title="${escapeHtml(session.title)}">${escapeHtml(session.title)}</div>
|
||||
<div class="session-platform ${getPlatformColorClass(session.platform)}">${escapeHtml(session.platform)}</div>
|
||||
</div>
|
||||
<div class="session-meta">
|
||||
<span>${formatTimeAgo(session.timestamp)}</span>
|
||||
<span>${formatChars(session.charCount)}</span>
|
||||
</div>
|
||||
<button class="session-delete" data-id="${session.id}" title="Delete">×</button>
|
||||
`;
|
||||
card.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('session-delete'))
|
||||
return;
|
||||
restoreSession(session);
|
||||
});
|
||||
card.querySelector('.session-delete')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.id);
|
||||
});
|
||||
listEl.appendChild(card);
|
||||
}
|
||||
statsEl.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${formatChars(sessions.reduce((sum, s) => sum + s.charCount, 0))} total`;
|
||||
}
|
||||
async function restoreSession(session) {
|
||||
// Open the URL in the current or new tab
|
||||
const tab = await getCurrentTab();
|
||||
if (tab?.id) {
|
||||
await chrome.tabs.update(tab.id, { url: session.url });
|
||||
// After navigation, inject context via content script
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'INJECT_CONTEXT',
|
||||
payload: { context: session.context },
|
||||
});
|
||||
}
|
||||
catch {
|
||||
// Page may not be loaded yet
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
async function deleteSession(id) {
|
||||
await chrome.runtime.sendMessage({ type: 'DELETE_SESSION', payload: { id } });
|
||||
await renderSessions();
|
||||
showToast('Session deleted');
|
||||
}
|
||||
function showToast(message) {
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing)
|
||||
existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
function showConfirm(title, text, onConfirm) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-title">${escapeHtml(title)}</div>
|
||||
<div class="confirm-text">${escapeHtml(text)}</div>
|
||||
<div class="confirm-buttons">
|
||||
<button class="confirm-btn confirm-btn-danger" id="confirm-yes">Delete</button>
|
||||
<button class="confirm-btn confirm-btn-cancel" id="confirm-no">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector('#confirm-yes')?.addEventListener('click', () => {
|
||||
onConfirm();
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.querySelector('#confirm-no')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay)
|
||||
overlay.remove();
|
||||
});
|
||||
}
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
async function updateCurrentPage() {
|
||||
const badgeEl = document.getElementById('platform-badge');
|
||||
const titleEl = document.getElementById('page-title');
|
||||
const captureBtn = document.getElementById('btn-capture');
|
||||
const restoreBtn = document.getElementById('btn-restore');
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.url) {
|
||||
badgeEl.textContent = '—';
|
||||
titleEl.textContent = 'No active page';
|
||||
captureBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
const platform = detector.detect(tab.url);
|
||||
if (!platform) {
|
||||
badgeEl.textContent = '—';
|
||||
badgeEl.className = 'platform-badge';
|
||||
titleEl.textContent = 'No AI tool detected on this page';
|
||||
captureBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
badgeEl.textContent = detector.getPlatformName(platform);
|
||||
badgeEl.className = `platform-badge ${getPlatformColorClass(platform)}`;
|
||||
titleEl.textContent = tab.title || 'Untitled';
|
||||
captureBtn.disabled = false;
|
||||
restoreBtn.disabled = false;
|
||||
}
|
||||
async function captureCurrent() {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.id)
|
||||
return;
|
||||
try {
|
||||
const response = await chrome.tabs.sendMessage(tab.id, { type: 'REQUEST_CAPTURE' });
|
||||
if (response?.success) {
|
||||
showToast('Context captured!');
|
||||
await renderSessions();
|
||||
}
|
||||
else {
|
||||
showToast('Nothing to capture');
|
||||
}
|
||||
}
|
||||
catch {
|
||||
showToast('Refresh the page first');
|
||||
}
|
||||
}
|
||||
async function restoreCurrent() {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.id || !tab.url)
|
||||
return;
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_CONTEXT',
|
||||
payload: { title: tab.title || '', url: tab.url.split('?')[0] },
|
||||
});
|
||||
if (response?.found && response.context) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'INJECT_CONTEXT',
|
||||
payload: { context: response.context },
|
||||
});
|
||||
showToast('Context restored!');
|
||||
}
|
||||
catch {
|
||||
showToast('Refresh the page first');
|
||||
}
|
||||
}
|
||||
else {
|
||||
showToast('No saved context for this page');
|
||||
}
|
||||
}
|
||||
async function clearAll() {
|
||||
showConfirm('Clear All Sessions', 'This will permanently delete all saved context. Are you sure?', async () => {
|
||||
await chrome.runtime.sendMessage({ type: 'CLEAR_ALL' });
|
||||
await renderSessions();
|
||||
showToast('All sessions cleared');
|
||||
});
|
||||
}
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await updateCurrentPage();
|
||||
await renderSessions();
|
||||
document.getElementById('btn-capture')?.addEventListener('click', captureCurrent);
|
||||
document.getElementById('btn-restore')?.addEventListener('click', restoreCurrent);
|
||||
document.getElementById('btn-clear')?.addEventListener('click', clearAll);
|
||||
});
|
||||
export { renderSessions, updateCurrentPage, formatTimeAgo, formatChars };
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Popup Controller — Manages the extension popup UI
|
||||
*/
|
||||
|
||||
import { PlatformDetector } from '../lib/detector';
|
||||
|
||||
interface SavedSession {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
platform: string;
|
||||
context: string;
|
||||
timestamp: number;
|
||||
charCount: number;
|
||||
}
|
||||
|
||||
const detector = new PlatformDetector();
|
||||
|
||||
async function getCurrentTab(): Promise<chrome.tabs.Tab | null> {
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
return tab || null;
|
||||
}
|
||||
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const diff = Date.now() - timestamp;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function formatChars(n: number): string {
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k chars`;
|
||||
return `${n} chars`;
|
||||
}
|
||||
|
||||
function getPlatformColorClass(platform: string): string {
|
||||
return platform.toLowerCase();
|
||||
}
|
||||
|
||||
async function renderSessions(): Promise<void> {
|
||||
const listEl = document.getElementById('sessions-list')!;
|
||||
const statsEl = document.getElementById('stats')!;
|
||||
|
||||
const response = await chrome.runtime.sendMessage({ type: 'LIST_SESSIONS' });
|
||||
const sessions: SavedSession[] = response?.sessions || [];
|
||||
|
||||
if (sessions.length === 0) {
|
||||
listEl.innerHTML = '<div class="empty-state">No saved sessions yet</div>';
|
||||
statsEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
for (const session of sessions) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'session-card';
|
||||
card.innerHTML = `
|
||||
<div class="session-header">
|
||||
<div class="session-title" title="${escapeHtml(session.title)}">${escapeHtml(session.title)}</div>
|
||||
<div class="session-platform ${getPlatformColorClass(session.platform)}">${escapeHtml(session.platform)}</div>
|
||||
</div>
|
||||
<div class="session-meta">
|
||||
<span>${formatTimeAgo(session.timestamp)}</span>
|
||||
<span>${formatChars(session.charCount)}</span>
|
||||
</div>
|
||||
<button class="session-delete" data-id="${session.id}" title="Delete">×</button>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).classList.contains('session-delete')) return;
|
||||
restoreSession(session);
|
||||
});
|
||||
|
||||
card.querySelector('.session-delete')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
deleteSession(session.id);
|
||||
});
|
||||
|
||||
listEl.appendChild(card);
|
||||
}
|
||||
|
||||
statsEl.textContent = `${sessions.length} session${sessions.length === 1 ? '' : 's'} · ${formatChars(
|
||||
sessions.reduce((sum, s) => sum + s.charCount, 0)
|
||||
)} total`;
|
||||
}
|
||||
|
||||
async function restoreSession(session: SavedSession): Promise<void> {
|
||||
// Open the URL in the current or new tab
|
||||
const tab = await getCurrentTab();
|
||||
if (tab?.id) {
|
||||
await chrome.tabs.update(tab.id, { url: session.url });
|
||||
// After navigation, inject context via content script
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id!, {
|
||||
type: 'INJECT_CONTEXT',
|
||||
payload: { context: session.context },
|
||||
});
|
||||
} catch {
|
||||
// Page may not be loaded yet
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
async function deleteSession(id: string): Promise<void> {
|
||||
await chrome.runtime.sendMessage({ type: 'DELETE_SESSION', payload: { id } });
|
||||
await renderSessions();
|
||||
showToast('Session deleted');
|
||||
}
|
||||
|
||||
function showToast(message: string): void {
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 2000);
|
||||
}
|
||||
|
||||
function showConfirm(title: string, text: string, onConfirm: () => void): void {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="confirm-box">
|
||||
<div class="confirm-title">${escapeHtml(title)}</div>
|
||||
<div class="confirm-text">${escapeHtml(text)}</div>
|
||||
<div class="confirm-buttons">
|
||||
<button class="confirm-btn confirm-btn-danger" id="confirm-yes">Delete</button>
|
||||
<button class="confirm-btn confirm-btn-cancel" id="confirm-no">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
overlay.querySelector('#confirm-yes')?.addEventListener('click', () => {
|
||||
onConfirm();
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.querySelector('#confirm-no')?.addEventListener('click', () => {
|
||||
overlay.remove();
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
async function updateCurrentPage(): Promise<void> {
|
||||
const badgeEl = document.getElementById('platform-badge')!;
|
||||
const titleEl = document.getElementById('page-title')!;
|
||||
const captureBtn = document.getElementById('btn-capture') as HTMLButtonElement;
|
||||
const restoreBtn = document.getElementById('btn-restore') as HTMLButtonElement;
|
||||
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.url) {
|
||||
badgeEl.textContent = '—';
|
||||
titleEl.textContent = 'No active page';
|
||||
captureBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const platform = detector.detect(tab.url);
|
||||
if (!platform) {
|
||||
badgeEl.textContent = '—';
|
||||
badgeEl.className = 'platform-badge';
|
||||
titleEl.textContent = 'No AI tool detected on this page';
|
||||
captureBtn.disabled = true;
|
||||
restoreBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
badgeEl.textContent = detector.getPlatformName(platform);
|
||||
badgeEl.className = `platform-badge ${getPlatformColorClass(platform)}`;
|
||||
titleEl.textContent = tab.title || 'Untitled';
|
||||
captureBtn.disabled = false;
|
||||
restoreBtn.disabled = false;
|
||||
}
|
||||
|
||||
async function captureCurrent(): Promise<void> {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.id) return;
|
||||
|
||||
try {
|
||||
const response = await chrome.tabs.sendMessage(tab.id, { type: 'REQUEST_CAPTURE' });
|
||||
if (response?.success) {
|
||||
showToast('Context captured!');
|
||||
await renderSessions();
|
||||
} else {
|
||||
showToast('Nothing to capture');
|
||||
}
|
||||
} catch {
|
||||
showToast('Refresh the page first');
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreCurrent(): Promise<void> {
|
||||
const tab = await getCurrentTab();
|
||||
if (!tab?.id || !tab.url) return;
|
||||
|
||||
const response = await chrome.runtime.sendMessage({
|
||||
type: 'GET_CONTEXT',
|
||||
payload: { title: tab.title || '', url: tab.url.split('?')[0] },
|
||||
});
|
||||
|
||||
if (response?.found && response.context) {
|
||||
try {
|
||||
await chrome.tabs.sendMessage(tab.id, {
|
||||
type: 'INJECT_CONTEXT',
|
||||
payload: { context: response.context },
|
||||
});
|
||||
showToast('Context restored!');
|
||||
} catch {
|
||||
showToast('Refresh the page first');
|
||||
}
|
||||
} else {
|
||||
showToast('No saved context for this page');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAll(): Promise<void> {
|
||||
showConfirm('Clear All Sessions', 'This will permanently delete all saved context. Are you sure?', async () => {
|
||||
await chrome.runtime.sendMessage({ type: 'CLEAR_ALL' });
|
||||
await renderSessions();
|
||||
showToast('All sessions cleared');
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
await updateCurrentPage();
|
||||
await renderSessions();
|
||||
|
||||
document.getElementById('btn-capture')?.addEventListener('click', captureCurrent);
|
||||
document.getElementById('btn-restore')?.addEventListener('click', restoreCurrent);
|
||||
document.getElementById('btn-clear')?.addEventListener('click', clearAll);
|
||||
});
|
||||
|
||||
export { renderSessions, updateCurrentPage, formatTimeAgo, formatChars };
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PlatformDetector } from '../src/lib/detector';
|
||||
|
||||
describe('PlatformDetector', () => {
|
||||
const detector = new PlatformDetector();
|
||||
|
||||
it('detects ChatGPT URLs', () => {
|
||||
expect(detector.detect('https://chatgpt.com/c/123')).toBe('chatgpt');
|
||||
expect(detector.detect('https://chat.openai.com/chat')).toBe('chatgpt');
|
||||
});
|
||||
|
||||
it('detects Claude URLs', () => {
|
||||
expect(detector.detect('https://claude.ai/chat/abc')).toBe('claude');
|
||||
});
|
||||
|
||||
it('detects Cursor URLs', () => {
|
||||
expect(detector.detect('https://cursor.sh/chat')).toBe('cursor');
|
||||
});
|
||||
|
||||
it('detects Gemini URLs', () => {
|
||||
expect(detector.detect('https://gemini.google.com/app')).toBe('gemini');
|
||||
expect(detector.detect('https://aistudio.google.com/app')).toBe('gemini');
|
||||
});
|
||||
|
||||
it('detects Copilot URLs', () => {
|
||||
expect(detector.detect('https://copilot.microsoft.com/')).toBe('copilot');
|
||||
expect(detector.detect('https://github.com/copilot')).toBe('copilot');
|
||||
});
|
||||
|
||||
it('returns null for unrelated URLs', () => {
|
||||
expect(detector.detect('https://google.com')).toBeNull();
|
||||
expect(detector.detect('https://example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('maps platform keys to display names', () => {
|
||||
expect(detector.getPlatformName('chatgpt')).toBe('ChatGPT');
|
||||
expect(detector.getPlatformName('claude')).toBe('Claude');
|
||||
expect(detector.getPlatformName('unknown')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('lists all supported platforms', () => {
|
||||
const platforms = detector.getSupportedPlatforms();
|
||||
expect(platforms).toContain('chatgpt');
|
||||
expect(platforms).toContain('claude');
|
||||
expect(platforms).toContain('cursor');
|
||||
expect(platforms).toContain('gemini');
|
||||
expect(platforms).toContain('copilot');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ContextExtractor } from '../src/lib/extractor';
|
||||
|
||||
describe('ContextExtractor', () => {
|
||||
let extractor: ContextExtractor;
|
||||
|
||||
beforeEach(() => {
|
||||
extractor = new ContextExtractor();
|
||||
});
|
||||
|
||||
it('extracts nothing for unknown platforms', () => {
|
||||
expect(extractor.extract('unknown')).toBe('');
|
||||
});
|
||||
|
||||
it('extracts ChatGPT conversation turns', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-testid="conversation-turn-1">
|
||||
<div>Hello AI</div>
|
||||
</div>
|
||||
<div data-testid="conversation-turn-2">
|
||||
<div>Hi there!</div>
|
||||
</div>
|
||||
`;
|
||||
const result = extractor.extract('chatgpt');
|
||||
expect(result).toContain('[user]: Hello AI');
|
||||
expect(result).toContain('[assistant]: Hi there!');
|
||||
});
|
||||
|
||||
it('falls back to article elements for ChatGPT', () => {
|
||||
document.body.innerHTML = `
|
||||
<article>First message</article>
|
||||
<article>Second message</article>
|
||||
`;
|
||||
const result = extractor.extract('chatgpt');
|
||||
expect(result).toContain('[user]: First message');
|
||||
expect(result).toContain('[assistant]: Second message');
|
||||
});
|
||||
|
||||
it('extracts Claude messages', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="message">How do I refactor this?</div>
|
||||
<div class="message">Here is a suggestion...</div>
|
||||
`;
|
||||
const result = extractor.extract('claude');
|
||||
expect(result).toContain('[human]: How do I refactor this?');
|
||||
expect(result).toContain('[assistant]: Here is a suggestion...');
|
||||
});
|
||||
|
||||
it('extracts Cursor chat', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="chat-message">Fix this bug</div>
|
||||
<div class="chat-message">The issue is...</div>
|
||||
`;
|
||||
const result = extractor.extract('cursor');
|
||||
expect(result).toContain('[user]: Fix this bug');
|
||||
expect(result).toContain('[assistant]: The issue is...');
|
||||
});
|
||||
|
||||
it('removes buttons and timestamps from content', () => {
|
||||
document.body.innerHTML = `
|
||||
<article>
|
||||
Some code
|
||||
<button>Copy</button>
|
||||
<span class="timestamp">2:30 PM</span>
|
||||
</article>
|
||||
`;
|
||||
const result = extractor.extract('chatgpt');
|
||||
expect(result).toContain('Some code');
|
||||
expect(result).not.toContain('Copy');
|
||||
expect(result).not.toContain('2:30 PM');
|
||||
});
|
||||
|
||||
it('returns empty string when no elements found', () => {
|
||||
document.body.innerHTML = '<div>Empty page</div>';
|
||||
expect(extractor.extract('chatgpt')).toBe('');
|
||||
});
|
||||
|
||||
it('skips very short text', () => {
|
||||
document.body.innerHTML = `
|
||||
<article>Hi</article>
|
||||
<article></article>
|
||||
`;
|
||||
const result = extractor.extract('chatgpt');
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["chrome", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { crx } from '@crxjs/vite-plugin'
|
||||
import manifest from './manifest.json' assert { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [crx({ manifest })],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user