Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Serialize Settings and popup writes in the worker, including first-run defaults
and confirmed resets, to preserve overlapping edits (#46).
- Keep delayed extraction, analysis, links, and saved chat scoped to the current
document/conversation; preserve current and independently restored summaries (#47, #48).
- Stop queued summary sections after a fatal failure; preserve daily cleanup
alarms across worker starts (#49, #50).
- Keep provider deadlines active through response bodies, and extract visible
text with block boundaries without changing the source DOM (#51, #52).
- Restore keyboard access to summary-length radios and expose task-tab selection,
panel relationships, and Arrow/Home/End navigation (#36).
- Replace fictional contribution examples, consolidate setup and validation
guidance, and exclude development configs from the npm package (#35).
- Pin verified current CI action releases, select Ubuntu 26.04 explicitly, and
Expand All @@ -30,6 +40,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A redesigned popup and reading workspace with a persistent source panel,
neutral/teal light and dark themes, local SVG icons, clear task labels,
compact study prompts, and responsive layouts (#36).
- A read-only CI guard rejects a stacked PR whose base is already contained in
the default branch, with Git-history regression tests (issue #22).
- Service-worker regression tests for startup failure, page context menus,
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ Page text goes directly to that provider; there is no application server or tele
The screenshot uses an intercepted test response; it demonstrates the interface,
not live model quality.

The reading workspace keeps your source beside the tools, with light and dark
themes and a compact popup. Use **Study guide** or **Quiz me** in Chat to start
learning. Navigate task tabs with Left/Right, Home, and End; Tab moves into the
selected panel. Summary length works with native radio-keyboard controls.
See the [interface decisions and verification](docs/product/INTERFACE.md).
The combined interface and reliability delivery is tracked in
[PR #39](https://github.com/aaron-seq/GenAI-Browser-Tool/pull/39) and the
[current checkpoint](docs/product/PROJECT-STATUS.md).

## Start here

Clone or download this repository:
Expand All @@ -26,14 +35,14 @@ git clone https://github.com/aaron-seq/GenAI-Browser-Tool.git
Generate a summary or ask a question.
4. Choose **Open workspace** for a persistent reading tab. Import UTF-8 `.txt`
or `.md` files there; importing alone sends no provider request.
5. In Chat, choose **Make a study guide** or **Quiz me**, ask follow-up questions,
5. In Chat, choose **Study guide** or **Quiz me**, ask follow-up questions,
and expand the source passages to check the evidence.

Installation is currently unpacked; there is no published Web Store release.

## What works

- **Summary:** four styles and four lengths; matching saved results avoid another
- **Summary:** four styles and three lengths; matching saved results avoid another
provider call.
- **Chat:** local keyword retrieval selects up to six passages across the document.
Answers display source labels and excerpts.
Expand Down
31 changes: 28 additions & 3 deletions background.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ class BackgroundService {
);
chrome.commands.onCommand.addListener(this.handleCommand.bind(this));
chrome.alarms.onAlarm.addListener(this.handleAlarm.bind(this));
chrome.alarms.create('cleanupOldData', { periodInMinutes: 1440 });
this.ensureCleanupAlarm();
chrome.storage.onChanged.addListener((changes, area) => {
const change = changes.user_preferences;
if (area !== 'sync' || !change) return;
Expand All @@ -123,6 +123,18 @@ class BackgroundService {
});
}

async ensureCleanupAlarm() {
try {
// Replacing a named alarm restarts its delay on every worker wake.
if (!(await chrome.alarms.get('cleanupOldData'))) {
await chrome.alarms.create('cleanupOldData', { periodInMinutes: 1440 });
}
} catch {
// Keep startup usable; a later worker start will check the alarm again.
this.logger.error('History cleanup alarm initialization failed');
}
}

// ---------------------------------------------------------------- messaging

/**
Expand Down Expand Up @@ -220,6 +232,11 @@ class BackgroundService {
case 'UPDATE_USER_PREFERENCES':
return this.configManager.updateUserPreferences(payload);

case 'RESET_USER_PREFERENCES':
return this.configManager.setUserPreferences(
this.configManager.defaultSettings
);

case 'GET_PROVIDER_STATUS':
return this.configManager.getProviderStatus();

Expand Down Expand Up @@ -786,13 +803,21 @@ export async function mapWithConcurrency(items, limit, fn) {
/** @type {R[]} */
const results = new Array(items.length);
let next = 0;
let failed = false;

const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (next < items.length) {
while (!failed && next < items.length) {
const index = next++;
results[index] = await fn(/** @type {T} */ (items[index]), index);
try {
results[index] = await fn(/** @type {T} */ (items[index]), index);
} catch (error) {
// Siblings may finish, but no queued work can produce a usable result.
// eslint-disable-next-line require-atomic-updates -- This flag only transitions to true.
failed = true;
throw error;
}
}
}
);
Expand Down
132 changes: 111 additions & 21 deletions content.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ class PageContentExtractor {
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
this.handleMessage(request)
.then(data => sendResponse({ success: true, data }))
.catch(/** @param {any} error */ error =>
sendResponse({ success: false, error: error.message })
.catch(
/** @param {any} error */ error =>
sendResponse({ success: false, error: error.message })
);
return true; // async sendResponse
});
Expand Down Expand Up @@ -49,50 +50,139 @@ class PageContentExtractor {
headings: this.extractHeadings(),
metadata: this.getPageMetadata(),
language: document.documentElement.lang || 'unknown',
timestamp: Date.now()
timestamp: Date.now(),
};
}

getPageTitle() {
return document.title || document.querySelector('h1')?.textContent?.trim() || 'Untitled Page';
return (
document.title ||
document.querySelector('h1')?.textContent?.trim() ||
'Untitled Page'
);
}

/**
* Take the most specific container that looks like article content, then strip
* chrome (nav, ads, comments) from a clone so the live page is never touched.
* Read the most specific article container without changing the live page.
* A detached clone loses CSS visibility and innerText's block boundaries.
*
* @returns {string}
*/
getMainText() {
const selectors = [
'article', '[role="main"]', 'main', '.post-content', '.entry-content',
'#content', '.story-body', 'body'
'article',
'[role="main"]',
'main',
'.post-content',
'.entry-content',
'#content',
'.story-body',
'body',
];
const mainElement = selectors
.map(selector => document.querySelector(selector))
.find(element => element);

if (!mainElement) return '';

const clone = /** @type {HTMLElement} */ (mainElement.cloneNode(true));
const unwanted = [
'script', 'style', 'noscript', 'nav', 'header', 'footer', 'aside',
'.ad', '.sidebar', '.comments', '[role="navigation"]', '[role="banner"]'
];
unwanted.forEach(selector => {
clone.querySelectorAll(selector).forEach(element => element.remove());
});
'script',
'style',
'noscript',
'nav',
'header',
'footer',
'aside',
'.ad',
'.sidebar',
'.comments',
'[role="navigation"]',
'[role="banner"]',
].join(',');
/** @type {Map<Element, CSSStyleDeclaration>} */
const styles = new Map();
/** @param {Element} element */
const styleFor = element => {
let style = styles.get(element);
if (!style) {
style = window.getComputedStyle(element);
styles.set(element, style);
}
return style;
};
// A selected article can itself be inside a non-rendered container.
for (
let parent = mainElement.parentElement;
parent;
parent = parent.parentElement
) {
const style = styleFor(parent);
if (style.display === 'none' || style.contentVisibility === 'hidden')
return '';
if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open')) {
const summary = parent.querySelector(':scope > summary');
if (!summary?.contains(mainElement)) return '';
}
}

// innerText (not textContent) so hidden elements stay out and block-level
// structure survives as line breaks.
return (clone.innerText || clone.textContent || '').trim();
/** @type {string[]} */
const parts = [];
const lineBreak = (count = 1) => {
const last = parts.at(-1);
if (last?.endsWith('\n'.repeat(count))) return;
if (last) parts[parts.length - 1] = last.trimEnd();
parts.push('\n'.repeat(count));
};
/** @param {Element} element @returns {void} */
const visit = element => {
if (element.matches(unwanted)) return;
const style = styleFor(element);
if (style.display === 'none' || style.contentVisibility === 'hidden')
return;

const visible =
style.visibility !== 'hidden' && style.visibility !== 'collapse';
if (element.tagName === 'BR') {
if (visible) lineBreak();
return;
}
const block = /^(block|flow-root|list-item|flex|grid|table)/.test(
style.display
);
// Blank lines retain the paragraph boundaries consumed by summary chunking.
const breaks = element.tagName === 'P' ? 2 : 1;
if (block) lineBreak(breaks);
// Closed details render only their first direct summary, not the body.
const collapsed =
element.tagName === 'DETAILS' && !element.hasAttribute('open');
const children = collapsed
? [element.querySelector(':scope > summary')]
: element.childNodes;
for (const child of children) {
if (!child) continue;
if (child.nodeType === Node.ELEMENT_NODE) {
// visibility can be explicitly restored by a child of a hidden parent.
visit(/** @type {Element} */ (child));
} else if (child.nodeType === Node.TEXT_NODE && visible) {
let text = child.textContent || '';
if (!/^(pre|break-spaces)/.test(style.whiteSpace)) {
text = text.replace(/\s+/g, ' ');
if (/[\s]$/.test(parts.at(-1) || '')) text = text.trimStart();
}
if (text) parts.push(text);
}
}
if (block) lineBreak(breaks);
};
visit(mainElement);
return parts.join('').trim();
}

extractHeadings() {
return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
.map(heading => ({
level: Number(heading.tagName[1]),
text: heading.textContent?.trim() || ''
text: heading.textContent?.trim() || '',
}))
.filter(heading => heading.text);
}
Expand Down Expand Up @@ -121,7 +211,7 @@ class PageContentExtractor {
return {
href: link.href,
text: link.textContent?.trim() || '',
isExternal: link.hostname !== window.location.hostname
isExternal: link.hostname !== window.location.hostname,
};
})
.filter(link => link.href.startsWith('http'));
Expand All @@ -133,7 +223,7 @@ class PageContentExtractor {
src: image.src,
alt: image.alt,
width: image.naturalWidth,
height: image.naturalHeight
height: image.naturalHeight,
}))
.filter(image => image.src && !image.src.startsWith('data:'));
}
Expand Down
55 changes: 39 additions & 16 deletions core/configuration-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const STORAGE_KEY = 'user_preferences';
*/
export class ConfigurationManager {
constructor() {
/** @type {Promise<any>} */
this.writes = Promise.resolve();
this.defaultSettings = {
preferredProvider: 'anthropic',
/** @type {Record<string, string>} */
Expand Down Expand Up @@ -53,10 +55,12 @@ export class ConfigurationManager {
* distinguish a first run from a later one.
*/
async initialize() {
const stored = await chrome.storage.sync.get([STORAGE_KEY]);
if (!stored[STORAGE_KEY]) {
await this.setUserPreferences(this.defaultSettings);
}
return this.serializeWrite(async () => {
const stored = await chrome.storage.sync.get([STORAGE_KEY]);
if (!stored[STORAGE_KEY]) {
await chrome.storage.sync.set({ [STORAGE_KEY]: this.defaultSettings });
}
});
}

/**
Expand Down Expand Up @@ -102,22 +106,41 @@ export class ConfigurationManager {
* @returns {Promise<any>}
*/
async updateUserPreferences(newSettings) {
const current = await this.getUserPreferences();
const updated = {
...current,
...newSettings,
apiKeys: { ...current.apiKeys, ...(newSettings.apiKeys || {}) },
models: { ...current.models, ...(newSettings.models || {}) },
features: { ...current.features, ...(newSettings.features || {}) },
updatedAt: Date.now(),
};
await chrome.storage.sync.set({ [STORAGE_KEY]: updated });
return updated;
return this.serializeWrite(async () => {
const current = await this.getUserPreferences();
const updated = {
...current,
...newSettings,
apiKeys: { ...current.apiKeys, ...(newSettings.apiKeys || {}) },
models: { ...current.models, ...(newSettings.models || {}) },
features: { ...current.features, ...(newSettings.features || {}) },
updatedAt: Date.now(),
};
await chrome.storage.sync.set({ [STORAGE_KEY]: updated });
return updated;
});
}

/** @param {any} settings */
async setUserPreferences(settings) {
await chrome.storage.sync.set({ [STORAGE_KEY]: settings });
return this.serializeWrite(async () => {
await chrome.storage.sync.set({ [STORAGE_KEY]: settings });
return this.withDefaults(settings);
});
}

/**
* All settings writes run through the worker's manager. Read-modify-write,
* first-run seeding, and replacement/reset must share the same ordering.
* A failed operation reaches its caller without blocking later writes.
* @template T
* @param {() => Promise<T>} write
* @returns {Promise<T>}
*/
serializeWrite(write) {
const operation = this.writes.then(write, write);
this.writes = operation;
return operation;
}

/**
Expand Down
Loading