diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 374adb8..0000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,32 +0,0 @@ -# SQLite databases -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm - -# Daemon runtime files -daemon.lock -daemon.log -daemon.pid -bd.sock - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -# Legacy database files -db.sqlite -bd.db - -# Merge artifacts (temporary files from 3-way merge) -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# Keep JSONL exports and config (source of truth for git) -!issues.jsonl -!metadata.json -!config.json diff --git a/.beads/README.md b/.beads/README.md deleted file mode 100644 index 50f281f..0000000 --- a/.beads/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Beads - AI-Native Issue Tracking - -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. - -## What is Beads? - -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. - -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - -## Quick Start - -### Essential Commands - -```bash -# Create new issues -bd create "Add user authentication" - -# View all issues -bd list - -# View issue details -bd show - -# Update issue status -bd update --status in_progress -bd update --status done - -# Sync with git remote -bd sync -``` - -### Working with Issues - -Issues in Beads are: -- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code -- **AI-friendly**: CLI-first design works perfectly with AI coding agents -- **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits - -## Why Beads? - -✨ **AI-Native Design** -- Built specifically for AI-assisted development workflows -- CLI-first interface works seamlessly with AI coding agents -- No context switching to web UIs - -🚀 **Developer Focused** -- Issues live in your repo, right next to your code -- Works offline, syncs when you push -- Fast, lightweight, and stays out of your way - -🔧 **Git Integration** -- Automatic sync with git commits -- Branch-aware issue tracking -- Intelligent JSONL merge resolution - -## Get Started with Beads - -Try Beads in your own projects: - -```bash -# Install Beads -curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash - -# Initialize in your repo -bd init - -# Create your first issue -bd create "Try out Beads" -``` - -## Learn More - -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) -- **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) - ---- - -*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml deleted file mode 100644 index f242785..0000000 --- a/.beads/config.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: load from JSONL, no SQLite, write back after each command -# When true, bd will use .beads/issues.jsonl as the source of truth -# instead of SQLite database -# no-db: false - -# Disable daemon for RPC communication (forces direct database access) -# no-daemon: false - -# Disable auto-flush of database to JSONL after mutations -# no-auto-flush: false - -# Disable auto-import from JSONL when it's newer than database -# no-auto-import: false - -# Enable JSON output by default -# json: false - -# Default actor for audit trails (overridden by BD_ACTOR or --actor) -# actor: "" - -# Path to database (overridden by BEADS_DB or --db) -# db: "" - -# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) -# auto-start-daemon: true - -# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) -# flush-debounce: "5s" - -# Git branch for beads commits (bd sync will commit to this branch) -# IMPORTANT: Set this for team projects so all clones use the same sync branch. -# This setting persists across clones (unlike database config which is gitignored). -# Can also use BEADS_SYNC_BRANCH env var for local override. -# If not set, bd sync will require you to run 'bd config set sync.branch '. -# sync-branch: "beads-sync" - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct JSONL -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# Integration settings (access with 'bd config get/set') -# These are stored in the database, not in this file: -# - jira.url -# - jira.project -# - linear.url -# - linear.api-key -# - github.org -# - github.repo diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl deleted file mode 100644 index 00a5a9f..0000000 --- a/.beads/issues.jsonl +++ /dev/null @@ -1,15 +0,0 @@ -{"id":"light-session-16d","title":"Implement code review recommendations","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-08T15:51:21.70403199+03:00","updated_at":"2026-01-08T15:54:39.953267761+03:00","closed_at":"2026-01-08T15:54:39.953267761+03:00","close_reason":"Closed"} -{"id":"light-session-2hd","title":"Test cache + navigation trigger for load more feature","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-09T00:46:45.660702419+03:00","updated_at":"2026-01-09T10:45:29.927755814+03:00","closed_at":"2026-01-09T10:45:29.927759601+03:00"} -{"id":"light-session-3qq","title":"Set up CI for Chrome Web Store publishing","description":"Configure GitHub Actions workflow to build and publish extension to Chrome Web Store on release","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-09T22:58:36.99477884+03:00","created_by":"mayor","updated_at":"2026-01-09T23:01:15.306260186+03:00"} -{"id":"light-session-4ec","title":"Fix off-by-one error in turn counting - trimMapping","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:21:25.453075056+03:00","updated_at":"2026-01-07T21:23:59.887784624+03:00","closed_at":"2026-01-07T21:23:59.887784624+03:00","close_reason":"Closed"} -{"id":"light-session-67h","title":"Bug: trimming not working on page reload with extension enabled","description":"Settings show keep=5 but more messages visible. Race condition or localStorage sync issue.","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-01-09T22:39:11.800084105+03:00","created_by":"mayor","updated_at":"2026-01-09T22:40:24.864320247+03:00"} -{"id":"light-session-6sc","title":"Implement code review improvements","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:59:50.319980052+03:00","updated_at":"2026-01-07T23:06:39.948042561+03:00","closed_at":"2026-01-07T23:06:39.948042561+03:00","close_reason":"Closed"} -{"id":"light-session-7sq","title":"Rename 'turns' to 'messages' in UI","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-07T21:50:55.111515332+03:00","updated_at":"2026-01-07T21:53:42.151552035+03:00","closed_at":"2026-01-07T21:53:42.151552035+03:00","close_reason":"Closed"} -{"id":"light-session-8bq","title":"Fix 'Body has already been consumed' error in fetch interceptor","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:06:28.74686929+03:00","updated_at":"2026-01-07T22:07:29.911516018+03:00","closed_at":"2026-01-07T22:07:29.911516018+03:00","close_reason":"Fixed by extracting URL/method before nativeFetch"} -{"id":"light-session-by5","title":"Pre-render trimming: Phase 1 - content-visibility CSS","description":"Add content-visibility: auto to message selectors for browser-native render optimization","status":"closed","priority":2,"issue_type":"task","created_at":"2025-12-29T23:48:15.259222148+03:00","updated_at":"2025-12-29T23:52:02.636580724+03:00","closed_at":"2025-12-29T23:52:02.636580724+03:00","close_reason":"Closed"} -{"id":"light-session-e8t","title":"Implement sibling container injection for Load More","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-09T10:45:29.905580437+03:00","updated_at":"2026-01-09T10:46:42.770910413+03:00"} -{"id":"light-session-hqc","title":"Fix Firefox Xray vision bug - cloneInto for CustomEvent detail","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T21:09:56.751185775+03:00","updated_at":"2026-01-07T21:12:56.968676465+03:00","closed_at":"2026-01-07T21:12:56.968676465+03:00","close_reason":"Closed"} -{"id":"light-session-kf1","title":"Fix ESLint error for cloneInto Firefox API","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-08T19:23:38.808971016+03:00","updated_at":"2026-01-08T19:24:30.315635209+03:00","closed_at":"2026-01-08T19:24:30.315635209+03:00","close_reason":"Closed"} -{"id":"light-session-oho","title":"Fix: empty user nodes counted but not rendered","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-07T22:21:42.141200219+03:00","updated_at":"2026-01-07T22:44:23.70359928+03:00","closed_at":"2026-01-07T22:44:23.70359928+03:00","close_reason":"Fixed: preserve original root node as tree anchor for ChatGPT"} -{"id":"light-session-q3p","title":"Fix race condition: sync settings to localStorage for page-script","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-09T22:28:10.93947054+03:00","created_by":"mayor","updated_at":"2026-01-21T00:22:44.552517775+03:00","closed_at":"2026-01-21T00:22:44.552517775+03:00","close_reason":"Closed"} -{"id":"light-session-t31","title":"Fix status bar disappearing on chat navigation","status":"closed","priority":1,"issue_type":"task","owner":"limerc@proton.me","created_at":"2026-01-21T00:18:59.530384013+03:00","created_by":"limerc","updated_at":"2026-01-21T00:22:53.703823479+03:00","closed_at":"2026-01-21T00:22:53.703823479+03:00","close_reason":"Closed"} diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index c787975..0000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "database": "beads.db", - "jsonl_export": "issues.jsonl" -} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index df7a4af..aa472e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,40 +1,3 @@ # Agent Instructions -This project uses **bd** (beads) for issue tracking. Run `bd onboard` to get started. - -## Quick Reference - -```bash -bd ready # Find available work -bd show # View issue details -bd update --status in_progress # Claim work -bd close # Complete work -bd sync # Sync with git -``` - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up -2. **Run quality gates** (if code changed) - Tests, linters, builds -3. **Update issue status** - Close finished work, update in-progress items -4. **PUSH TO REMOTE** - This is MANDATORY: - ```bash - git pull --rebase - bd sync - git push - git status # MUST show "up to date with origin" - ``` -5. **Clean up** - Clear stashes, prune remote branches -6. **Verify** - All changes committed AND pushed -7. **Hand off** - Provide context for next session - -**CRITICAL RULES:** -- Work is NOT complete until `git push` succeeds -- NEVER stop before pushing - that leaves work stranded locally -- NEVER say "ready to push when you are" - YOU must push -- If push fails, resolve and retry until it succeeds - +No issue tracker configured. diff --git a/extension/manifest.chrome.json b/extension/manifest.chrome.json index 1a7ef51..d3e9ae9 100644 --- a/extension/manifest.chrome.json +++ b/extension/manifest.chrome.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.6.2", + "version": "1.6.3", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", @@ -13,7 +13,7 @@ "default_title": "LightSession Pro", "default_popup": "popup/popup.html" }, - "permissions": ["storage", "tabs"], + "permissions": ["storage", "tabs", "declarativeContent"], "host_permissions": [ "*://chat.openai.com/*", "*://chatgpt.com/*" diff --git a/extension/manifest.firefox.json b/extension/manifest.firefox.json index 39e8035..007cbad 100644 --- a/extension/manifest.firefox.json +++ b/extension/manifest.firefox.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "LightSession Pro for ChatGPT", - "version": "1.6.2", + "version": "1.6.3", "description": "Keep ChatGPT fast by keeping only the last N messages in the DOM. Local-only.", "icons": { "16": "icons/icon-16.png", diff --git a/extension/src/background/action-state.ts b/extension/src/background/action-state.ts new file mode 100644 index 0000000..2739c04 --- /dev/null +++ b/extension/src/background/action-state.ts @@ -0,0 +1,105 @@ +/** + * LightSession Pro - Action icon state + * Enable the action only on ChatGPT sites. + */ + +import browser from '../shared/browser-polyfill'; +import { isChatGptUrl } from '../shared/url'; + +const DEFAULT_POPUP = browser.runtime.getManifest().action?.default_popup ?? 'popup/popup.html'; +const CHATGPT_RULES = [ + { hostEquals: 'chatgpt.com' }, + { hostEquals: 'chat.openai.com' }, +]; + +function setPopupForTab(tabId: number, popup: string): void { + if (!browser.action) { + return; + } + + try { + void browser.action.setPopup({ tabId, popup }); + } catch { + // Ignore action update failures (e.g., restricted tabs) + } +} + +export function updateActionForTab(tabId: number, url?: string | null): void { + if (!browser.action || !tabId) { + return; + } + + try { + if (isChatGptUrl(url)) { + void browser.action.enable(tabId); + setPopupForTab(tabId, DEFAULT_POPUP); + } else { + void browser.action.disable(tabId); + setPopupForTab(tabId, ''); + } + } catch { + // Ignore action update failures (e.g., restricted tabs) + } +} + +export function disableActionByDefault(): void { + if (!browser.action) { + return; + } + + try { + void browser.action.disable(); + void browser.action.setPopup({ popup: '' }); + } catch { + // Ignore failures for restricted contexts + } +} + +export async function ensureDeclarativeActionRules(): Promise { + const declarative = ( + browser as unknown as { declarativeContent?: typeof chrome.declarativeContent } + ).declarativeContent; + + if (!declarative?.onPageChanged || !declarative.PageStateMatcher || !declarative.ShowAction) { + return; + } + + const rules = [ + { + conditions: CHATGPT_RULES.map( + (rule) => new declarative.PageStateMatcher({ pageUrl: rule }) + ), + actions: [new declarative.ShowAction()], + }, + ]; + + await new Promise((resolve) => { + declarative.onPageChanged.removeRules(undefined, () => resolve()); + }); + + declarative.onPageChanged.addRules(rules); +} + +export async function syncActionStateForAllTabs(): Promise { + if (!browser.action) { + return; + } + + try { + const tabs = await browser.tabs.query({}); + for (const tab of tabs) { + if (!tab?.id) continue; + updateActionForTab(tab.id, tab.url); + } + } catch { + try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + for (const tab of tabs) { + if (!tab?.id) continue; + updateActionForTab(tab.id, tab.url); + } + } catch { + // Ignore tab query failures + } + } +} diff --git a/extension/src/background/background.ts b/extension/src/background/background.ts index 1e1a991..d08ebd5 100644 --- a/extension/src/background/background.ts +++ b/extension/src/background/background.ts @@ -8,6 +8,12 @@ import type { RuntimeMessage, RuntimeResponse } from '../shared/types'; import { initializeSettings, loadSettings, updateSettings } from '../shared/storage'; import { setDebugMode, logDebug, logError } from '../shared/logger'; import { createMessageHandler } from '../shared/messages'; +import { + disableActionByDefault, + ensureDeclarativeActionRules, + syncActionStateForAllTabs, + updateActionForTab, +} from './action-state'; /** * Initialize background script @@ -22,6 +28,14 @@ async function initialize(): Promise { const settings = await loadSettings(); setDebugMode(settings.debug); + // Disable action by default, then enable per-tab where applicable + disableActionByDefault(); + + await ensureDeclarativeActionRules(); + + // Set initial action state across existing tabs + await syncActionStateForAllTabs(); + logDebug('Background script initialized'); } @@ -72,6 +86,40 @@ browser.storage.onChanged.addListener((changes, areaName) => { } }); +browser.runtime.onInstalled.addListener(() => { + disableActionByDefault(); + void ensureDeclarativeActionRules(); + void syncActionStateForAllTabs(); +}); + +browser.runtime.onStartup.addListener(() => { + disableActionByDefault(); + void ensureDeclarativeActionRules(); + void syncActionStateForAllTabs(); +}); + +// Enable action only on ChatGPT tabs +browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.url) { + updateActionForTab(tabId, changeInfo.url); + return; + } + if (changeInfo.status === 'complete') { + updateActionForTab(tabId, tab.url); + } +}); + +browser.tabs.onActivated.addListener(({ tabId }) => { + void (async () => { + try { + const tab = await browser.tabs.get(tabId); + updateActionForTab(tabId, tab.url); + } catch { + // Ignore failures on restricted tabs + } + })(); +}); + // Register message listener // The handler returns true to indicate async response (required for Chrome) browser.runtime.onMessage.addListener(messageHandler); diff --git a/extension/src/content/chat-view.ts b/extension/src/content/chat-view.ts new file mode 100644 index 0000000..5cc0890 --- /dev/null +++ b/extension/src/content/chat-view.ts @@ -0,0 +1,28 @@ +/** + * LightSession for ChatGPT - Chat view helpers + */ + +const TURN_SELECTORS = [ + '[data-testid="conversation-turn"]', + '[data-message-id]', + '[data-message-author-role]', + 'article', +]; + +export function hasConversationTurns(root: ParentNode): boolean { + for (const selector of TURN_SELECTORS) { + if (root.querySelector(selector)) { + return true; + } + } + return false; +} + +export function isEmptyChatView(root: ParentNode): boolean { + const main = root.querySelector('main'); + if (!main) { + return false; + } + + return !hasConversationTurns(main); +} diff --git a/extension/src/content/content.ts b/extension/src/content/content.ts index f629e5f..bb50c25 100644 --- a/extension/src/content/content.ts +++ b/extension/src/content/content.ts @@ -20,6 +20,7 @@ import { refreshStatusBar, setStatusBarVisibility, } from './status-bar'; +import { isEmptyChatView } from './chat-view'; // ============================================================================ @@ -53,6 +54,9 @@ function isValidTrimStatus(obj: unknown): obj is TrimStatus { let currentSettings: LsSettings | null = null; let proxyReady = false; +let emptyChatState = false; +let emptyChatCheckTimer: number | null = null; +let emptyChatObserver: MutationObserver | null = null; // ============================================================================ // Page Script Communication @@ -255,6 +259,43 @@ function setupNavigationDetection(): void { }; } +// ============================================================================ +// Empty Chat Detection +// ============================================================================ + +function checkEmptyChatView(): void { + const isEmpty = isEmptyChatView(document); + if (isEmpty && !emptyChatState) { + resetAccumulatedTrimmed(); + refreshStatusBar(); + } + emptyChatState = isEmpty; +} + +function scheduleEmptyChatCheck(): void { + if (emptyChatCheckTimer !== null) { + return; + } + + emptyChatCheckTimer = window.setTimeout(() => { + emptyChatCheckTimer = null; + checkEmptyChatView(); + }, 200); +} + +function setupEmptyChatObserver(): void { + if (emptyChatObserver) { + return; + } + + emptyChatObserver = new MutationObserver(() => { + scheduleEmptyChatCheck(); + }); + + emptyChatObserver.observe(document.documentElement, { childList: true, subtree: true }); + scheduleEmptyChatCheck(); +} + // ============================================================================ // Initialization // ============================================================================ @@ -313,6 +354,9 @@ async function initialize(): Promise { // Set up navigation detection setupNavigationDetection(); + // Detect empty chat state to reset stale status + setupEmptyChatObserver(); + // Check proxy status after a short delay setTimeout(checkProxyStatus, TIMING.PROXY_READY_TIMEOUT_MS); diff --git a/extension/src/content/status-bar.ts b/extension/src/content/status-bar.ts index b8b89e6..b61e108 100644 --- a/extension/src/content/status-bar.ts +++ b/extension/src/content/status-bar.ts @@ -309,6 +309,10 @@ export function resetAccumulatedTrimmed(): void { accumulatedTrimmed = 0; currentStats = null; pendingStats = null; + if (pendingUpdateTimer !== null) { + clearTimeout(pendingUpdateTimer); + pendingUpdateTimer = null; + } if (!isVisible) { return; diff --git a/extension/src/page/page-script.ts b/extension/src/page/page-script.ts index b6efcd2..c796255 100644 --- a/extension/src/page/page-script.ts +++ b/extension/src/page/page-script.ts @@ -91,6 +91,8 @@ async function ensureConfigReady(timeoutMs = 50): Promise { } let configReceived = false; +const CONFIG_FALLBACK_TIMEOUT_MS = 2000; +const configStartTime = Date.now(); /** * localStorage key - must match storage.ts LOCAL_STORAGE_KEY @@ -164,6 +166,7 @@ function getConfig(): LsConfig { // Fall back to window config (set by content script events) const cfg = window.__LS_CONFIG__; if (cfg) { + configReceived = true; return { enabled: cfg.enabled ?? DEFAULT_CONFIG.enabled, limit: Math.max(1, cfg.limit ?? DEFAULT_CONFIG.limit), @@ -272,6 +275,14 @@ async function interceptedFetch( const cfg = getConfig(); // If config was never received, avoid trimming to prevent incorrect behavior. + if (!configReceived) { + if (Date.now() - configStartTime > CONFIG_FALLBACK_TIMEOUT_MS) { + configReceived = true; + } else { + return nativeFetch(...args); + } + } + if (!configReceived) { return nativeFetch(...args); } diff --git a/extension/src/shared/url.ts b/extension/src/shared/url.ts new file mode 100644 index 0000000..fcd539a --- /dev/null +++ b/extension/src/shared/url.ts @@ -0,0 +1,18 @@ +/** + * URL helpers shared across extension components. + */ + +const CHATGPT_HOSTS = new Set(['chat.openai.com', 'chatgpt.com']); + +export function isChatGptUrl(url?: string | null): boolean { + if (!url) { + return false; + } + + try { + const parsed = new URL(url); + return CHATGPT_HOSTS.has(parsed.hostname); + } catch { + return false; + } +} diff --git a/package-lock.json b/package-lock.json index 2ba6c43..6fc0134 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2495,7 +2495,7 @@ "dev": true }, "node_modules/concat-stream": { - "version": "1.6.2", + "version": "1.6.3", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "dev": true, diff --git a/package.json b/package.json index 5689f0b..b610e8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "light-session", - "version": "1.6.2", + "version": "1.6.3", "type": "module", "description": "LightSession Pro - Browser extension to optimize ChatGPT performance", "engines": { diff --git a/tests/unit/action-state.test.ts b/tests/unit/action-state.test.ts new file mode 100644 index 0000000..700c020 --- /dev/null +++ b/tests/unit/action-state.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for action icon state handling. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../extension/src/shared/browser-polyfill', () => ({ + default: { + action: { + enable: vi.fn(), + disable: vi.fn(), + setPopup: vi.fn(), + }, + declarativeContent: { + onPageChanged: { + removeRules: vi.fn((_ids, cb) => { + if (cb) cb(); + }), + addRules: vi.fn(), + }, + PageStateMatcher: vi.fn(function PageStateMatcher(this: unknown, options: unknown) { + return { options }; + }), + ShowAction: vi.fn(function ShowAction(this: unknown) { + return {}; + }), + }, + tabs: { + query: vi.fn(), + }, + runtime: { + getManifest: vi.fn(() => ({ action: { default_popup: 'popup/popup.html' } })), + }, + }, +})); + +import browser from '../../extension/src/shared/browser-polyfill'; +import { + disableActionByDefault, + ensureDeclarativeActionRules, + updateActionForTab, + syncActionStateForAllTabs, +} from '../../extension/src/background/action-state'; + +const mockedBrowser = browser as unknown as { + action: { + enable: ReturnType; + disable: ReturnType; + setPopup: ReturnType; + }; + tabs: { query: ReturnType }; + runtime: { getManifest: ReturnType }; + declarativeContent: { + onPageChanged: { removeRules: ReturnType; addRules: ReturnType }; + PageStateMatcher: ReturnType; + ShowAction: ReturnType; + }; +}; + +describe('action state', () => { + beforeEach(() => { + mockedBrowser.action.enable.mockClear(); + mockedBrowser.action.disable.mockClear(); + mockedBrowser.action.setPopup.mockClear(); + mockedBrowser.tabs.query.mockReset(); + mockedBrowser.declarativeContent.onPageChanged.removeRules.mockClear(); + mockedBrowser.declarativeContent.onPageChanged.addRules.mockClear(); + mockedBrowser.declarativeContent.PageStateMatcher.mockClear(); + mockedBrowser.declarativeContent.ShowAction.mockClear(); + }); + + it('enables action on chatgpt.com', () => { + updateActionForTab(1, 'https://chatgpt.com/'); + expect(mockedBrowser.action.enable).toHaveBeenCalledWith(1); + expect(mockedBrowser.action.disable).not.toHaveBeenCalled(); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 1, popup: 'popup/popup.html' }); + }); + + it('enables action on chat.openai.com', () => { + updateActionForTab(2, 'https://chat.openai.com/chat/abc'); + expect(mockedBrowser.action.enable).toHaveBeenCalledWith(2); + expect(mockedBrowser.action.disable).not.toHaveBeenCalled(); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 2, popup: 'popup/popup.html' }); + }); + + it('disables action on non-ChatGPT URLs', () => { + updateActionForTab(3, 'https://example.com/'); + expect(mockedBrowser.action.disable).toHaveBeenCalledWith(3); + expect(mockedBrowser.action.enable).not.toHaveBeenCalled(); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 3, popup: '' }); + }); + + it('disables action when URL is missing', () => { + updateActionForTab(4, undefined); + expect(mockedBrowser.action.disable).toHaveBeenCalledWith(4); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 4, popup: '' }); + }); + + it('syncs action state across tabs', async () => { + mockedBrowser.tabs.query.mockResolvedValue([ + { id: 1, url: 'https://chatgpt.com/' }, + { id: 2, url: 'https://example.com/' }, + ]); + + await syncActionStateForAllTabs(); + + expect(mockedBrowser.action.enable).toHaveBeenCalledWith(1); + expect(mockedBrowser.action.disable).toHaveBeenCalledWith(2); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 1, popup: 'popup/popup.html' }); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ tabId: 2, popup: '' }); + }); + + it('falls back to active tab when full query fails', async () => { + mockedBrowser.tabs.query + .mockRejectedValueOnce(new Error('query failed')) + .mockResolvedValueOnce([{ id: 3, url: 'https://chatgpt.com/' }]); + + await syncActionStateForAllTabs(); + + expect(mockedBrowser.tabs.query).toHaveBeenNthCalledWith(1, {}); + expect(mockedBrowser.tabs.query).toHaveBeenNthCalledWith(2, { active: true, currentWindow: true }); + expect(mockedBrowser.action.enable).toHaveBeenCalledWith(3); + }); + + it('disables action by default', () => { + disableActionByDefault(); + expect(mockedBrowser.action.disable).toHaveBeenCalledWith(); + expect(mockedBrowser.action.setPopup).toHaveBeenCalledWith({ popup: '' }); + }); + + it('registers declarative rules when available', async () => { + await ensureDeclarativeActionRules(); + + expect(mockedBrowser.declarativeContent.onPageChanged.removeRules).toHaveBeenCalled(); + expect(mockedBrowser.declarativeContent.onPageChanged.addRules).toHaveBeenCalledTimes(1); + expect(mockedBrowser.declarativeContent.PageStateMatcher).toHaveBeenCalledTimes(2); + expect(mockedBrowser.declarativeContent.ShowAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/chat-view.test.ts b/tests/unit/chat-view.test.ts new file mode 100644 index 0000000..d84b31b --- /dev/null +++ b/tests/unit/chat-view.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for chat view helpers. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { hasConversationTurns, isEmptyChatView } from '../../extension/src/content/chat-view'; + +describe('chat view helpers', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('detects empty chat view when main has no turns', () => { + document.body.innerHTML = '
No messages
'; + + expect(isEmptyChatView(document)).toBe(true); + }); + + it('detects conversation turns by data-testid', () => { + document.body.innerHTML = '
'; + + expect(hasConversationTurns(document)).toBe(true); + expect(isEmptyChatView(document)).toBe(false); + }); + + it('detects conversation turns by message id', () => { + document.body.innerHTML = '
'; + + expect(hasConversationTurns(document)).toBe(true); + expect(isEmptyChatView(document)).toBe(false); + }); + + it('detects conversation turns by author role', () => { + document.body.innerHTML = '
'; + + expect(hasConversationTurns(document)).toBe(true); + expect(isEmptyChatView(document)).toBe(false); + }); + + it('detects conversation turns by article elements', () => { + document.body.innerHTML = '
Hi
'; + + expect(hasConversationTurns(document)).toBe(true); + expect(isEmptyChatView(document)).toBe(false); + }); +}); diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index b3782cc..7f2bc0c 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -121,8 +121,16 @@ describe('manifest consistency', () => { expect(firefoxManifest.description).toBe(chromeManifest.description); }); - it('both manifests have the same permissions', () => { - expect(firefoxManifest.permissions.sort()).toEqual(chromeManifest.permissions.sort()); + it('chrome permissions include firefox permissions (plus chrome-only)', () => { + const firefoxPerms = new Set(firefoxManifest.permissions); + const chromePerms = new Set(chromeManifest.permissions); + + for (const perm of firefoxPerms) { + expect(chromePerms.has(perm)).toBe(true); + } + + const extraChromePerms = [...chromePerms].filter((perm) => !firefoxPerms.has(perm)); + expect(extraChromePerms.sort()).toEqual(['declarativeContent']); }); it('both manifests have the same host_permissions', () => {