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