From f5daf7bb9503449b7f6e2b0d275c2e5efa033342 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 20:25:27 -0700 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20CDP=20inspector=20module=20?= =?UTF-8?q?=E2=80=94=20persistent=20sessions,=20CSS=20cascade,=20style=20m?= =?UTF-8?q?odification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New browse/src/cdp-inspector.ts with full CDP inspection engine: - inspectElement() via CSS.getMatchedStylesForNode + DOM.getBoxModel - modifyStyle() via CSS.setStyleTexts with headless page.evaluate fallback - Persistent CDP session lifecycle (create, reuse, detach on nav, re-create) - Specificity sorting, overridden property detection, UA rule filtering - Modification history with undo support - formatInspectorResult() for CLI output Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/cdp-inspector.ts | 761 ++++++++++++++++++++++++++++++++++++ 1 file changed, 761 insertions(+) create mode 100644 browse/src/cdp-inspector.ts diff --git a/browse/src/cdp-inspector.ts b/browse/src/cdp-inspector.ts new file mode 100644 index 000000000..f8ed51762 --- /dev/null +++ b/browse/src/cdp-inspector.ts @@ -0,0 +1,761 @@ +/** + * CDP Inspector — Chrome DevTools Protocol integration for deep CSS inspection + * + * Manages a persistent CDP session per active page for: + * - Full CSS rule cascade inspection (matched rules, computed styles, inline styles) + * - Box model measurement + * - Live CSS modification via CSS.setStyleTexts + * - Modification history with undo/reset + * + * Session lifecycle: + * Create on first inspect call → reuse across inspections → detach on + * navigation/tab switch/shutdown → re-create transparently on next call + */ + +import type { Page } from 'playwright'; + +// ─── Types ────────────────────────────────────────────────────── + +export interface InspectorResult { + selector: string; + tagName: string; + id: string | null; + classes: string[]; + attributes: Record; + boxModel: { + content: { x: number; y: number; width: number; height: number }; + padding: { top: number; right: number; bottom: number; left: number }; + border: { top: number; right: number; bottom: number; left: number }; + margin: { top: number; right: number; bottom: number; left: number }; + }; + computedStyles: Record; + matchedRules: Array<{ + selector: string; + properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }>; + source: string; + sourceLine: number; + sourceColumn: number; + specificity: { a: number; b: number; c: number }; + media?: string; + userAgent: boolean; + styleSheetId?: string; + range?: object; + }>; + inlineStyles: Record; + pseudoElements: Array<{ + pseudo: string; + rules: Array<{ selector: string; properties: string }>; + }>; +} + +export interface StyleModification { + selector: string; + property: string; + oldValue: string; + newValue: string; + source: string; + sourceLine: number; + timestamp: number; + method: 'setStyleTexts' | 'inline'; +} + +// ─── Constants ────────────────────────────────────────────────── + +/** ~55 key CSS properties for computed style output */ +const KEY_CSS_PROPERTIES = [ + 'display', 'position', 'top', 'right', 'bottom', 'left', + 'float', 'clear', 'z-index', 'overflow', 'overflow-x', 'overflow-y', + 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-style', 'border-color', + 'font-family', 'font-size', 'font-weight', 'line-height', + 'color', 'background-color', 'background-image', 'opacity', + 'box-shadow', 'border-radius', 'transform', 'transition', + 'flex-direction', 'flex-wrap', 'justify-content', 'align-items', 'gap', + 'grid-template-columns', 'grid-template-rows', + 'text-align', 'text-decoration', 'visibility', 'cursor', 'pointer-events', +]; + +const KEY_CSS_SET = new Set(KEY_CSS_PROPERTIES); + +// ─── Session Management ───────────────────────────────────────── + +/** Map of Page → CDP session. Sessions are reused per page. */ +const cdpSessions = new WeakMap(); +/** Track which pages have initialized DOM+CSS domains */ +const initializedPages = new WeakSet(); + +/** + * Get or create a CDP session for the given page. + * Enables DOM + CSS domains on first use. + */ +async function getOrCreateSession(page: Page): Promise { + let session = cdpSessions.get(page); + if (session) { + // Verify session is still alive + try { + await session.send('DOM.getDocument', { depth: 0 }); + return session; + } catch { + // Session is stale — recreate + cdpSessions.delete(page); + initializedPages.delete(page); + } + } + + session = await page.context().newCDPSession(page); + cdpSessions.set(page, session); + + // Enable DOM and CSS domains + await session.send('DOM.enable'); + await session.send('CSS.enable'); + initializedPages.add(page); + + // Auto-detach on navigation + page.once('framenavigated', () => { + try { + session.detach().catch(() => {}); + } catch {} + cdpSessions.delete(page); + initializedPages.delete(page); + }); + + return session; +} + +// ─── Modification History ─────────────────────────────────────── + +const modificationHistory: StyleModification[] = []; + +// ─── Specificity Calculation ──────────────────────────────────── + +/** + * Parse a CSS selector and compute its specificity as {a, b, c}. + * a = ID selectors, b = class/attr/pseudo-class, c = type/pseudo-element + */ +function computeSpecificity(selector: string): { a: number; b: number; c: number } { + let a = 0, b = 0, c = 0; + + // Remove :not() wrapper but count its contents + let cleaned = selector; + + // Count IDs: #foo + const ids = cleaned.match(/#[a-zA-Z_-][\w-]*/g); + if (ids) a += ids.length; + + // Count classes: .foo, attribute selectors: [attr], pseudo-classes: :hover (not ::) + const classes = cleaned.match(/\.[a-zA-Z_-][\w-]*/g); + if (classes) b += classes.length; + const attrs = cleaned.match(/\[[^\]]+\]/g); + if (attrs) b += attrs.length; + const pseudoClasses = cleaned.match(/(?])([a-zA-Z][\w-]*)/g); + if (types) c += types.length; + // Count pseudo-elements: ::before, ::after + const pseudoElements = cleaned.match(/::[a-zA-Z][\w-]*/g); + if (pseudoElements) c += pseudoElements.length; + + return { a, b, c }; +} + +/** + * Compare specificities: returns negative if s1 < s2, positive if s1 > s2, 0 if equal. + */ +function compareSpecificity( + s1: { a: number; b: number; c: number }, + s2: { a: number; b: number; c: number } +): number { + if (s1.a !== s2.a) return s1.a - s2.a; + if (s1.b !== s2.b) return s1.b - s2.b; + return s1.c - s2.c; +} + +// ─── Core Functions ───────────────────────────────────────────── + +/** + * Inspect an element via CDP, returning full CSS cascade data. + */ +export async function inspectElement( + page: Page, + selector: string, + options?: { includeUA?: boolean } +): Promise { + const session = await getOrCreateSession(page); + + // Get document root + const { root } = await session.send('DOM.getDocument', { depth: 0 }); + + // Query for the element + let nodeId: number; + try { + const result = await session.send('DOM.querySelector', { + nodeId: root.nodeId, + selector, + }); + nodeId = result.nodeId; + if (!nodeId) throw new Error(`Element not found: ${selector}`); + } catch (err: any) { + throw new Error(`Element not found: ${selector} — ${err.message}`); + } + + // Get element attributes + const { node } = await session.send('DOM.describeNode', { nodeId, depth: 0 }); + const tagName = (node.localName || node.nodeName || '').toLowerCase(); + const attrPairs = node.attributes || []; + const attributes: Record = {}; + for (let i = 0; i < attrPairs.length; i += 2) { + attributes[attrPairs[i]] = attrPairs[i + 1]; + } + const id = attributes.id || null; + const classes = attributes.class ? attributes.class.split(/\s+/).filter(Boolean) : []; + + // Get box model + let boxModel = { + content: { x: 0, y: 0, width: 0, height: 0 }, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + border: { top: 0, right: 0, bottom: 0, left: 0 }, + margin: { top: 0, right: 0, bottom: 0, left: 0 }, + }; + + try { + const boxData = await session.send('DOM.getBoxModel', { nodeId }); + const model = boxData.model; + + // Content quad: [x1,y1, x2,y2, x3,y3, x4,y4] + const content = model.content; + const padding = model.padding; + const border = model.border; + const margin = model.margin; + + const contentX = content[0]; + const contentY = content[1]; + const contentWidth = content[2] - content[0]; + const contentHeight = content[5] - content[1]; + + boxModel = { + content: { x: contentX, y: contentY, width: contentWidth, height: contentHeight }, + padding: { + top: content[1] - padding[1], + right: padding[2] - content[2], + bottom: padding[5] - content[5], + left: content[0] - padding[0], + }, + border: { + top: padding[1] - border[1], + right: border[2] - padding[2], + bottom: border[5] - padding[5], + left: padding[0] - border[0], + }, + margin: { + top: border[1] - margin[1], + right: margin[2] - border[2], + bottom: margin[5] - border[5], + left: border[0] - margin[0], + }, + }; + } catch { + // Element may not have a box model (e.g., display:none) + } + + // Get matched styles + const matchedData = await session.send('CSS.getMatchedStylesForNode', { nodeId }); + + // Get computed styles + const computedData = await session.send('CSS.getComputedStyleForNode', { nodeId }); + const computedStyles: Record = {}; + for (const entry of computedData.computedStyle) { + if (KEY_CSS_SET.has(entry.name)) { + computedStyles[entry.name] = entry.value; + } + } + + // Get inline styles + const inlineData = await session.send('CSS.getInlineStylesForNode', { nodeId }); + const inlineStyles: Record = {}; + if (inlineData.inlineStyle?.cssProperties) { + for (const prop of inlineData.inlineStyle.cssProperties) { + if (prop.name && prop.value && !prop.disabled) { + inlineStyles[prop.name] = prop.value; + } + } + } + + // Process matched rules + const matchedRules: InspectorResult['matchedRules'] = []; + + // Track all property values to mark overridden ones + const seenProperties = new Map(); // property → index of highest-specificity rule + + if (matchedData.matchedCSSRules) { + for (const match of matchedData.matchedCSSRules) { + const rule = match.rule; + const isUA = rule.origin === 'user-agent'; + + if (isUA && !options?.includeUA) continue; + + // Get the matching selector text + let selectorText = ''; + if (rule.selectorList?.selectors) { + // Use the specific matching selector + const matchingIdx = match.matchingSelectors?.[0] ?? 0; + selectorText = rule.selectorList.selectors[matchingIdx]?.text || rule.selectorList.text || ''; + } + + // Get source info + let source = 'inline'; + let sourceLine = 0; + let sourceColumn = 0; + let styleSheetId: string | undefined; + let range: object | undefined; + + if (rule.styleSheetId) { + styleSheetId = rule.styleSheetId; + try { + // Try to resolve stylesheet URL + source = rule.origin === 'regular' ? (rule.styleSheetId || 'stylesheet') : rule.origin; + } catch {} + } + + if (rule.style?.range) { + range = rule.style.range; + sourceLine = rule.style.range.startLine || 0; + sourceColumn = rule.style.range.startColumn || 0; + } + + // Try to get a friendly source name from stylesheet + if (styleSheetId) { + try { + // Stylesheet URL might be embedded in the rule data + // CDP provides sourceURL in some cases + if (rule.style?.cssText) { + // Parse source from the styleSheetId metadata + } + } catch {} + } + + // Get media query if present + let media: string | undefined; + if (match.rule?.media) { + const mediaList = match.rule.media; + if (Array.isArray(mediaList) && mediaList.length > 0) { + media = mediaList.map((m: any) => m.text).filter(Boolean).join(', '); + } + } + + const specificity = computeSpecificity(selectorText); + + // Process CSS properties + const properties: Array<{ name: string; value: string; important: boolean; overridden: boolean }> = []; + if (rule.style?.cssProperties) { + for (const prop of rule.style.cssProperties) { + if (!prop.name || prop.disabled) continue; + // Skip internal/vendor properties unless they are in our key set + if (prop.name.startsWith('-') && !KEY_CSS_SET.has(prop.name)) continue; + + properties.push({ + name: prop.name, + value: prop.value || '', + important: prop.important || (prop.value?.includes('!important') ?? false), + overridden: false, // will be set later + }); + } + } + + matchedRules.push({ + selector: selectorText, + properties, + source, + sourceLine, + sourceColumn, + specificity, + media, + userAgent: isUA, + styleSheetId, + range, + }); + } + } + + // Sort by specificity (highest first — these win) + matchedRules.sort((a, b) => -compareSpecificity(a.specificity, b.specificity)); + + // Mark overridden properties: the first rule in the sorted list (highest specificity) wins + for (let i = 0; i < matchedRules.length; i++) { + for (const prop of matchedRules[i].properties) { + const key = prop.name; + if (!seenProperties.has(key)) { + seenProperties.set(key, i); + } else { + // This property was already declared by a higher-specificity rule + // Unless this one is !important and the earlier one isn't + const earlierIdx = seenProperties.get(key)!; + const earlierRule = matchedRules[earlierIdx]; + const earlierProp = earlierRule.properties.find(p => p.name === key); + if (prop.important && earlierProp && !earlierProp.important) { + // This !important overrides the earlier non-important + if (earlierProp) earlierProp.overridden = true; + seenProperties.set(key, i); + } else { + prop.overridden = true; + } + } + } + } + + // Process pseudo-elements + const pseudoElements: InspectorResult['pseudoElements'] = []; + if (matchedData.pseudoElements) { + for (const pseudo of matchedData.pseudoElements) { + const pseudoType = pseudo.pseudoType || 'unknown'; + const rules: Array<{ selector: string; properties: string }> = []; + if (pseudo.matches) { + for (const match of pseudo.matches) { + const rule = match.rule; + const sel = rule.selectorList?.text || ''; + const props = (rule.style?.cssProperties || []) + .filter((p: any) => p.name && !p.disabled) + .map((p: any) => `${p.name}: ${p.value}`) + .join('; '); + if (props) { + rules.push({ selector: sel, properties: props }); + } + } + } + if (rules.length > 0) { + pseudoElements.push({ pseudo: `::${pseudoType}`, rules }); + } + } + } + + // Resolve stylesheet URLs for better source info + for (const rule of matchedRules) { + if (rule.styleSheetId && rule.source !== 'inline') { + try { + const sheetMeta = await session.send('CSS.getStyleSheetText', { styleSheetId: rule.styleSheetId }).catch(() => null); + // Try to get the stylesheet header for URL info + // The styleSheetId itself is opaque, but we can try to get source URL + } catch {} + } + } + + return { + selector, + tagName, + id, + classes, + attributes, + boxModel, + computedStyles, + matchedRules, + inlineStyles, + pseudoElements, + }; +} + +/** + * Modify a CSS property on an element. + * Uses CSS.setStyleTexts in headed mode, falls back to inline style in headless. + */ +export async function modifyStyle( + page: Page, + selector: string, + property: string, + value: string +): Promise { + // Validate CSS property name + if (!/^[a-zA-Z-]+$/.test(property)) { + throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`); + } + + let oldValue = ''; + let source = 'inline'; + let sourceLine = 0; + let method: 'setStyleTexts' | 'inline' = 'inline'; + + try { + // Try CDP approach first + const session = await getOrCreateSession(page); + const result = await inspectElement(page, selector); + oldValue = result.computedStyles[property] || ''; + + // Find the most-specific matching rule that has this property + let targetRule: InspectorResult['matchedRules'][0] | null = null; + for (const rule of result.matchedRules) { + if (rule.userAgent) continue; + const hasProp = rule.properties.some(p => p.name === property); + if (hasProp && rule.styleSheetId && rule.range) { + targetRule = rule; + break; + } + } + + if (targetRule?.styleSheetId && targetRule.range) { + // Modify via CSS.setStyleTexts + const range = targetRule.range as any; + + // Get current style text + const styleText = await session.send('CSS.getStyleSheetText', { + styleSheetId: targetRule.styleSheetId, + }); + + // Build new style text by replacing the property value + const currentProps = targetRule.properties; + const newPropsText = currentProps + .map(p => { + if (p.name === property) { + return `${p.name}: ${value}`; + } + return `${p.name}: ${p.value}`; + }) + .join('; '); + + try { + await session.send('CSS.setStyleTexts', { + edits: [{ + styleSheetId: targetRule.styleSheetId, + range, + text: newPropsText, + }], + }); + method = 'setStyleTexts'; + source = `${targetRule.source}:${targetRule.sourceLine}`; + sourceLine = targetRule.sourceLine; + } catch { + // Fall back to inline + } + } + + if (method === 'inline') { + // Fallback: modify via inline style + await page.evaluate( + ([sel, prop, val]) => { + const el = document.querySelector(sel); + if (!el) throw new Error(`Element not found: ${sel}`); + (el as HTMLElement).style.setProperty(prop, val); + }, + [selector, property, value] + ); + } + } catch (err: any) { + // Full fallback: use page.evaluate for headless + await page.evaluate( + ([sel, prop, val]) => { + const el = document.querySelector(sel); + if (!el) throw new Error(`Element not found: ${sel}`); + (el as HTMLElement).style.setProperty(prop, val); + }, + [selector, property, value] + ); + } + + const modification: StyleModification = { + selector, + property, + oldValue, + newValue: value, + source, + sourceLine, + timestamp: Date.now(), + method, + }; + + modificationHistory.push(modification); + return modification; +} + +/** + * Undo a modification by index (or last if no index given). + */ +export async function undoModification(page: Page, index?: number): Promise { + const idx = index ?? modificationHistory.length - 1; + if (idx < 0 || idx >= modificationHistory.length) { + throw new Error(`No modification at index ${idx}. History has ${modificationHistory.length} entries.`); + } + + const mod = modificationHistory[idx]; + + if (mod.method === 'setStyleTexts') { + // Try to restore via CDP + try { + await modifyStyle(page, mod.selector, mod.property, mod.oldValue); + // Remove the undo modification from history (it's a restore, not a new mod) + modificationHistory.pop(); + } catch { + // Fall back to inline restore + await page.evaluate( + ([sel, prop, val]) => { + const el = document.querySelector(sel); + if (!el) return; + if (val) { + (el as HTMLElement).style.setProperty(prop, val); + } else { + (el as HTMLElement).style.removeProperty(prop); + } + }, + [mod.selector, mod.property, mod.oldValue] + ); + } + } else { + // Inline modification — restore or remove + await page.evaluate( + ([sel, prop, val]) => { + const el = document.querySelector(sel); + if (!el) return; + if (val) { + (el as HTMLElement).style.setProperty(prop, val); + } else { + (el as HTMLElement).style.removeProperty(prop); + } + }, + [mod.selector, mod.property, mod.oldValue] + ); + } + + modificationHistory.splice(idx, 1); +} + +/** + * Get the full modification history. + */ +export function getModificationHistory(): StyleModification[] { + return [...modificationHistory]; +} + +/** + * Reset all modifications, restoring original values. + */ +export async function resetModifications(page: Page): Promise { + // Restore in reverse order + for (let i = modificationHistory.length - 1; i >= 0; i--) { + const mod = modificationHistory[i]; + try { + await page.evaluate( + ([sel, prop, val]) => { + const el = document.querySelector(sel); + if (!el) return; + if (val) { + (el as HTMLElement).style.setProperty(prop, val); + } else { + (el as HTMLElement).style.removeProperty(prop); + } + }, + [mod.selector, mod.property, mod.oldValue] + ); + } catch { + // Best effort + } + } + modificationHistory.length = 0; +} + +/** + * Format an InspectorResult for CLI text output. + */ +export function formatInspectorResult( + result: InspectorResult, + options?: { includeUA?: boolean } +): string { + const lines: string[] = []; + + // Element header + const classStr = result.classes.length > 0 ? ` class="${result.classes.join(' ')}"` : ''; + const idStr = result.id ? ` id="${result.id}"` : ''; + lines.push(`Element: <${result.tagName}${idStr}${classStr}>`); + lines.push(`Selector: ${result.selector}`); + + const w = Math.round(result.boxModel.content.width + result.boxModel.padding.left + result.boxModel.padding.right); + const h = Math.round(result.boxModel.content.height + result.boxModel.padding.top + result.boxModel.padding.bottom); + lines.push(`Dimensions: ${w} x ${h}`); + lines.push(''); + + // Box model + lines.push('Box Model:'); + const bm = result.boxModel; + lines.push(` margin: ${Math.round(bm.margin.top)}px ${Math.round(bm.margin.right)}px ${Math.round(bm.margin.bottom)}px ${Math.round(bm.margin.left)}px`); + lines.push(` padding: ${Math.round(bm.padding.top)}px ${Math.round(bm.padding.right)}px ${Math.round(bm.padding.bottom)}px ${Math.round(bm.padding.left)}px`); + lines.push(` border: ${Math.round(bm.border.top)}px ${Math.round(bm.border.right)}px ${Math.round(bm.border.bottom)}px ${Math.round(bm.border.left)}px`); + lines.push(` content: ${Math.round(bm.content.width)} x ${Math.round(bm.content.height)}`); + lines.push(''); + + // Matched rules + const displayRules = options?.includeUA + ? result.matchedRules + : result.matchedRules.filter(r => !r.userAgent); + + lines.push(`Matched Rules (${displayRules.length}):`); + if (displayRules.length === 0) { + lines.push(' (none)'); + } else { + for (const rule of displayRules) { + const propsStr = rule.properties + .filter(p => !p.overridden) + .map(p => `${p.name}: ${p.value}${p.important ? ' !important' : ''}`) + .join('; '); + if (!propsStr) continue; + const spec = `[${rule.specificity.a},${rule.specificity.b},${rule.specificity.c}]`; + lines.push(` ${rule.selector} { ${propsStr} }`); + lines.push(` -> ${rule.source}:${rule.sourceLine} ${spec}${rule.media ? ` @media ${rule.media}` : ''}`); + } + } + lines.push(''); + + // Inline styles + lines.push('Inline Styles:'); + const inlineEntries = Object.entries(result.inlineStyles); + if (inlineEntries.length === 0) { + lines.push(' (none)'); + } else { + const inlineStr = inlineEntries.map(([k, v]) => `${k}: ${v}`).join('; '); + lines.push(` ${inlineStr}`); + } + lines.push(''); + + // Computed styles (key properties, compact format) + lines.push('Computed (key):'); + const cs = result.computedStyles; + const computedPairs: string[] = []; + for (const prop of KEY_CSS_PROPERTIES) { + if (cs[prop] !== undefined) { + computedPairs.push(`${prop}: ${cs[prop]}`); + } + } + // Group into lines of ~3 properties each + for (let i = 0; i < computedPairs.length; i += 3) { + const chunk = computedPairs.slice(i, i + 3); + lines.push(` ${chunk.join(' | ')}`); + } + + // Pseudo-elements + if (result.pseudoElements.length > 0) { + lines.push(''); + lines.push('Pseudo-elements:'); + for (const pseudo of result.pseudoElements) { + for (const rule of pseudo.rules) { + lines.push(` ${pseudo.pseudo} ${rule.selector} { ${rule.properties} }`); + } + } + } + + return lines.join('\n'); +} + +/** + * Detach CDP session for a page (or all pages). + */ +export function detachSession(page?: Page): void { + if (page) { + const session = cdpSessions.get(page); + if (session) { + try { session.detach().catch(() => {}); } catch {} + cdpSessions.delete(page); + initializedPages.delete(page); + } + } + // Note: WeakMap doesn't support iteration, so we can't detach all. + // Callers with specific pages should call this per-page. +} From e084ca90fd0ce8caff739aa2b79854a4e799414a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 20:25:32 -0700 Subject: [PATCH 02/28] feat: browse server inspector endpoints + inspect/style/cleanup/prettyscreenshot CLI Server endpoints: POST /inspector/pick, GET /inspector, POST /inspector/apply, POST /inspector/reset, GET /inspector/history, GET /inspector/events (SSE). CLI commands: inspect (CDP cascade), style (live CSS mod), cleanup (page clutter removal), prettyscreenshot (clean screenshot pipeline). Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/commands.ts | 7 + browse/src/read-commands.ts | 49 ++++++ browse/src/server.ts | 163 ++++++++++++++++++++ browse/src/write-commands.ts | 290 +++++++++++++++++++++++++++++++++++ 4 files changed, 509 insertions(+) diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 152445384..ae80f32de 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -15,6 +15,7 @@ export const READ_COMMANDS = new Set([ 'js', 'eval', 'css', 'attrs', 'console', 'network', 'cookies', 'storage', 'perf', 'dialog', 'is', + 'inspect', ]); export const WRITE_COMMANDS = new Set([ @@ -22,6 +23,7 @@ export const WRITE_COMMANDS = new Set([ 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', 'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent', 'upload', 'dialog-accept', 'dialog-dismiss', + 'style', 'cleanup', 'prettyscreenshot', ]); export const META_COMMANDS = new Set([ @@ -115,6 +117,11 @@ export const COMMAND_DESCRIPTIONS: Record' }, // Frame 'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame ' }, + // CSS Inspector + 'inspect': { category: 'Inspection', description: 'Deep CSS inspection via CDP — full rule cascade, box model, computed styles', usage: 'inspect [selector] [--all] [--history]' }, + 'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style | style --undo [N]' }, + 'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' }, + 'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' }, }; // Load-time validation: descriptions must cover exactly the command sets diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 5615b60f0..83c791a3d 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -11,6 +11,7 @@ import type { Page, Frame } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; +import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector'; /** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ function hasAwait(code: string): boolean { @@ -352,6 +353,54 @@ export async function handleReadCommand( .join('\n'); } + case 'inspect': { + // Parse flags + let includeUA = false; + let showHistory = false; + let selector: string | undefined; + + for (const arg of args) { + if (arg === '--all') { + includeUA = true; + } else if (arg === '--history') { + showHistory = true; + } else if (!selector) { + selector = arg; + } + } + + // --history mode: return modification history + if (showHistory) { + const history = getModificationHistory(); + if (history.length === 0) return '(no style modifications)'; + return history.map((m, i) => + `[${i}] ${m.selector} { ${m.property}: ${m.oldValue} → ${m.newValue} } (${m.source}, ${m.method})` + ).join('\n'); + } + + // If no selector given, check for stored inspector data + if (!selector) { + // Access stored inspector data from the server's in-memory state + // The server stores this when the extension picks an element via POST /inspector/pick + const stored = (bm as any)._inspectorData; + const storedTs = (bm as any)._inspectorTimestamp; + if (stored) { + const stale = storedTs && (Date.now() - storedTs > 60000); + let output = formatInspectorResult(stored, { includeUA }); + if (stale) output = '⚠ Data may be stale (>60s old)\n\n' + output; + return output; + } + throw new Error('Usage: browse inspect [selector] [--all] [--history]\nOr pick an element in the Chrome sidebar first.'); + } + + // Direct inspection by selector + const result = await inspectElement(page, selector, { includeUA }); + // Store for later retrieval + (bm as any)._inspectorData = result; + (bm as any)._inspectorTimestamp = Date.now(); + return formatInspectorResult(result, { includeUA }); + } + default: throw new Error(`Unknown read command: ${command}`); } diff --git a/browse/src/server.ts b/browse/src/server.ts index dca380409..a247ef4b1 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -23,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from './commands'; import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity'; +import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector'; // Bun.spawn used instead of child_process.spawn (compiled bun binaries // fail posix_spawn on all executables including /bin/bash) import * as fs from 'fs'; @@ -544,6 +545,22 @@ const idleCheckInterval = setInterval(() => { import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; +// ─── Inspector State (in-memory) ────────────────────────────── +let inspectorData: InspectorResult | null = null; +let inspectorTimestamp: number = 0; + +// Inspector SSE subscribers +type InspectorSubscriber = (event: any) => void; +const inspectorSubscribers = new Set(); + +function emitInspectorEvent(event: any): void { + for (const notify of inspectorSubscribers) { + queueMicrotask(() => { + try { notify(event); } catch {} + }); + } +} + // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); let isShuttingDown = false; @@ -728,6 +745,9 @@ async function shutdown() { isShuttingDown = true; console.log('[browse] Shutting down...'); + // Clean up CDP inspector sessions + try { detachSession(); } catch {} + inspectorSubscribers.clear(); // Stop watch mode if active if (browserManager.isWatching()) browserManager.stopWatch(); killAgent(); @@ -1127,6 +1147,149 @@ async function start() { }); } + // ─── Inspector endpoints ────────────────────────────────────── + + // POST /inspector/pick — receive element pick from extension, run CDP inspection + if (url.pathname === '/inspector/pick' && req.method === 'POST') { + const body = await req.json(); + const { selector, activeTabUrl } = body; + if (!selector) { + return new Response(JSON.stringify({ error: 'Missing selector' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + try { + const page = browserManager.getPage(); + const result = await inspectElement(page, selector); + inspectorData = result; + inspectorTimestamp = Date.now(); + // Also store on browserManager for CLI access + (browserManager as any)._inspectorData = result; + (browserManager as any)._inspectorTimestamp = inspectorTimestamp; + emitInspectorEvent({ type: 'pick', selector, timestamp: inspectorTimestamp }); + return new Response(JSON.stringify(result), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // GET /inspector — return latest inspector data + if (url.pathname === '/inspector' && req.method === 'GET') { + if (!inspectorData) { + return new Response(JSON.stringify({ data: null }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + const stale = inspectorTimestamp > 0 && (Date.now() - inspectorTimestamp > 60000); + return new Response(JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp, stale }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // POST /inspector/apply — apply a CSS modification + if (url.pathname === '/inspector/apply' && req.method === 'POST') { + const body = await req.json(); + const { selector, property, value } = body; + if (!selector || !property || value === undefined) { + return new Response(JSON.stringify({ error: 'Missing selector, property, or value' }), { + status: 400, headers: { 'Content-Type': 'application/json' }, + }); + } + try { + const page = browserManager.getPage(); + const mod = await modifyStyle(page, selector, property, value); + emitInspectorEvent({ type: 'apply', modification: mod, timestamp: Date.now() }); + return new Response(JSON.stringify(mod), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // POST /inspector/reset — clear all modifications + if (url.pathname === '/inspector/reset' && req.method === 'POST') { + try { + const page = browserManager.getPage(); + await resetModifications(page); + emitInspectorEvent({ type: 'reset', timestamp: Date.now() }); + return new Response(JSON.stringify({ ok: true }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, headers: { 'Content-Type': 'application/json' }, + }); + } + } + + // GET /inspector/history — return modification list + if (url.pathname === '/inspector/history' && req.method === 'GET') { + return new Response(JSON.stringify({ history: getModificationHistory() }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }); + } + + // GET /inspector/events — SSE for inspector state changes + if (url.pathname === '/inspector/events' && req.method === 'GET') { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + // Send current state immediately + if (inspectorData) { + controller.enqueue(encoder.encode( + `event: state\ndata: ${JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp })}\n\n` + )); + } + + // Subscribe for live events + const notify: InspectorSubscriber = (event) => { + try { + controller.enqueue(encoder.encode( + `event: inspector\ndata: ${JSON.stringify(event)}\n\n` + )); + } catch { + inspectorSubscribers.delete(notify); + } + }; + inspectorSubscribers.add(notify); + + // Heartbeat every 15s + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch { + clearInterval(heartbeat); + inspectorSubscribers.delete(notify); + } + }, 15000); + + // Cleanup on disconnect + req.signal.addEventListener('abort', () => { + clearInterval(heartbeat); + inspectorSubscribers.delete(notify); + try { controller.close(); } catch {} + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }, + }); + } + + // ─── Command endpoint ────────────────────────────────────────── + if (url.pathname === '/command' && req.method === 'POST') { resetIdleTimer(); // Only commands reset idle timer const body = await req.json(); diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 02413daf8..f3292cc11 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -11,6 +11,49 @@ import { validateNavigationUrl } from './url-validation'; import * as fs from 'fs'; import * as path from 'path'; import { TEMP_DIR, isPathWithin } from './platform'; +import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector'; + +// Security: Path validation for screenshot output +const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()]; + +function validateOutputPath(filePath: string): void { + const resolved = path.resolve(filePath); + const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir)); + if (!isSafe) { + throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`); + } +} + +/** Common selectors for page clutter removal */ +const CLEANUP_SELECTORS = { + ads: [ + 'ins.adsbygoogle', '[id^="google_ads"]', '[id^="div-gpt-ad"]', + 'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]', + '[class*="ad-banner"]', '[class*="ad-wrapper"]', '[class*="ad-container"]', + '[data-ad]', '[data-ad-slot]', '[class*="sponsored"]', + '.ad', '.ads', '.advert', '.advertisement', + ], + cookies: [ + '[class*="cookie-consent"]', '[class*="cookie-banner"]', '[class*="cookie-notice"]', + '[id*="cookie-consent"]', '[id*="cookie-banner"]', '[id*="cookie-notice"]', + '[class*="consent-banner"]', '[class*="consent-modal"]', + '[class*="gdpr"]', '[id*="gdpr"]', + '[class*="CookieConsent"]', '[id*="CookieConsent"]', + '#onetrust-consent-sdk', '.onetrust-pc-dark-filter', + '[class*="cc-banner"]', '[class*="cc-window"]', + ], + sticky: [ + // Select fixed/sticky positioned elements (except navs and headers at top) + // This is handled via JavaScript evaluation, not pure selectors + ], + social: [ + '[class*="social-share"]', '[class*="share-buttons"]', '[class*="share-bar"]', + '[class*="social-widget"]', '[class*="social-icons"]', + 'iframe[src*="facebook.com/plugins"]', 'iframe[src*="platform.twitter"]', + '[class*="fb-like"]', '[class*="tweet-button"]', + '[class*="addthis"]', '[class*="sharethis"]', + ], +}; export async function handleWriteCommand( command: string, @@ -358,6 +401,253 @@ export async function handleWriteCommand( return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`; } + case 'style': { + // style --undo [N] → revert modification + if (args[0] === '--undo') { + const idx = args[1] ? parseInt(args[1], 10) : undefined; + await undoModification(page, idx); + return idx !== undefined ? `Reverted modification #${idx}` : 'Reverted last modification'; + } + + // style + const [selector, property, ...valueParts] = args; + const value = valueParts.join(' '); + if (!selector || !property || !value) { + throw new Error('Usage: browse style | style --undo [N]'); + } + + // Validate CSS property name + if (!/^[a-zA-Z-]+$/.test(property)) { + throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`); + } + + const mod = await modifyStyle(page, selector, property, value); + return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`; + } + + case 'cleanup': { + // Parse flags + let doAds = false, doCookies = false, doSticky = false, doSocial = false; + let doAll = false; + + if (args.length === 0) { + throw new Error('Usage: browse cleanup [--ads] [--cookies] [--sticky] [--social] [--all]'); + } + + for (const arg of args) { + switch (arg) { + case '--ads': doAds = true; break; + case '--cookies': doCookies = true; break; + case '--sticky': doSticky = true; break; + case '--social': doSocial = true; break; + case '--all': doAll = true; break; + default: + throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --all`); + } + } + + if (doAll) { + doAds = doCookies = doSticky = doSocial = true; + } + + const removed: string[] = []; + + // Build selector list for categories to clean + const selectors: string[] = []; + if (doAds) selectors.push(...CLEANUP_SELECTORS.ads); + if (doCookies) selectors.push(...CLEANUP_SELECTORS.cookies); + if (doSocial) selectors.push(...CLEANUP_SELECTORS.social); + + if (selectors.length > 0) { + const count = await page.evaluate((sels: string[]) => { + let removed = 0; + for (const sel of sels) { + try { + const els = document.querySelectorAll(sel); + els.forEach(el => { + (el as HTMLElement).style.display = 'none'; + removed++; + }); + } catch {} + } + return removed; + }, selectors); + if (count > 0) { + if (doAds) removed.push('ads'); + if (doCookies) removed.push('cookie banners'); + if (doSocial) removed.push('social widgets'); + } + } + + // Sticky/fixed elements — handled separately with computed style check + if (doSticky) { + const stickyCount = await page.evaluate(() => { + let removed = 0; + const allElements = document.querySelectorAll('*'); + for (const el of allElements) { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + const tag = el.tagName.toLowerCase(); + // Skip main nav/header elements + if (tag === 'nav' || tag === 'header') continue; + if (el.getAttribute('role') === 'navigation') continue; + // Skip elements at the very top that look like navbars + const rect = el.getBoundingClientRect(); + if (rect.top <= 10 && rect.height < 100 && tag !== 'div') continue; + (el as HTMLElement).style.display = 'none'; + removed++; + } + } + return removed; + }); + if (stickyCount > 0) removed.push(`${stickyCount} sticky/fixed elements`); + } + + if (removed.length === 0) return 'No clutter elements found to remove.'; + return `Cleaned up: ${removed.join(', ')}`; + } + + case 'prettyscreenshot': { + // Parse flags + let scrollTo: string | undefined; + let doCleanup = false; + const hideSelectors: string[] = []; + let viewportWidth: number | undefined; + let outputPath: string | undefined; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--scroll-to' && i + 1 < args.length) { + scrollTo = args[++i]; + } else if (args[i] === '--cleanup') { + doCleanup = true; + } else if (args[i] === '--hide' && i + 1 < args.length) { + // Collect all following non-flag args as selectors to hide + i++; + while (i < args.length && !args[i].startsWith('--')) { + hideSelectors.push(args[i]); + i++; + } + i--; // Back up since the for loop will increment + } else if (args[i] === '--width' && i + 1 < args.length) { + viewportWidth = parseInt(args[++i], 10); + if (isNaN(viewportWidth)) throw new Error('--width must be a number'); + } else if (!args[i].startsWith('--')) { + outputPath = args[i]; + } else { + throw new Error(`Unknown prettyscreenshot flag: ${args[i]}`); + } + } + + // Default output path + if (!outputPath) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + outputPath = `${TEMP_DIR}/browse-pretty-${timestamp}.png`; + } + validateOutputPath(outputPath); + + const originalViewport = page.viewportSize(); + + // Set viewport width if specified + if (viewportWidth && originalViewport) { + await page.setViewportSize({ width: viewportWidth, height: originalViewport.height }); + } + + // Run cleanup if requested + if (doCleanup) { + const allSelectors = [ + ...CLEANUP_SELECTORS.ads, + ...CLEANUP_SELECTORS.cookies, + ...CLEANUP_SELECTORS.social, + ]; + await page.evaluate((sels: string[]) => { + for (const sel of sels) { + try { + document.querySelectorAll(sel).forEach(el => { + (el as HTMLElement).style.display = 'none'; + }); + } catch {} + } + // Also hide fixed/sticky (except nav) + for (const el of document.querySelectorAll('*')) { + const style = getComputedStyle(el); + if (style.position === 'fixed' || style.position === 'sticky') { + const tag = el.tagName.toLowerCase(); + if (tag === 'nav' || tag === 'header') continue; + if (el.getAttribute('role') === 'navigation') continue; + (el as HTMLElement).style.display = 'none'; + } + } + }, allSelectors); + } + + // Hide specific elements + if (hideSelectors.length > 0) { + await page.evaluate((sels: string[]) => { + for (const sel of sels) { + try { + document.querySelectorAll(sel).forEach(el => { + (el as HTMLElement).style.display = 'none'; + }); + } catch {} + } + }, hideSelectors); + } + + // Scroll to target + if (scrollTo) { + // Try as CSS selector first, then as text content + const scrolled = await page.evaluate((target: string) => { + // Try CSS selector + let el = document.querySelector(target); + if (el) { + el.scrollIntoView({ behavior: 'instant', block: 'center' }); + return true; + } + // Try text match + const walker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_TEXT, + null, + ); + let node: Node | null; + while ((node = walker.nextNode())) { + if (node.textContent?.includes(target)) { + const parent = node.parentElement; + if (parent) { + parent.scrollIntoView({ behavior: 'instant', block: 'center' }); + return true; + } + } + } + return false; + }, scrollTo); + + if (!scrolled) { + // Restore viewport before throwing + if (viewportWidth && originalViewport) { + await page.setViewportSize(originalViewport); + } + throw new Error(`Could not find element or text to scroll to: ${scrollTo}`); + } + // Brief wait for scroll to settle + await page.waitForTimeout(300); + } + + // Take screenshot + await page.screenshot({ path: outputPath, fullPage: !scrollTo }); + + // Restore viewport + if (viewportWidth && originalViewport) { + await page.setViewportSize(originalViewport); + } + + const parts = ['Screenshot saved']; + if (doCleanup) parts.push('(cleaned)'); + if (scrollTo) parts.push(`(scrolled to: ${scrollTo})`); + parts.push(`: ${outputPath}`); + return parts.join(' '); + } + default: throw new Error(`Unknown write command: ${command}`); } From f395f58406593313d5eeaef22aefab2b04773e32 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 20:25:36 -0700 Subject: [PATCH 03/28] =?UTF-8?q?feat:=20sidebar=20CSS=20inspector=20?= =?UTF-8?q?=E2=80=94=20element=20picker,=20box=20model,=20rule=20cascade,?= =?UTF-8?q?=20quick=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension changes for the visual CSS inspector: - inspector.js: element picker with hover highlight, CSS selector generation, basic mode fallback (getComputedStyle + CSSOM), page alteration handlers - inspector.css: picker overlay styles (blue highlight + tooltip) - background.js: inspector message routing (picker <-> server <-> sidepanel) - sidepanel: Inspector tab with box model viz (gstack palette), matched rules with specificity badges, computed styles, click-to-edit quick edit, Send to Agent/Code button, empty/loading/error states Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/background.js | 130 +++++++++++ extension/inspector.css | 29 +++ extension/inspector.js | 459 ++++++++++++++++++++++++++++++++++++ extension/manifest.json | 2 +- extension/sidepanel.css | 490 +++++++++++++++++++++++++++++++++++++++ extension/sidepanel.html | 78 +++++++ extension/sidepanel.js | 437 ++++++++++++++++++++++++++++++++++ 7 files changed, 1624 insertions(+), 1 deletion(-) create mode 100644 extension/inspector.css create mode 100644 extension/inspector.js diff --git a/extension/background.js b/extension/background.js index af1f32ea6..7e1dd6da9 100644 --- a/extension/background.js +++ b/extension/background.js @@ -158,6 +158,73 @@ async function fetchAndRelayRefs() { } catch {} } +// ─── Inspector ────────────────────────────────────────────────── + +async function injectInspector(tabId) { + try { + await chrome.scripting.executeScript({ + target: { tabId, allFrames: true }, + files: ['inspector.js'], + }); + await chrome.scripting.insertCSS({ + target: { tabId, allFrames: true }, + files: ['inspector.css'], + }); + } catch (err) { + return { error: 'Cannot inspect this page (CSP restriction)' }; + } + // Send startPicker to all frames + try { + await chrome.tabs.sendMessage(tabId, { type: 'startPicker' }); + } catch {} + return { ok: true }; +} + +async function stopInspector(tabId) { + try { + await chrome.tabs.sendMessage(tabId, { type: 'stopPicker' }); + } catch {} + return { ok: true }; +} + +async function postInspectorPick(selector, frameInfo, basicData, activeTabUrl) { + const base = getBaseUrl(); + if (!base || !authToken) { + // No browse server — return basic data as fallback + return { mode: 'basic', selector, basicData, frameInfo }; + } + + try { + const resp = await fetch(`${base}/inspector/pick`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}`, + }, + body: JSON.stringify({ selector, activeTabUrl, frameInfo }), + signal: AbortSignal.timeout(10000), + }); + if (!resp.ok) { + // Server error — fall back to basic mode + return { mode: 'basic', selector, basicData, frameInfo }; + } + const data = await resp.json(); + return { mode: 'cdp', ...data }; + } catch { + // No server or timeout — fall back to basic mode + return { mode: 'basic', selector, basicData, frameInfo }; + } +} + +async function sendToContentScript(tabId, message) { + try { + const response = await chrome.tabs.sendMessage(tabId, message); + return response || { ok: true }; + } catch { + return { error: 'Content script not available' }; + } +} + // ─── Message Handling ────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { @@ -194,6 +261,69 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } + // Inspector: inject + start picker + if (msg.type === 'startInspector') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tabId = tabs?.[0]?.id; + if (!tabId) { sendResponse({ error: 'No active tab' }); return; } + injectInspector(tabId).then(result => sendResponse(result)); + }); + return true; + } + + // Inspector: stop picker + if (msg.type === 'stopInspector') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tabId = tabs?.[0]?.id; + if (!tabId) { sendResponse({ error: 'No active tab' }); return; } + stopInspector(tabId).then(result => sendResponse(result)); + }); + return true; + } + + // Inspector: element picked by content script + if (msg.type === 'elementPicked') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const activeTabUrl = tabs?.[0]?.url || null; + const frameInfo = msg.frameSrc ? { frameSrc: msg.frameSrc, frameName: msg.frameName } : null; + postInspectorPick(msg.selector, frameInfo, msg.basicData, activeTabUrl) + .then(result => { + // Forward enriched result to sidepanel + chrome.runtime.sendMessage({ + type: 'inspectResult', + data: { + ...result, + selector: msg.selector, + tagName: msg.tagName, + classes: msg.classes, + id: msg.id, + dimensions: msg.dimensions, + basicData: msg.basicData, + frameInfo, + }, + }).catch(() => {}); + sendResponse({ ok: true }); + }); + }); + return true; + } + + // Inspector: picker cancelled + if (msg.type === 'pickerCancelled') { + chrome.runtime.sendMessage({ type: 'pickerCancelled' }).catch(() => {}); + return; + } + + // Inspector: route alteration commands to content script + if (msg.type === 'applyStyle' || msg.type === 'toggleClass' || msg.type === 'injectCSS' || msg.type === 'resetAll') { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + const tabId = tabs?.[0]?.id; + if (!tabId) { sendResponse({ error: 'No active tab' }); return; } + sendToContentScript(tabId, msg).then(result => sendResponse(result)); + }); + return true; + } + // Sidebar → browse server command proxy if (msg.type === 'command') { executeCommand(msg.command, msg.args).then(result => sendResponse(result)); diff --git a/extension/inspector.css b/extension/inspector.css new file mode 100644 index 000000000..cb032559f --- /dev/null +++ b/extension/inspector.css @@ -0,0 +1,29 @@ +/* gstack browse — CSS Inspector overlay styles + * Injected alongside inspector.js into the active tab. + * Design system: amber accent, zinc neutrals. + */ + +#gstack-inspector-highlight { + position: fixed; + pointer-events: none; + z-index: 2147483647; + background: rgba(59, 130, 246, 0.15); + border: 2px solid rgba(59, 130, 246, 0.6); + border-radius: 2px; + transition: top 50ms ease, left 50ms ease, width 50ms ease, height 50ms ease; +} + +#gstack-inspector-tooltip { + position: fixed; + pointer-events: none; + z-index: 2147483647; + background: #27272A; + color: #e0e0e0; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4); + line-height: 18px; +} diff --git a/extension/inspector.js b/extension/inspector.js new file mode 100644 index 000000000..01af66d91 --- /dev/null +++ b/extension/inspector.js @@ -0,0 +1,459 @@ +/** + * gstack browse — CSS Inspector content script + * + * Dynamically injected via chrome.scripting.executeScript. + * Provides element picker, selector generation, basic computed style capture, + * and page alteration handlers for agent-pushed CSS changes. + */ + +(() => { + // Guard against double-injection + if (window.__gstackInspectorActive) return; + window.__gstackInspectorActive = true; + + // ─── State ────────────────────────────────────────────────────── + let pickerActive = false; + let highlightEl = null; + let tooltipEl = null; + let lastPickTime = 0; + const PICK_DEBOUNCE_MS = 200; + + // Track original inline styles for resetAll + const originalStyles = new Map(); // element -> Map + const injectedStyleIds = new Set(); + + // ─── Highlight Overlay ────────────────────────────────────────── + + function createHighlight() { + if (highlightEl) return; + + highlightEl = document.createElement('div'); + highlightEl.id = 'gstack-inspector-highlight'; + highlightEl.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 2147483647; + background: rgba(59, 130, 246, 0.15); + border: 2px solid rgba(59, 130, 246, 0.6); + border-radius: 2px; + transition: top 50ms, left 50ms, width 50ms, height 50ms; + `; + document.documentElement.appendChild(highlightEl); + + tooltipEl = document.createElement('div'); + tooltipEl.id = 'gstack-inspector-tooltip'; + tooltipEl.style.cssText = ` + position: fixed; + pointer-events: none; + z-index: 2147483647; + background: #27272A; + color: #e0e0e0; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: 11px; + padding: 3px 8px; + border-radius: 4px; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + display: none; + `; + document.documentElement.appendChild(tooltipEl); + } + + function removeHighlight() { + if (highlightEl) { highlightEl.remove(); highlightEl = null; } + if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; } + } + + function updateHighlight(el) { + if (!highlightEl || !tooltipEl) return; + const rect = el.getBoundingClientRect(); + + highlightEl.style.top = rect.top + 'px'; + highlightEl.style.left = rect.left + 'px'; + highlightEl.style.width = rect.width + 'px'; + highlightEl.style.height = rect.height + 'px'; + highlightEl.style.display = 'block'; + + // Build tooltip text: .classes WxH + const tag = el.tagName.toLowerCase(); + const classes = el.className && typeof el.className === 'string' + ? '.' + el.className.trim().split(/\s+/).join('.') + : ''; + const dims = `${Math.round(rect.width)}x${Math.round(rect.height)}`; + tooltipEl.textContent = `<${tag}> ${classes} ${dims}`.trim(); + + // Position tooltip above element, or below if no room + const tooltipHeight = 24; + const gap = 6; + let tooltipTop = rect.top - tooltipHeight - gap; + if (tooltipTop < 4) tooltipTop = rect.bottom + gap; + let tooltipLeft = rect.left; + if (tooltipLeft < 4) tooltipLeft = 4; + + tooltipEl.style.top = tooltipTop + 'px'; + tooltipEl.style.left = tooltipLeft + 'px'; + tooltipEl.style.display = 'block'; + } + + // ─── Selector Generation ──────────────────────────────────────── + + function buildSelector(el) { + // If element has an id, use it directly + if (el.id) { + const sel = '#' + CSS.escape(el.id); + if (isUnique(sel)) return sel; + } + + // Build path from element up to nearest ancestor with id or body + const parts = []; + let current = el; + + while (current && current !== document.body && current !== document.documentElement) { + let part = current.tagName.toLowerCase(); + + // If current has an id, use it and stop + if (current.id) { + part = '#' + CSS.escape(current.id); + parts.unshift(part); + break; + } + + // Add classes + if (current.className && typeof current.className === 'string') { + const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); + if (classes.length > 0) { + part += '.' + classes.map(c => CSS.escape(c)).join('.'); + } + } + + // Add nth-child if needed to disambiguate + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter( + s => s.tagName === current.tagName + ); + if (siblings.length > 1) { + const idx = siblings.indexOf(current) + 1; + part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; + } + } + + parts.unshift(part); + current = current.parentElement; + } + + // If we didn't reach an id, prepend body + if (parts.length > 0 && !parts[0].startsWith('#')) { + // Don't prepend body, just use the path as-is + } + + const selector = parts.join(' > '); + + // Verify uniqueness + if (isUnique(selector)) return selector; + + // Fallback: add nth-child at each level until unique + return selector; + } + + function isUnique(selector) { + try { + return document.querySelectorAll(selector).length === 1; + } catch { + return false; + } + } + + // ─── Basic Mode Data Capture ──────────────────────────────────── + + const KEY_PROPERTIES = [ + 'display', 'position', 'top', 'right', 'bottom', 'left', + 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'color', 'background-color', 'background-image', + 'font-family', 'font-size', 'font-weight', 'line-height', 'letter-spacing', + 'text-align', 'text-decoration', 'text-transform', + 'overflow', 'overflow-x', 'overflow-y', + 'opacity', 'z-index', + 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', + 'grid-template-columns', 'grid-template-rows', + 'box-shadow', 'border-radius', + 'transition', 'transform', + ]; + + function captureBasicData(el) { + const computed = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + // Capture key computed properties + const computedStyles = {}; + for (const prop of KEY_PROPERTIES) { + computedStyles[prop] = computed.getPropertyValue(prop); + } + + // Box model from computed + const boxModel = { + content: { width: rect.width, height: rect.height }, + padding: { + top: parseFloat(computed.paddingTop) || 0, + right: parseFloat(computed.paddingRight) || 0, + bottom: parseFloat(computed.paddingBottom) || 0, + left: parseFloat(computed.paddingLeft) || 0, + }, + border: { + top: parseFloat(computed.borderTopWidth) || 0, + right: parseFloat(computed.borderRightWidth) || 0, + bottom: parseFloat(computed.borderBottomWidth) || 0, + left: parseFloat(computed.borderLeftWidth) || 0, + }, + margin: { + top: parseFloat(computed.marginTop) || 0, + right: parseFloat(computed.marginRight) || 0, + bottom: parseFloat(computed.marginBottom) || 0, + left: parseFloat(computed.marginLeft) || 0, + }, + }; + + // Matched CSS rules via CSSOM (same-origin only) + const matchedRules = []; + try { + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules || sheet.rules; + if (!rules) continue; + for (const rule of rules) { + if (rule.type !== CSSRule.STYLE_RULE) continue; + try { + if (el.matches(rule.selectorText)) { + const properties = []; + for (let i = 0; i < rule.style.length; i++) { + const prop = rule.style[i]; + properties.push({ + name: prop, + value: rule.style.getPropertyValue(prop), + priority: rule.style.getPropertyPriority(prop), + }); + } + matchedRules.push({ + selector: rule.selectorText, + properties, + source: sheet.href || 'inline', + }); + } + } catch { /* skip rules that can't be matched */ } + } + } catch { /* cross-origin sheet — silently skip */ } + } + } catch { /* CSSOM not available */ } + + return { computedStyles, boxModel, matchedRules }; + } + + // ─── Picker Event Handlers ────────────────────────────────────── + + function onMouseMove(e) { + if (!pickerActive) return; + // Ignore our own overlay elements + const target = e.target; + if (target === highlightEl || target === tooltipEl) return; + if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; + + updateHighlight(target); + } + + function onClick(e) { + if (!pickerActive) return; + + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + + // Debounce + const now = Date.now(); + if (now - lastPickTime < PICK_DEBOUNCE_MS) return; + lastPickTime = now; + + const target = e.target; + if (target === highlightEl || target === tooltipEl) return; + if (target.id === 'gstack-inspector-highlight' || target.id === 'gstack-inspector-tooltip') return; + + const selector = buildSelector(target); + const basicData = captureBasicData(target); + + // Frame detection + const frameInfo = {}; + if (window !== window.top) { + try { + frameInfo.frameSrc = window.location.href; + frameInfo.frameName = window.name || null; + } catch { /* cross-origin frame */ } + } + + chrome.runtime.sendMessage({ + type: 'elementPicked', + selector, + tagName: target.tagName.toLowerCase(), + classes: target.className && typeof target.className === 'string' + ? target.className.trim().split(/\s+/).filter(c => c.length > 0) + : [], + id: target.id || null, + dimensions: { + width: Math.round(target.getBoundingClientRect().width), + height: Math.round(target.getBoundingClientRect().height), + }, + basicData, + ...frameInfo, + }); + + // Keep highlight on the picked element + } + + function onKeyDown(e) { + if (!pickerActive) return; + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + stopPicker(); + chrome.runtime.sendMessage({ type: 'pickerCancelled' }); + } + } + + // ─── Picker Start/Stop ────────────────────────────────────────── + + function startPicker() { + if (pickerActive) return; + pickerActive = true; + createHighlight(); + document.addEventListener('mousemove', onMouseMove, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKeyDown, true); + } + + function stopPicker() { + if (!pickerActive) return; + pickerActive = false; + removeHighlight(); + document.removeEventListener('mousemove', onMouseMove, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKeyDown, true); + } + + // ─── Page Alteration Handlers ─────────────────────────────────── + + function findElement(selector) { + try { + return document.querySelector(selector); + } catch { + return null; + } + } + + function applyStyle(selector, property, value) { + // Validate property name: alphanumeric + hyphens only + if (!/^[a-zA-Z-]+$/.test(property)) return { error: 'Invalid property name' }; + + const el = findElement(selector); + if (!el) return { error: 'Element not found' }; + + // Track original value for resetAll + if (!originalStyles.has(el)) { + originalStyles.set(el, new Map()); + } + const origMap = originalStyles.get(el); + if (!origMap.has(property)) { + origMap.set(property, el.style.getPropertyValue(property)); + } + + el.style.setProperty(property, value, 'important'); + return { ok: true }; + } + + function toggleClass(selector, className, action) { + const el = findElement(selector); + if (!el) return { error: 'Element not found' }; + + if (action === 'add') { + el.classList.add(className); + } else if (action === 'remove') { + el.classList.remove(className); + } else { + el.classList.toggle(className); + } + return { ok: true }; + } + + function injectCSS(id, css) { + const styleId = `gstack-inject-${id}`; + let styleEl = document.getElementById(styleId); + if (!styleEl) { + styleEl = document.createElement('style'); + styleEl.id = styleId; + document.head.appendChild(styleEl); + } + styleEl.textContent = css; + injectedStyleIds.add(styleId); + return { ok: true }; + } + + function resetAll() { + // Restore original inline styles + for (const [el, propMap] of originalStyles) { + for (const [prop, origVal] of propMap) { + if (origVal) { + el.style.setProperty(prop, origVal); + } else { + el.style.removeProperty(prop); + } + } + } + originalStyles.clear(); + + // Remove injected style elements + for (const id of injectedStyleIds) { + const el = document.getElementById(id); + if (el) el.remove(); + } + injectedStyleIds.clear(); + + return { ok: true }; + } + + // ─── Message Listener ────────────────────────────────────────── + + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === 'startPicker') { + startPicker(); + sendResponse({ ok: true }); + return; + } + if (msg.type === 'stopPicker') { + stopPicker(); + sendResponse({ ok: true }); + return; + } + if (msg.type === 'applyStyle') { + const result = applyStyle(msg.selector, msg.property, msg.value); + sendResponse(result); + return; + } + if (msg.type === 'toggleClass') { + const result = toggleClass(msg.selector, msg.className, msg.action); + sendResponse(result); + return; + } + if (msg.type === 'injectCSS') { + const result = injectCSS(msg.id, msg.css); + sendResponse(result); + return; + } + if (msg.type === 'resetAll') { + const result = resetAll(); + sendResponse(result); + return; + } + }); +})(); diff --git a/extension/manifest.json b/extension/manifest.json index ea710e140..81b318048 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -3,7 +3,7 @@ "name": "gstack browse", "version": "0.1.0", "description": "Live activity feed and @ref overlays for gstack browse", - "permissions": ["sidePanel", "storage", "activeTab"], + "permissions": ["sidePanel", "storage", "activeTab", "scripting"], "host_permissions": ["http://127.0.0.1:*/"], "action": { "default_icon": { diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 855589616..55c7392a8 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -697,6 +697,496 @@ footer { flex-shrink: 0; } +/* ─── Inspector Tab ──────────────────────────────────── */ + +.inspector-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.inspector-pick-btn { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + background: none; + border: 1px solid var(--amber-500); + border-radius: var(--radius-sm); + color: var(--amber-500); + font-family: var(--font-system); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 150ms; + flex-shrink: 0; +} + +.inspector-pick-btn:hover { + background: rgba(245, 158, 11, 0.1); + color: var(--amber-400); +} + +.inspector-pick-btn.active { + background: var(--amber-500); + color: #000; +} + +.inspector-pick-icon { + font-size: 14px; + line-height: 1; +} + +.inspector-selected { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-body); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.inspector-mode-badge { + font-family: var(--font-mono); + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.inspector-mode-badge.basic { + background: var(--zinc-800); + color: var(--zinc-400); +} + +.inspector-mode-badge.cdp { + background: rgba(34, 197, 94, 0.15); + color: var(--success); +} + +/* Inspector content area */ +.inspector-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +/* Empty state */ +.inspector-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 24px; + text-align: center; + gap: 6px; +} + +.inspector-empty-icon { + font-size: 24px; + color: var(--zinc-600); + margin-bottom: 4px; +} + +.inspector-empty p { + color: var(--zinc-400); + font-size: 13px; + margin: 0; +} + +.inspector-empty .muted { + color: var(--zinc-600); + font-size: 12px; +} + +/* Loading state */ +.inspector-loading { + padding: 16px 12px; +} + +.inspector-loading-text { + font-size: 12px; + color: var(--amber-500); + margin-bottom: 12px; + animation: pulse 2s ease-in-out infinite; +} + +.inspector-skeleton { + display: flex; + flex-direction: column; + gap: 8px; +} + +.inspector-skeleton-bar { + height: 12px; + background: var(--zinc-800); + border-radius: var(--radius-sm); + animation: shimmer 1.5s ease-in-out infinite; +} + +.inspector-skeleton-bar:nth-child(1) { width: 80%; } +.inspector-skeleton-bar:nth-child(2) { width: 60%; } +.inspector-skeleton-bar:nth-child(3) { width: 70%; } + +@keyframes shimmer { + 0%, 100% { opacity: 0.3; } + 50% { opacity: 0.7; } +} + +/* Error state */ +.inspector-error { + padding: 16px 12px; + color: var(--error); + font-size: 12px; + font-family: var(--font-mono); +} + +/* Inspector sections */ +.inspector-section { + border-bottom: 1px solid var(--border-subtle); +} + +.inspector-section-header { + font-family: var(--font-system); + font-size: 13px; + font-weight: 600; + color: var(--zinc-400); + padding: 8px 12px 4px; +} + +.inspector-section-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 8px 12px; + background: none; + border: none; + font-family: var(--font-system); + font-size: 13px; + font-weight: 600; + color: var(--zinc-400); + cursor: pointer; + text-align: left; + transition: color 150ms; +} + +.inspector-section-toggle:hover { + color: var(--text-body); +} + +.inspector-toggle-arrow { + font-size: 10px; + color: var(--zinc-400); + flex-shrink: 0; + width: 12px; +} + +.inspector-section-body { + padding: 4px 12px 8px; +} + +.inspector-section-body.collapsed { + display: none; +} + +.inspector-rule-count { + font-size: 11px; + font-weight: 400; + color: var(--zinc-600); + margin-left: 4px; +} + +.inspector-no-data { + color: var(--zinc-600); + font-size: 11px; + font-style: italic; + padding: 4px 0; +} + +/* ─── Box Model ──────────────────────────────────────── */ + +.inspector-boxmodel { + padding: 8px 12px 12px; +} + +.boxmodel-margin, +.boxmodel-border, +.boxmodel-padding, +.boxmodel-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed; + text-align: center; +} + +.boxmodel-margin { + background: rgba(245, 158, 11, 0.08); + border-color: rgba(245, 158, 11, 0.3); + padding: 14px 20px; + border-radius: var(--radius-sm); +} + +.boxmodel-border { + background: rgba(161, 161, 170, 0.08); + border-color: rgba(161, 161, 170, 0.3); + padding: 14px 20px; + width: 100%; +} + +.boxmodel-padding { + background: rgba(34, 197, 94, 0.08); + border-color: rgba(34, 197, 94, 0.3); + padding: 14px 20px; + width: 100%; +} + +.boxmodel-content { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.3); + padding: 8px 12px; + width: 100%; + min-height: 28px; +} + +.boxmodel-content span { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-body); +} + +.boxmodel-label { + position: absolute; + top: 1px; + left: 4px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--zinc-400); + pointer-events: none; +} + +.boxmodel-value { + position: absolute; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-body); +} + +.boxmodel-value.boxmodel-top { top: 1px; left: 50%; transform: translateX(-50%); } +.boxmodel-value.boxmodel-right { right: 4px; top: 50%; transform: translateY(-50%); } +.boxmodel-value.boxmodel-bottom { bottom: 1px; left: 50%; transform: translateX(-50%); } +.boxmodel-value.boxmodel-left { left: 4px; top: 50%; transform: translateY(-50%); } + +/* ─── Matched Rules ──────────────────────────────────── */ + +.inspector-rule { + padding: 6px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.inspector-rule:last-child { + border-bottom: none; +} + +.inspector-rule-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 2px; +} + +.inspector-selector { + font-family: var(--font-mono); + font-size: 12px; + color: var(--amber-400); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 35ch; +} + +.inspector-specificity { + font-family: var(--font-mono); + font-size: 10px; + background: var(--zinc-600); + color: var(--zinc-400); + padding: 0 4px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.inspector-rule-props { + padding-left: 12px; +} + +.inspector-prop { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; +} + +.inspector-prop.overridden { + text-decoration: line-through; + opacity: 0.5; +} + +.inspector-prop-name { + color: var(--zinc-400); +} + +.inspector-prop-value { + color: var(--text-body); +} + +.inspector-important { + color: var(--error); + font-size: 10px; +} + +.inspector-rule-source { + font-family: var(--font-mono); + font-size: 11px; + color: var(--zinc-600); + margin-top: 2px; +} + +/* UA rules */ +.inspector-ua-rules { + margin-top: 4px; +} + +.inspector-ua-toggle { + display: flex; + align-items: center; + gap: 4px; + background: none; + border: none; + font-family: var(--font-mono); + font-size: 11px; + color: var(--zinc-600); + cursor: pointer; + padding: 4px 0; + transition: color 150ms; +} + +.inspector-ua-toggle:hover { + color: var(--zinc-400); +} + +.inspector-ua-body.collapsed { + display: none; +} + +/* ─── Computed Styles ────────────────────────────────── */ + +.inspector-computed-row { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + padding: 0 0 0 4px; +} + +.inspector-computed-row .inspector-prop-name { + color: var(--zinc-400); +} + +.inspector-computed-row .inspector-prop-value { + color: var(--text-body); +} + +/* ─── Quick Edit ─────────────────────────────────────── */ + +.inspector-quickedit-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.inspector-quickedit-row { + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + display: flex; + align-items: center; + gap: 4px; +} + +.inspector-quickedit-row .inspector-prop-name { + color: var(--zinc-400); + flex-shrink: 0; +} + +.inspector-quickedit-value { + color: var(--text-body); + cursor: pointer; + padding: 1px 4px; + border-radius: 2px; + transition: background 150ms; + min-width: 40px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-quickedit-value:hover { + background: var(--bg-hover); +} + +.inspector-quickedit-input { + font-family: var(--font-mono); + font-size: 12px; + background: var(--bg-base); + border: 1px solid var(--amber-500); + border-radius: 2px; + color: var(--text-heading); + padding: 1px 4px; + outline: none; + width: 100%; +} + +/* ─── Send to Agent ──────────────────────────────────── */ + +.inspector-send { + padding: 8px 12px; + background: var(--bg-surface); + border-top: 1px solid var(--border); + flex-shrink: 0; + position: sticky; + bottom: 0; +} + +.inspector-send-btn { + width: 100%; + height: 32px; + background: var(--amber-500); + border: none; + border-radius: var(--radius-md); + color: #000; + font-family: var(--font-system); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 150ms; +} + +.inspector-send-btn:hover { + background: var(--amber-400); +} + +.inspector-send-btn:active { + transform: scale(0.98); +} + /* ─── Accessibility ───────────────────────────────────── */ :focus-visible { outline: 2px solid var(--amber-500); diff --git a/extension/sidepanel.html b/extension/sidepanel.html index abbffb992..8e5b8fd4b 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -48,6 +48,83 @@ + +
+ +
+ + + +
+ + +
+ +
+
+

Pick an element to inspect

+

Click the button above, then click any element on the page

+
+ + + + + + + + + +
+ + + +
+ + + +
@@ -127,12 +130,13 @@
- + +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 546e2fecd..1ff287f7f 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -17,6 +17,10 @@ let serverToken = null; let chatLineCount = 0; let chatPollInterval = null; let connState = 'disconnected'; // disconnected | connected | reconnecting | dead +let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes +let sidebarActiveTabId = null; // which browser tab's chat we're showing +const chatLineCountByTab = {}; // tabId -> last seen chatLineCount +const chatDomByTab = {}; // tabId -> saved innerHTML let reconnectAttempts = 0; let reconnectTimer = null; const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" @@ -103,8 +107,12 @@ function addChatEntry(entry) { const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); - // User messages → chat bubble + // User messages → chat bubble (skip if we already rendered it optimistically) if (entry.role === 'user') { + if (lastOptimisticMsg === entry.message) { + lastOptimisticMsg = null; // consumed — don't skip next identical msg + return; + } const bubble = document.createElement('div'); bubble.className = 'chat-bubble user'; bubble.innerHTML = `${escapeHtml(entry.message)}${formatChatTime(entry.ts)}`; @@ -136,6 +144,13 @@ function addChatEntry(entry) { function handleAgentEvent(entry) { if (entry.type === 'agent_start') { + // If we already showed thinking dots optimistically in sendMessage(), + // don't duplicate. Just ensure fast polling is on. + if (agentContainer && document.getElementById('agent-thinking')) { + startFastPoll(); + updateStopButton(true); + return; + } // Create a new agent response container agentText = ''; agentContainer = document.createElement('div'); @@ -150,6 +165,8 @@ function handleAgentEvent(entry) { thinking.innerHTML = ''; agentContainer.appendChild(thinking); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); + startFastPoll(); + updateStopButton(true); return; } @@ -157,6 +174,8 @@ function handleAgentEvent(entry) { // Remove thinking indicator const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); + updateStopButton(false); + stopFastPoll(); // Add timestamp if (agentContainer) { const ts = document.createElement('span'); @@ -172,6 +191,8 @@ function handleAgentEvent(entry) { if (entry.type === 'agent_error') { const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); + updateStopButton(false); + stopFastPoll(); if (!agentContainer) { agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; @@ -200,7 +221,11 @@ function handleAgentEvent(entry) { toolEl.className = 'agent-tool'; const toolName = entry.tool || 'Tool'; const toolInput = entry.input || ''; - toolEl.innerHTML = `${escapeHtml(toolName)} ${escapeHtml(toolInput)}`; + + // Use the verbose description as the primary text + // The tool name becomes a subtle badge + const toolIcon = toolName === 'Bash' ? '▸' : toolName === 'Read' ? '📄' : toolName === 'Grep' ? '🔍' : toolName === 'Glob' ? '📁' : '⚡'; + toolEl.innerHTML = `${toolIcon} ${escapeHtml(toolInput)}`; agentContainer.appendChild(toolEl); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; @@ -251,8 +276,34 @@ async function sendMessage() { commandInput.disabled = true; sendBtn.disabled = true; + // Show user bubble + thinking dots IMMEDIATELY — don't wait for poll. + // This eliminates up to 1000ms of perceived latency. + lastOptimisticMsg = msg; + const welcome = chatMessages.querySelector('.chat-welcome'); + if (welcome) welcome.remove(); + const userBubble = document.createElement('div'); + userBubble.className = 'chat-bubble user'; + userBubble.innerHTML = `${escapeHtml(msg)}${formatChatTime(new Date().toISOString())}`; + chatMessages.appendChild(userBubble); + + agentText = ''; + agentContainer = document.createElement('div'); + agentContainer.className = 'agent-response'; + agentTextEl = null; + chatMessages.appendChild(agentContainer); + const thinking = document.createElement('div'); + thinking.className = 'agent-thinking'; + thinking.id = 'agent-thinking'; + thinking.innerHTML = ''; + agentContainer.appendChild(thinking); + agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); + updateStopButton(true); + + // Speed up polling while agent is working + startFastPoll(); + const result = await new Promise((resolve) => { - chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg }, resolve); + chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg, tabId: sidebarActiveTabId }, resolve); }); commandInput.disabled = false; @@ -260,7 +311,7 @@ async function sendMessage() { commandInput.focus(); if (result?.ok) { - // Immediately poll to show the user's own message + // Poll immediately to sync server state pollChat(); } else { commandInput.classList.add('error'); @@ -286,6 +337,7 @@ commandInput.addEventListener('keydown', (e) => { }); sendBtn.addEventListener('click', sendMessage); +document.getElementById('stop-agent-btn').addEventListener('click', stopAgent); // Poll for new chat messages let initialLoadDone = false; @@ -293,16 +345,25 @@ let initialLoadDone = false; async function pollChat() { if (!serverUrl || !serverToken) return; try { - const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}`, { + // Request chat for the currently displayed tab + const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : ''; + const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}${tabParam}`, { headers: authHeaders(), signal: AbortSignal.timeout(3000), }); if (!resp.ok) return; const data = await resp.json(); + // Detect tab switch from server — swap chat context + if (data.activeTabId !== undefined && data.activeTabId !== sidebarActiveTabId) { + switchChatTab(data.activeTabId); + return; // switchChatTab triggers a fresh poll + } + // First successful poll — hide loading spinner if (!initialLoadDone) { initialLoadDone = true; + sidebarActiveTabId = data.activeTabId ?? null; const loading = document.getElementById('chat-loading'); const welcome = document.getElementById('chat-welcome'); if (loading) loading.style.display = 'none'; @@ -319,6 +380,181 @@ async function pollChat() { } chatLineCount = data.total; } + + // Clean up orphaned thinking indicators after replay. + const thinking = document.getElementById('agent-thinking'); + if (thinking && data.agentStatus !== 'processing') { + thinking.remove(); + if (agentContainer) { + const notice = document.createElement('div'); + notice.className = 'agent-text'; + notice.style.color = 'var(--text-meta)'; + notice.style.fontStyle = 'italic'; + notice.textContent = '(session ended)'; + agentContainer.appendChild(notice); + agentContainer = null; + agentTextEl = null; + } + } + + // Show/hide stop button based on agent status + updateStopButton(data.agentStatus === 'processing'); + } catch {} +} + +/** Switch the sidebar to show a different tab's chat context */ +function switchChatTab(newTabId) { + if (newTabId === sidebarActiveTabId) return; + + // Save current tab's chat DOM + scroll position + if (sidebarActiveTabId !== null) { + chatDomByTab[sidebarActiveTabId] = chatMessages.innerHTML; + chatLineCountByTab[sidebarActiveTabId] = chatLineCount; + } + + sidebarActiveTabId = newTabId; + + // Restore saved chat for new tab, or show welcome + if (chatDomByTab[newTabId]) { + chatMessages.innerHTML = chatDomByTab[newTabId]; + chatLineCount = chatLineCountByTab[newTabId] || 0; + } else { + chatMessages.innerHTML = ` +
+
G
+

Send a message about this page.

+

Each tab has its own conversation.

+
`; + chatLineCount = 0; + } + + // Reset agent state for this tab + agentContainer = null; + agentTextEl = null; + agentText = ''; + + // Immediately poll the new tab's chat + pollChat(); +} + +function updateStopButton(agentRunning) { + const stopBtn = document.getElementById('stop-agent-btn'); + if (!stopBtn) return; + stopBtn.style.display = agentRunning ? '' : 'none'; +} + +async function stopAgent() { + if (!serverUrl) return; + try { + await fetch(`${serverUrl}/sidebar-agent/stop`, { method: 'POST', headers: authHeaders() }); + } catch {} + // Immediately clean up UI + const thinking = document.getElementById('agent-thinking'); + if (thinking) thinking.remove(); + if (agentContainer) { + const notice = document.createElement('div'); + notice.className = 'agent-text'; + notice.style.color = 'var(--text-meta)'; + notice.style.fontStyle = 'italic'; + notice.textContent = 'Stopped'; + agentContainer.appendChild(notice); + agentContainer = null; + agentTextEl = null; + } + updateStopButton(false); + stopFastPoll(); +} + +// ─── Adaptive poll speed ───────────────────────────────────────── +// 300ms while agent is working (fast first-token), 1000ms when idle. +const FAST_POLL_MS = 300; +const SLOW_POLL_MS = 1000; + +function startFastPoll() { + if (chatPollInterval) clearInterval(chatPollInterval); + chatPollInterval = setInterval(pollChat, FAST_POLL_MS); +} + +function stopFastPoll() { + if (chatPollInterval) clearInterval(chatPollInterval); + chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); +} + +// ─── Browser Tab Bar ───────────────────────────────────────────── +let tabPollInterval = null; +let lastTabJson = ''; + +async function pollTabs() { + if (!serverUrl || !serverToken) return; + try { + // Tell the server which Chrome tab the user is actually looking at. + // This syncs manual tab switches in the browser → server activeTabId. + let activeTabUrl = null; + try { + const chromeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + activeTabUrl = chromeTabs?.[0]?.url || null; + } catch {} + + const resp = await fetch(`${serverUrl}/sidebar-tabs${activeTabUrl ? '?activeUrl=' + encodeURIComponent(activeTabUrl) : ''}`, { + headers: authHeaders(), + signal: AbortSignal.timeout(2000), + }); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.tabs) return; + + // Only re-render if tabs changed + const json = JSON.stringify(data.tabs); + if (json === lastTabJson) return; + lastTabJson = json; + + renderTabBar(data.tabs); + } catch {} +} + +function renderTabBar(tabs) { + const bar = document.getElementById('browser-tabs'); + if (!bar) return; + + if (!tabs || tabs.length <= 1) { + bar.style.display = 'none'; + return; + } + + bar.style.display = ''; + bar.innerHTML = ''; + + for (const tab of tabs) { + const el = document.createElement('div'); + el.className = 'browser-tab' + (tab.active ? ' active' : ''); + el.title = tab.url || ''; + + // Show favicon-style domain + title + let label = tab.title || ''; + if (!label && tab.url) { + try { label = new URL(tab.url).hostname; } catch { label = tab.url; } + } + if (label.length > 20) label = label.slice(0, 20) + '…'; + + el.textContent = label || `Tab ${tab.id}`; + el.dataset.tabId = tab.id; + + el.addEventListener('click', () => switchBrowserTab(tab.id)); + bar.appendChild(el); + } +} + +async function switchBrowserTab(tabId) { + if (!serverUrl) return; + try { + await fetch(`${serverUrl}/sidebar-tabs/switch`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ id: tabId }), + }); + // Switch chat context + re-poll tabs + switchChatTab(tabId); + pollTabs(); } catch {} } @@ -960,12 +1196,17 @@ function updateConnection(url, token) { connectSSE(); connectInspectorSSE(); if (chatPollInterval) clearInterval(chatPollInterval); - chatPollInterval = setInterval(pollChat, 1000); + chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); pollChat(); + // Poll browser tabs every 2s (lightweight, just tab list) + if (tabPollInterval) clearInterval(tabPollInterval); + tabPollInterval = setInterval(pollTabs, 2000); + pollTabs(); } else { document.getElementById('footer-dot').className = 'dot'; document.getElementById('footer-port').textContent = ''; if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } + if (tabPollInterval) { clearInterval(tabPollInterval); tabPollInterval = null; } if (wasConnected) { startReconnect(); } @@ -1060,6 +1301,25 @@ chrome.runtime.onMessage.addListener((msg) => { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); } + // Instant tab switch — background.js fires this on chrome.tabs.onActivated + if (msg.type === 'browserTabActivated') { + // Tell the server which tab is now active, then switch chat context + if (serverUrl && serverToken) { + fetch(`${serverUrl}/sidebar-tabs?activeUrl=${encodeURIComponent(msg.url || '')}`, { + headers: authHeaders(), + signal: AbortSignal.timeout(2000), + }).then(r => r.json()).then(data => { + if (data.tabs) { + renderTabBar(data.tabs); + // Find the server-side tab ID for this Chrome tab + const activeTab = data.tabs.find(t => t.active); + if (activeTab && activeTab.id !== sidebarActiveTabId) { + switchChatTab(activeTab.id); + } + } + }).catch(() => {}); + } + } }); // ─── Chat Gate ────────────────────────────────────────────────── From 812882d1e6a8917073ecd30b5c84c20ca943694b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 22:19:33 -0700 Subject: [PATCH 08/28] test: per-tab isolation, BROWSE_TAB pinning, tab tracking, sidebar UX sidebar-agent.test.ts (new tests): - BROWSE_TAB env var passed to claude process - CLI reads BROWSE_TAB and sends tabId in body - handleCommand accepts tabId, saves/restores activeTabId - Tab pinning only activates when tabId provided - Per-tab agent state, queue, concurrency - processingTabs set for parallel agents sidebar-ux.test.ts (new tests): - context.on('page') tracks user-created tabs - page.on('close') removes tabs from pages map - Tab isolation uses BROWSE_TAB not system prompt hack - Per-tab chat context in sidepanel - Tab bar rendering, stop button, banner text Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/sidebar-agent.test.ts | 353 ++++++++++++++++ browse/test/sidebar-ux.test.ts | 676 ++++++++++++++++++++++++++++++ 2 files changed, 1029 insertions(+) create mode 100644 browse/test/sidebar-ux.test.ts diff --git a/browse/test/sidebar-agent.test.ts b/browse/test/sidebar-agent.test.ts index 2c8d49e91..ee77a33b8 100644 --- a/browse/test/sidebar-agent.test.ts +++ b/browse/test/sidebar-agent.test.ts @@ -67,6 +67,74 @@ function writeToInbox( return finalFile; } +/** Shorten paths — same logic as sidebar-agent.ts shorten() */ +function shorten(str: string): string { + return str + .replace(/\/Users\/[^/]+/g, '~') + .replace(/\/conductor\/workspaces\/[^/]+\/[^/]+/g, '') + .replace(/\.claude\/skills\/gstack\//g, '') + .replace(/browse\/dist\/browse/g, '$B'); +} + +/** describeToolCall — replicated from sidebar-agent.ts for unit testing */ +function describeToolCall(tool: string, input: any): string { + if (!input) return ''; + + if (tool === 'Bash' && input.command) { + const cmd = input.command; + const browseMatch = cmd.match(/\$B\s+(\w+)|browse[^\s]*\s+(\w+)/); + if (browseMatch) { + const browseCmd = browseMatch[1] || browseMatch[2]; + const args = cmd.split(/\s+/).slice(2).join(' '); + switch (browseCmd) { + case 'goto': return `Opening ${args.replace(/['"]/g, '')}`; + case 'snapshot': return args.includes('-i') ? 'Scanning for interactive elements' : args.includes('-D') ? 'Checking what changed' : 'Taking a snapshot of the page'; + case 'screenshot': return `Saving screenshot${args ? ` to ${shorten(args)}` : ''}`; + case 'click': return `Clicking ${args}`; + case 'fill': { const parts = args.split(/\s+/); return `Typing "${parts.slice(1).join(' ')}" into ${parts[0]}`; } + case 'text': return 'Reading page text'; + case 'html': return args ? `Reading HTML of ${args}` : 'Reading full page HTML'; + case 'links': return 'Finding all links on the page'; + case 'forms': return 'Looking for forms'; + case 'console': return 'Checking browser console for errors'; + case 'network': return 'Checking network requests'; + case 'url': return 'Checking current URL'; + case 'back': return 'Going back'; + case 'forward': return 'Going forward'; + case 'reload': return 'Reloading the page'; + case 'scroll': return args ? `Scrolling to ${args}` : 'Scrolling down'; + case 'wait': return `Waiting for ${args}`; + case 'inspect': return args ? `Inspecting CSS of ${args}` : 'Getting CSS for last picked element'; + case 'style': return `Changing CSS: ${args}`; + case 'cleanup': return 'Removing page clutter (ads, popups, banners)'; + case 'prettyscreenshot': return 'Taking a clean screenshot'; + case 'css': return `Checking CSS property: ${args}`; + case 'is': return `Checking if element is ${args}`; + case 'diff': return `Comparing ${args}`; + case 'responsive': return 'Taking screenshots at mobile, tablet, and desktop sizes'; + case 'status': return 'Checking browser status'; + case 'tabs': return 'Listing open tabs'; + case 'focus': return 'Bringing browser to front'; + case 'select': return `Selecting option in ${args}`; + case 'hover': return `Hovering over ${args}`; + case 'viewport': return `Setting viewport to ${args}`; + case 'upload': return `Uploading file to ${args.split(/\s+/)[0]}`; + default: return `Running browse ${browseCmd} ${args}`.trim(); + } + } + if (cmd.includes('git ')) return `Running: ${shorten(cmd)}`; + let short = shorten(cmd); + return short.length > 100 ? short.slice(0, 100) + '…' : short; + } + + if (tool === 'Read' && input.file_path) return `Reading ${shorten(input.file_path)}`; + if (tool === 'Edit' && input.file_path) return `Editing ${shorten(input.file_path)}`; + if (tool === 'Write' && input.file_path) return `Writing ${shorten(input.file_path)}`; + if (tool === 'Grep' && input.pattern) return `Searching for "${input.pattern}"`; + if (tool === 'Glob' && input.pattern) return `Finding files matching ${input.pattern}`; + try { return shorten(JSON.stringify(input)).slice(0, 80); } catch { return ''; } +} + // ─── Test setup ────────────────────────────────────────────────── let tmpDir: string; @@ -197,3 +265,288 @@ describe('writeToInbox', () => { expect(files.length).toBe(2); }); }); + +// ─── describeToolCall (verbose narration) ──────────────────────── + +describe('describeToolCall', () => { + // Browse navigation commands + test('goto → plain English with URL', () => { + const result = describeToolCall('Bash', { command: '$B goto https://example.com' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('goto strips quotes from URL', () => { + const result = describeToolCall('Bash', { command: '$B goto "https://example.com"' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('url → checking current URL', () => { + expect(describeToolCall('Bash', { command: '$B url' })).toBe('Checking current URL'); + }); + + test('back/forward/reload → plain English', () => { + expect(describeToolCall('Bash', { command: '$B back' })).toBe('Going back'); + expect(describeToolCall('Bash', { command: '$B forward' })).toBe('Going forward'); + expect(describeToolCall('Bash', { command: '$B reload' })).toBe('Reloading the page'); + }); + + // Snapshot variants + test('snapshot -i → scanning for interactive elements', () => { + expect(describeToolCall('Bash', { command: '$B snapshot -i' })).toBe('Scanning for interactive elements'); + }); + + test('snapshot -D → checking what changed', () => { + expect(describeToolCall('Bash', { command: '$B snapshot -D' })).toBe('Checking what changed'); + }); + + test('snapshot (plain) → taking a snapshot', () => { + expect(describeToolCall('Bash', { command: '$B snapshot' })).toBe('Taking a snapshot of the page'); + }); + + // Interaction commands + test('click → clicking element', () => { + expect(describeToolCall('Bash', { command: '$B click @e3' })).toBe('Clicking @e3'); + }); + + test('fill → typing into element', () => { + expect(describeToolCall('Bash', { command: '$B fill @e4 "hello world"' })).toBe('Typing ""hello world"" into @e4'); + }); + + test('scroll with selector → scrolling to element', () => { + expect(describeToolCall('Bash', { command: '$B scroll .footer' })).toBe('Scrolling to .footer'); + }); + + test('scroll without args → scrolling down', () => { + expect(describeToolCall('Bash', { command: '$B scroll' })).toBe('Scrolling down'); + }); + + // Reading commands + test('text → reading page text', () => { + expect(describeToolCall('Bash', { command: '$B text' })).toBe('Reading page text'); + }); + + test('html with selector → reading HTML of element', () => { + expect(describeToolCall('Bash', { command: '$B html .header' })).toBe('Reading HTML of .header'); + }); + + test('html without selector → reading full page HTML', () => { + expect(describeToolCall('Bash', { command: '$B html' })).toBe('Reading full page HTML'); + }); + + test('links → finding all links', () => { + expect(describeToolCall('Bash', { command: '$B links' })).toBe('Finding all links on the page'); + }); + + test('console → checking console', () => { + expect(describeToolCall('Bash', { command: '$B console' })).toBe('Checking browser console for errors'); + }); + + // Inspector commands + test('inspect with selector → inspecting CSS', () => { + expect(describeToolCall('Bash', { command: '$B inspect .header' })).toBe('Inspecting CSS of .header'); + }); + + test('inspect without args → getting last picked element', () => { + expect(describeToolCall('Bash', { command: '$B inspect' })).toBe('Getting CSS for last picked element'); + }); + + test('style → changing CSS', () => { + expect(describeToolCall('Bash', { command: '$B style .header color red' })).toBe('Changing CSS: .header color red'); + }); + + test('cleanup → removing page clutter', () => { + expect(describeToolCall('Bash', { command: '$B cleanup --all' })).toBe('Removing page clutter (ads, popups, banners)'); + }); + + // Visual commands + test('screenshot → saving screenshot', () => { + expect(describeToolCall('Bash', { command: '$B screenshot /tmp/shot.png' })).toBe('Saving screenshot to /tmp/shot.png'); + }); + + test('screenshot without path', () => { + expect(describeToolCall('Bash', { command: '$B screenshot' })).toBe('Saving screenshot'); + }); + + test('responsive → multi-size screenshots', () => { + expect(describeToolCall('Bash', { command: '$B responsive' })).toBe('Taking screenshots at mobile, tablet, and desktop sizes'); + }); + + // Non-browse tools + test('Read tool → reading file', () => { + expect(describeToolCall('Read', { file_path: '/Users/foo/project/src/app.ts' })).toBe('Reading ~/project/src/app.ts'); + }); + + test('Grep tool → searching for pattern', () => { + expect(describeToolCall('Grep', { pattern: 'handleClick' })).toBe('Searching for "handleClick"'); + }); + + test('Glob tool → finding files', () => { + expect(describeToolCall('Glob', { pattern: '**/*.tsx' })).toBe('Finding files matching **/*.tsx'); + }); + + test('Edit tool → editing file', () => { + expect(describeToolCall('Edit', { file_path: '/Users/foo/src/main.ts' })).toBe('Editing ~/src/main.ts'); + }); + + // Edge cases + test('null input → empty string', () => { + expect(describeToolCall('Bash', null)).toBe(''); + }); + + test('unknown browse command → generic description', () => { + expect(describeToolCall('Bash', { command: '$B newtab https://foo.com' })).toContain('newtab'); + }); + + test('non-browse bash → shortened command', () => { + expect(describeToolCall('Bash', { command: 'echo hello' })).toBe('echo hello'); + }); + + test('full browse binary path recognized', () => { + const result = describeToolCall('Bash', { command: '/Users/garrytan/.claude/skills/gstack/browse/dist/browse goto https://example.com' }); + expect(result).toBe('Opening https://example.com'); + }); + + test('tab command → switching tab', () => { + expect(describeToolCall('Bash', { command: '$B tab 2' })).toContain('tab'); + }); +}); + +// ─── Per-tab agent concurrency (source code validation) ────────── + +describe('per-tab agent concurrency', () => { + const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8'); + const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8'); + + test('server has per-tab agent state map', () => { + expect(serverSrc).toContain('tabAgents'); + expect(serverSrc).toContain('TabAgentState'); + expect(serverSrc).toContain('getTabAgent'); + }); + + test('server returns per-tab agent status in /sidebar-chat', () => { + expect(serverSrc).toContain('getTabAgentStatus'); + expect(serverSrc).toContain('tabAgentStatus'); + }); + + test('spawnClaude accepts forTabId parameter', () => { + const spawnFn = serverSrc.slice( + serverSrc.indexOf('function spawnClaude('), + serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), + ); + expect(spawnFn).toContain('forTabId'); + expect(spawnFn).toContain('tabState.status'); + }); + + test('sidebar-command endpoint uses per-tab agent state', () => { + expect(serverSrc).toContain('msgTabId'); + expect(serverSrc).toContain('tabState.status'); + expect(serverSrc).toContain('tabState.queue'); + }); + + test('agent event handler resets per-tab state', () => { + expect(serverSrc).toContain('eventTabId'); + expect(serverSrc).toContain('tabState.status = \'idle\''); + }); + + test('agent event handler processes per-tab queue', () => { + // After agent_done, should process next message from THIS tab's queue + expect(serverSrc).toContain('tabState.queue.length > 0'); + expect(serverSrc).toContain('tabState.queue.shift'); + }); + + test('sidebar-agent uses per-tab processing set', () => { + expect(agentSrc).toContain('processingTabs'); + expect(agentSrc).not.toContain('isProcessing'); + }); + + test('sidebar-agent sends tabId with all events', () => { + // sendEvent should accept tabId parameter + expect(agentSrc).toContain('async function sendEvent(event: Record, tabId?: number)'); + // askClaude should extract tabId from queue entry + expect(agentSrc).toContain('const { prompt, args, stateFile, cwd, tabId }'); + }); + + test('sidebar-agent allows concurrent agents across tabs', () => { + // poll() should not block globally — it should check per-tab + expect(agentSrc).toContain('processingTabs.has(tid)'); + // askClaude should be fire-and-forget (no await blocking the loop) + expect(agentSrc).toContain('askClaude(entry).catch'); + }); + + test('queue entries include tabId', () => { + const spawnFn = serverSrc.slice( + serverSrc.indexOf('function spawnClaude('), + serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), + ); + expect(spawnFn).toContain('tabId: agentTabId'); + }); + + test('health check monitors all per-tab agents', () => { + expect(serverSrc).toContain('for (const [tid, state] of tabAgents)'); + }); +}); + +describe('BROWSE_TAB tab pinning (cross-tab isolation)', () => { + const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'server.ts'), 'utf-8'); + const agentSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'sidebar-agent.ts'), 'utf-8'); + const cliSrc = fs.readFileSync(path.join(__dirname, '..', 'src', 'cli.ts'), 'utf-8'); + + test('sidebar-agent passes BROWSE_TAB env var to claude process', () => { + // The env block should include BROWSE_TAB set to the tab ID + expect(agentSrc).toContain('BROWSE_TAB'); + expect(agentSrc).toContain('String(tid)'); + }); + + test('CLI reads BROWSE_TAB and sends tabId in command body', () => { + expect(cliSrc).toContain('process.env.BROWSE_TAB'); + expect(cliSrc).toContain('tabId: parseInt(browseTab'); + }); + + test('handleCommand accepts tabId from request body', () => { + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommand(') + 1) > 0 + ? serverSrc.indexOf('\nasync function ', serverSrc.indexOf('async function handleCommand(') + 1) + : serverSrc.indexOf('\n// ', serverSrc.indexOf('async function handleCommand(') + 200), + ); + // Should destructure tabId from body + expect(handleFn).toContain('tabId'); + // Should save and restore the active tab + expect(handleFn).toContain('savedTabId'); + expect(handleFn).toContain('browserManager.switchTab(tabId)'); + }); + + test('handleCommand restores active tab after command (success path)', () => { + // On success, should restore savedTabId + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.length, + ); + // Count restore calls — should appear in both success and error paths + const restoreCount = (handleFn.match(/browserManager\.switchTab\(savedTabId\)/g) || []).length; + expect(restoreCount).toBeGreaterThanOrEqual(2); // success + error paths + }); + + test('handleCommand restores active tab on error path', () => { + // The catch block should also restore + const catchBlock = serverSrc.slice( + serverSrc.indexOf('} catch (err: any) {', serverSrc.indexOf('async function handleCommand(')), + ); + expect(catchBlock).toContain('switchTab(savedTabId)'); + }); + + test('tab pinning only activates when tabId is provided', () => { + const handleFn = serverSrc.slice( + serverSrc.indexOf('async function handleCommand('), + serverSrc.indexOf('try {', serverSrc.indexOf('async function handleCommand(') + 1), + ); + // Should check tabId is not undefined/null before switching + expect(handleFn).toContain('tabId !== undefined'); + expect(handleFn).toContain('tabId !== null'); + }); + + test('CLI only sends tabId when BROWSE_TAB is set', () => { + // Should conditionally include tabId in the body + expect(cliSrc).toContain('browseTab ? { tabId:'); + }); +}); diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts new file mode 100644 index 000000000..bc595e7ae --- /dev/null +++ b/browse/test/sidebar-ux.test.ts @@ -0,0 +1,676 @@ +/** + * Tests for sidebar UX changes: + * - System prompt does not bake in page URL (navigation fix) + * - --resume is never used (stale context fix) + * - /sidebar-chat response includes agentStatus + * - Sidebar HTML has updated banner, placeholder, stop button + * - Narration instructions present in system prompt + */ + +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; + +const ROOT = path.resolve(__dirname, '..'); + +// ─── System prompt tests (server.ts spawnClaude) ───────────────── + +describe('sidebar system prompt (server.ts)', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + test('system prompt does not bake in page URL', () => { + // The old prompt had: `The user is currently viewing: ${pageUrl}` + // The new prompt should NOT contain this pattern + // Extract the systemPrompt array from spawnClaude + const promptSection = serverSrc.slice( + serverSrc.indexOf('const systemPrompt = ['), + serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, + ); + expect(promptSection).not.toContain('currently viewing'); + expect(promptSection).not.toContain('${pageUrl}'); + }); + + test('system prompt tells agent to check URL before acting', () => { + const promptSection = serverSrc.slice( + serverSrc.indexOf('const systemPrompt = ['), + serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, + ); + expect(promptSection).toContain('NEVER'); + expect(promptSection).toContain('navigate back'); + expect(promptSection).toContain('NEVER assume'); + expect(promptSection).toContain('url`'); + }); + + test('system prompt includes narration instructions', () => { + const promptSection = serverSrc.slice( + serverSrc.indexOf('const systemPrompt = ['), + serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, + ); + expect(promptSection).toContain('Narrate'); + expect(promptSection).toContain('plain English'); + }); + + test('--resume is never used in spawnClaude args', () => { + // Extract the spawnClaude function + const fnStart = serverSrc.indexOf('function spawnClaude('); + const fnEnd = serverSrc.indexOf('\nfunction ', fnStart + 1); + const fnBody = serverSrc.slice(fnStart, fnEnd); + // Should not push --resume to args + expect(fnBody).not.toContain("'--resume'"); + expect(fnBody).not.toContain('"--resume"'); + }); + + test('system prompt includes inspect and style commands', () => { + const promptSection = serverSrc.slice( + serverSrc.indexOf('const systemPrompt = ['), + serverSrc.indexOf("].join('\\n');", serverSrc.indexOf('const systemPrompt = [')) + 15, + ); + expect(promptSection).toContain('inspect'); + expect(promptSection).toContain('style'); + expect(promptSection).toContain('cleanup'); + }); +}); + +// ─── /sidebar-chat response includes agentStatus ───────────────── + +describe('/sidebar-chat agentStatus', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + test('sidebar-chat response includes agentStatus field', () => { + // Find the GET /sidebar-chat handler — look for the data response, not the auth error + const handlerStart = serverSrc.indexOf("url.pathname === '/sidebar-chat'"); + // Find the response that returns entries + total (skip the auth error response) + const entriesResponse = serverSrc.indexOf('{ entries, total', handlerStart); + expect(entriesResponse).toBeGreaterThan(handlerStart); + const responseLine = serverSrc.slice(entriesResponse, entriesResponse + 100); + expect(responseLine).toContain('agentStatus'); + }); +}); + +// ─── Sidebar HTML tests ────────────────────────────────────────── + +describe('sidebar HTML (sidepanel.html)', () => { + const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8'); + + test('banner says "Browser co-pilot" not "Standalone mode"', () => { + expect(html).toContain('Browser co-pilot'); + expect(html).not.toContain('Standalone mode'); + }); + + test('input placeholder says "Ask about this page"', () => { + expect(html).toContain('Ask about this page'); + expect(html).not.toContain('Message Claude Code'); + }); + + test('stop button exists with id stop-agent-btn', () => { + expect(html).toContain('id="stop-agent-btn"'); + expect(html).toContain('class="stop-btn"'); + }); + + test('stop button is hidden by default', () => { + // The stop button should have style="display: none;" initially + const stopBtnMatch = html.match(/id="stop-agent-btn"[^>]*/); + expect(stopBtnMatch).not.toBeNull(); + expect(stopBtnMatch![0]).toContain('display: none'); + }); +}); + +// ─── Sidebar JS tests ─────────────────────────────────────────── + +describe('sidebar JS (sidepanel.js)', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + + test('stopAgent function exists', () => { + expect(js).toContain('async function stopAgent()'); + }); + + test('stopAgent calls /sidebar-agent/stop endpoint', () => { + expect(js).toContain('/sidebar-agent/stop'); + }); + + test('stop button click handler is wired up', () => { + expect(js).toContain("getElementById('stop-agent-btn')"); + expect(js).toContain('stopAgent'); + }); + + test('updateStopButton function exists', () => { + expect(js).toContain('function updateStopButton('); + }); + + test('agent_start shows stop button', () => { + // Find the agent_start handler and verify it calls updateStopButton(true) + const startHandler = js.slice( + js.indexOf("entry.type === 'agent_start'"), + js.indexOf("entry.type === 'agent_done'"), + ); + expect(startHandler).toContain('updateStopButton(true)'); + }); + + test('agent_done hides stop button', () => { + const doneHandler = js.slice( + js.indexOf("entry.type === 'agent_done'"), + js.indexOf("entry.type === 'agent_error'"), + ); + expect(doneHandler).toContain('updateStopButton(false)'); + }); + + test('agent_error hides stop button', () => { + const errorIdx = js.indexOf("entry.type === 'agent_error'"); + const errorHandler = js.slice(errorIdx, errorIdx + 500); + expect(errorHandler).toContain('updateStopButton(false)'); + }); + + test('orphaned thinking cleanup checks agentStatus from server', () => { + // After polling, if agentStatus !== processing, thinking dots are removed + expect(js).toContain("data.agentStatus !== 'processing'"); + }); + + test('orphaned thinking cleanup adds (session ended) notice', () => { + expect(js).toContain('(session ended)'); + }); + + test('sendMessage renders user bubble + thinking dots optimistically', () => { + // sendMessage should create user bubble and agent-thinking BEFORE the server responds + const sendFn = js.slice(js.indexOf('async function sendMessage()'), js.indexOf('async function sendMessage()') + 2000); + expect(sendFn).toContain('chat-bubble user'); + expect(sendFn).toContain('agent-thinking'); + expect(sendFn).toContain('lastOptimisticMsg'); + }); + + test('fast polling during agent execution (300ms), slow when idle (1000ms)', () => { + expect(js).toContain('FAST_POLL_MS'); + expect(js).toContain('SLOW_POLL_MS'); + expect(js).toContain('startFastPoll'); + expect(js).toContain('stopFastPoll'); + // Fast = 300ms + expect(js).toContain('300'); + // Slow = 1000ms + expect(js).toContain('1000'); + }); + + test('agent_done calls stopFastPoll', () => { + const doneHandler = js.slice( + js.indexOf("entry.type === 'agent_done'"), + js.indexOf("entry.type === 'agent_error'"), + ); + expect(doneHandler).toContain('stopFastPoll'); + }); + + test('duplicate user bubble prevention via lastOptimisticMsg', () => { + expect(js).toContain('lastOptimisticMsg'); + // When polled message matches optimistic, skip rendering + expect(js).toContain('lastOptimisticMsg === entry.message'); + }); +}); + +// ─── Sidebar agent queue poll (sidebar-agent.ts) ───────────────── + +describe('sidebar agent queue poll (sidebar-agent.ts)', () => { + const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); + + test('queue poll interval is 200ms or less for fast TTFO', () => { + const match = agentSrc.match(/const POLL_MS\s*=\s*(\d+)/); + expect(match).not.toBeNull(); + const pollMs = parseInt(match![1], 10); + expect(pollMs).toBeLessThanOrEqual(200); + }); +}); + +// ─── System prompt size (TTFO optimization) ────────────────────── + +describe('system prompt size', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + test('system prompt is compact (under 20 lines)', () => { + const start = serverSrc.indexOf('const systemPrompt = ['); + const end = serverSrc.indexOf("].join('\\n');", start); + const promptBlock = serverSrc.slice(start, end); + const lines = promptBlock.split('\n').length; + // Compact prompt = fewer input tokens = faster first response + // Slightly higher limit because of per-tab instruction line + expect(lines).toBeLessThan(20); + }); + + test('system prompt does not contain verbose narration examples', () => { + // We trimmed examples to reduce token count. The agent gets the + // instruction to narrate, not 6 examples of how. + const start = serverSrc.indexOf('const systemPrompt = ['); + const end = serverSrc.indexOf("].join('\\n');", start); + const promptBlock = serverSrc.slice(start, end); + expect(promptBlock).not.toContain('Examples of good narration'); + expect(promptBlock).not.toContain('I can see a login form'); + }); +}); + +// ─── TTFO latency chain invariants ────────────────────────────── + +describe('TTFO latency chain', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); + + test('optimistic render happens BEFORE chrome.runtime.sendMessage', () => { + // In sendMessage(), the bubble + thinking dots must be created + // before the async POST to the server + const sendFn = js.slice( + js.indexOf('async function sendMessage()'), + js.indexOf('async function sendMessage()') + 3000, + ); + const optimisticIdx = sendFn.indexOf('agent-thinking'); + const sendIdx = sendFn.indexOf('chrome.runtime.sendMessage'); + expect(optimisticIdx).toBeGreaterThan(0); + expect(sendIdx).toBeGreaterThan(0); + expect(optimisticIdx).toBeLessThan(sendIdx); + }); + + test('sendMessage calls startFastPoll before server request', () => { + const sendFn = js.slice( + js.indexOf('async function sendMessage()'), + js.indexOf('async function sendMessage()') + 3000, + ); + const fastPollIdx = sendFn.indexOf('startFastPoll'); + const sendIdx = sendFn.indexOf('chrome.runtime.sendMessage'); + expect(fastPollIdx).toBeGreaterThan(0); + expect(fastPollIdx).toBeLessThan(sendIdx); + }); + + test('agent_start from server does not duplicate thinking dots', () => { + // When we already showed dots optimistically, agent_start from + // the poll should skip creating a second set + const startHandler = js.slice( + js.indexOf("entry.type === 'agent_start'"), + js.indexOf("entry.type === 'agent_done'"), + ); + expect(startHandler).toContain('agent-thinking'); + // Should check if thinking already exists and skip + expect(startHandler).toContain("getElementById('agent-thinking')"); + }); + + test('FAST_POLL_MS is strictly less than SLOW_POLL_MS', () => { + const fastMatch = js.match(/FAST_POLL_MS\s*=\s*(\d+)/); + const slowMatch = js.match(/SLOW_POLL_MS\s*=\s*(\d+)/); + expect(fastMatch).not.toBeNull(); + expect(slowMatch).not.toBeNull(); + expect(parseInt(fastMatch![1], 10)).toBeLessThan(parseInt(slowMatch![1], 10)); + }); + + test('stopAgent also calls stopFastPoll', () => { + const stopFn = js.slice( + js.indexOf('async function stopAgent()'), + js.indexOf('async function stopAgent()') + 800, + ); + expect(stopFn).toContain('stopFastPoll'); + }); +}); + +// ─── Browser tab bar ──────────────────────────────────────────── + +describe('browser tab bar (server.ts)', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + test('/sidebar-tabs endpoint exists', () => { + expect(serverSrc).toContain("/sidebar-tabs'"); + expect(serverSrc).toContain('getTabListWithTitles'); + }); + + test('/sidebar-tabs/switch endpoint exists', () => { + expect(serverSrc).toContain("/sidebar-tabs/switch'"); + expect(serverSrc).toContain('switchTab'); + }); + + test('/sidebar-tabs requires auth', () => { + // Find the handler and verify auth check + const handlerIdx = serverSrc.indexOf("/sidebar-tabs'"); + const handlerBlock = serverSrc.slice(handlerIdx, handlerIdx + 300); + expect(handlerBlock).toContain('validateAuth'); + }); +}); + +describe('browser tab bar (sidepanel.js)', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + + test('pollTabs function exists and calls /sidebar-tabs', () => { + expect(js).toContain('async function pollTabs()'); + expect(js).toContain('/sidebar-tabs'); + }); + + test('renderTabBar function exists', () => { + expect(js).toContain('function renderTabBar(tabs)'); + }); + + test('tab bar hidden when only 1 tab', () => { + const renderFn = js.slice( + js.indexOf('function renderTabBar('), + js.indexOf('function renderTabBar(') + 600, + ); + expect(renderFn).toContain('tabs.length <= 1'); + expect(renderFn).toContain("display = 'none'"); + }); + + test('switchBrowserTab calls /sidebar-tabs/switch', () => { + expect(js).toContain('async function switchBrowserTab('); + expect(js).toContain('/sidebar-tabs/switch'); + }); + + test('tab polling interval is set on connection', () => { + expect(js).toContain('tabPollInterval'); + expect(js).toContain('setInterval(pollTabs'); + }); + + test('tab polling cleaned up on disconnect', () => { + expect(js).toContain('clearInterval(tabPollInterval)'); + }); + + test('only re-renders when tabs change (diff check)', () => { + expect(js).toContain('lastTabJson'); + expect(js).toContain('json === lastTabJson'); + }); +}); + +describe('browser tab bar (sidepanel.html)', () => { + const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8'); + + test('browser-tabs container exists', () => { + expect(html).toContain('id="browser-tabs"'); + }); + + test('browser-tabs hidden by default', () => { + const match = html.match(/id="browser-tabs"[^>]*/); + expect(match).not.toBeNull(); + expect(match![0]).toContain('display:none'); + }); +}); + +// ─── Bidirectional tab sync ────────────────────────────────────── + +describe('sidebar→browser tab switch', () => { + const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); + + test('switchTab calls bringToFront so browser visually switches', () => { + const switchFn = bmSrc.slice( + bmSrc.indexOf('switchTab(id: number)'), + bmSrc.indexOf('switchTab(id: number)') + 400, + ); + expect(switchFn).toContain('bringToFront'); + }); +}); + +describe('browser→sidebar tab sync', () => { + const bmSrc = fs.readFileSync(path.join(ROOT, 'src', 'browser-manager.ts'), 'utf-8'); + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + + test('syncActiveTabByUrl method exists on BrowserManager', () => { + expect(bmSrc).toContain('syncActiveTabByUrl(activeUrl: string)'); + }); + + test('syncActiveTabByUrl updates activeTabId when URL matches a different tab', () => { + const fn = bmSrc.slice( + bmSrc.indexOf('syncActiveTabByUrl('), + bmSrc.indexOf('syncActiveTabByUrl(') + 1200, + ); + expect(fn).toContain('this.activeTabId = id'); + // Exact match + expect(fn).toContain('pageUrl === activeUrl'); + // Fuzzy match (origin+pathname) + expect(fn).toContain('activeOriginPath'); + expect(fn).toContain('fuzzyId'); + }); + + test('context.on("page") tracks user-created tabs', () => { + expect(bmSrc).toContain("context.on('page'"); + expect(bmSrc).toContain('this.pages.set(id, page)'); + // Should log when new tab detected + expect(bmSrc).toContain('New tab detected'); + }); + + test('page close handler removes tab from pages map', () => { + expect(bmSrc).toContain("page.on('close'"); + expect(bmSrc).toContain('this.pages.delete(id)'); + expect(bmSrc).toContain('Tab closed'); + }); + + test('syncActiveTabByUrl skips when only 1 tab (no ambiguity)', () => { + const fn = bmSrc.slice( + bmSrc.indexOf('syncActiveTabByUrl('), + bmSrc.indexOf('syncActiveTabByUrl(') + 600, + ); + expect(fn).toContain('this.pages.size <= 1'); + }); + + test('/sidebar-tabs reads activeUrl param and calls syncActiveTabByUrl', () => { + const handler = serverSrc.slice( + serverSrc.indexOf("/sidebar-tabs'"), + serverSrc.indexOf("/sidebar-tabs'") + 500, + ); + expect(handler).toContain("get('activeUrl')"); + expect(handler).toContain('syncActiveTabByUrl'); + }); + + test('/sidebar-command syncs activeTabUrl BEFORE reading tabId', () => { + // The server must call syncActiveTabByUrl before getActiveTabId + // so the agent targets the correct tab + const cmdIdx = serverSrc.indexOf("url.pathname === '/sidebar-command'"); + const handler = serverSrc.slice(cmdIdx, cmdIdx + 1200); + const syncIdx = handler.indexOf('syncActiveTabByUrl'); + const getIdIdx = handler.indexOf('getActiveTabId'); + expect(syncIdx).toBeGreaterThan(0); + expect(getIdIdx).toBeGreaterThan(syncIdx); // sync happens BEFORE reading ID + }); + + test('background.js listens for chrome.tabs.onActivated', () => { + const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8'); + expect(bgSrc).toContain('chrome.tabs.onActivated.addListener'); + expect(bgSrc).toContain('browserTabActivated'); + }); + + test('sidepanel handles browserTabActivated message instantly', () => { + expect(js).toContain("msg.type === 'browserTabActivated'"); + // Should call switchChatTab for instant context swap + expect(js).toContain('switchChatTab'); + }); + + test('pollTabs sends Chrome active tab URL to server', () => { + const pollFn = js.slice( + js.indexOf('async function pollTabs()'), + js.indexOf('async function pollTabs()') + 800, + ); + expect(pollFn).toContain('chrome.tabs.query'); + expect(pollFn).toContain('activeUrl='); + }); +}); + +describe('browser tab bar (sidepanel.css)', () => { + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('browser-tabs styles exist', () => { + expect(css).toContain('.browser-tabs'); + expect(css).toContain('.browser-tab'); + expect(css).toContain('.browser-tab.active'); + }); + + test('tab bar is horizontally scrollable', () => { + const barStyle = css.slice( + css.indexOf('.browser-tabs {'), + css.indexOf('}', css.indexOf('.browser-tabs {')) + 1, + ); + expect(barStyle).toContain('overflow-x: auto'); + }); + + test('active tab is visually distinct', () => { + const activeStyle = css.slice( + css.indexOf('.browser-tab.active {'), + css.indexOf('}', css.indexOf('.browser-tab.active {')) + 1, + ); + expect(activeStyle).toContain('--bg-surface'); + expect(activeStyle).toContain('--text-body'); + }); +}); + +// ─── Event relay (processAgentEvent) ──────────────────────────── + +describe('processAgentEvent handles sidebar-agent event types', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + // Extract processAgentEvent function body + const fnStart = serverSrc.indexOf('function processAgentEvent('); + const fnEnd = serverSrc.indexOf('\nfunction ', fnStart + 1); + const fnBody = serverSrc.slice(fnStart, fnEnd > fnStart ? fnEnd : fnStart + 2000); + + test('handles tool_use events directly (not raw Claude stream format)', () => { + // Must handle { type: 'tool_use', tool, input } from sidebar-agent + expect(fnBody).toContain("event.type === 'tool_use'"); + expect(fnBody).toContain('event.tool'); + expect(fnBody).toContain('event.input'); + }); + + test('handles text_delta events directly', () => { + expect(fnBody).toContain("event.type === 'text_delta'"); + expect(fnBody).toContain('event.text'); + }); + + test('handles text events directly', () => { + expect(fnBody).toContain("event.type === 'text'"); + }); + + test('handles result events', () => { + expect(fnBody).toContain("event.type === 'result'"); + }); + + test('handles agent_error events', () => { + expect(fnBody).toContain("event.type === 'agent_error'"); + expect(fnBody).toContain('event.error'); + }); + + test('does NOT re-parse raw Claude stream events (no content_block_start)', () => { + // sidebar-agent.ts already transforms these. Server should not duplicate. + expect(fnBody).not.toContain('content_block_start'); + expect(fnBody).not.toContain('content_block_delta'); + expect(fnBody).not.toContain("event.type === 'assistant'"); + }); + + test('all event types call addChatEntry with role: agent', () => { + // Every addChatEntry in processAgentEvent should have role: 'agent' + const addCalls = fnBody.match(/addChatEntry\(\{[^}]+\}\)/g) || []; + for (const call of addCalls) { + expect(call).toContain("role: 'agent'"); + } + }); +}); + +// ─── Per-tab chat context ──────────────────────────────────────── + +describe('per-tab chat context (server.ts)', () => { + const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); + + test('/sidebar-chat accepts tabId query param', () => { + const handler = serverSrc.slice( + serverSrc.indexOf("/sidebar-chat'"), + serverSrc.indexOf("/sidebar-chat'") + 600, + ); + expect(handler).toContain('tabId'); + }); + + test('addChatEntry takes a tabId parameter', () => { + // addChatEntry should route entries to the correct tab's buffer + expect(serverSrc).toContain('tabId'); + // Look for tabId in addChatEntry function + const fnIdx = serverSrc.indexOf('function addChatEntry('); + if (fnIdx > -1) { + const fnBody = serverSrc.slice(fnIdx, fnIdx + 300); + expect(fnBody).toContain('tabId'); + } + }); + + test('spawnClaude passes active tab ID to queue entry', () => { + const spawnFn = serverSrc.slice( + serverSrc.indexOf('function spawnClaude('), + serverSrc.indexOf('\nfunction ', serverSrc.indexOf('function spawnClaude(') + 1), + ); + expect(spawnFn).toContain('tabId'); + }); + + test('tab isolation uses BROWSE_TAB env var instead of system prompt hack', () => { + const agentSrc = fs.readFileSync(path.join(ROOT, 'src', 'sidebar-agent.ts'), 'utf-8'); + // Agent passes BROWSE_TAB env var to claude (not a system prompt instruction) + expect(agentSrc).toContain('BROWSE_TAB'); + // Server handleCommand reads tabId from body and pins to that tab + expect(serverSrc).toContain('savedTabId'); + expect(serverSrc).toContain('switchTab(tabId)'); + }); +}); + +describe('per-tab chat context (sidepanel.js)', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + + test('tracks activeTabId for chat context', () => { + expect(js).toContain('activeTabId'); + }); + + test('pollChat sends tabId to server', () => { + const pollFn = js.slice( + js.indexOf('async function pollChat()'), + js.indexOf('async function pollChat()') + 600, + ); + expect(pollFn).toContain('tabId'); + }); + + test('switching tabs swaps displayed chat', () => { + // When tab changes, old chat is saved and new tab's chat is shown + expect(js).toContain('switchChatTab'); + }); + + test('switchChatTab saves current tab DOM and restores new tab', () => { + const fn = js.slice( + js.indexOf('function switchChatTab('), + js.indexOf('function switchChatTab(') + 800, + ); + expect(fn).toContain('chatDomByTab'); + expect(fn).toContain('innerHTML'); + }); + + test('sendMessage includes tabId in message', () => { + const sendFn = js.slice( + js.indexOf('async function sendMessage()'), + js.indexOf('async function sendMessage()') + 2000, + ); + expect(sendFn).toContain('tabId'); + expect(sendFn).toContain('sidebarActiveTabId'); + }); +}); + +// ─── Sidebar CSS tests ────────────────────────────────────────── + +describe('sidebar CSS (sidepanel.css)', () => { + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('stop button style exists', () => { + expect(css).toContain('.stop-btn'); + }); + + test('stop button uses error color', () => { + const stopBtnSection = css.slice( + css.indexOf('.stop-btn {'), + css.indexOf('}', css.indexOf('.stop-btn {')) + 1, + ); + expect(stopBtnSection).toContain('--error'); + }); + + test('experimental-banner no longer uses amber warning colors', () => { + const bannerSection = css.slice( + css.indexOf('.experimental-banner {'), + css.indexOf('}', css.indexOf('.experimental-banner {')) + 1, + ); + // Should not be amber/warning anymore + expect(bannerSection).not.toContain('245, 158, 11, 0.15'); + expect(bannerSection).not.toContain('#F59E0B'); + }); + + test('tool description uses system font not mono', () => { + const toolSection = css.slice( + css.indexOf('.agent-tool {'), + css.indexOf('}', css.indexOf('.agent-tool {')) + 1, + ); + expect(toolSection).toContain('font-system'); + expect(toolSection).not.toContain('font-mono'); + }); +}); From 91d2f73a677dbaaef3321ac9afb80d4ec49e1dc7 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 22:27:42 -0700 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20resolve=20merge=20conflicts=20?= =?UTF-8?q?=E2=80=94=20keep=20security=20defenses=20+=20per-tab=20isolatio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged main's security improvements (XML escaping, prompt injection defense, allowed commands whitelist, --model opus, Write tool, stderr capture) with our branch's per-tab isolation (BROWSE_TAB env var, processingTabs set, no --resume). Updated test expectations for expanded system prompt. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/server.ts | 4 ++-- browse/test/sidebar-security.test.ts | 2 +- browse/test/sidebar-ux.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/browse/src/server.ts b/browse/src/server.ts index d70c98c2e..1e054d2c0 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -480,8 +480,8 @@ function spawnClaude(userMessage: string, extensionUrl?: string | null, forTabId const prompt = `${systemPrompt}\n\n\n${escapedMessage}\n`; // Never resume — each message is a fresh context. Resuming carries stale // page URLs and old navigation state that makes the agent fight the user. - const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose', - '--allowedTools', 'Bash,Read,Glob,Grep']; + const args = ['-p', prompt, '--model', 'opus', '--output-format', 'stream-json', '--verbose', + '--allowedTools', 'Bash,Read,Glob,Grep,Write']; addChatEntry({ ts: new Date().toISOString(), role: 'agent', type: 'agent_start' }); diff --git a/browse/test/sidebar-security.test.ts b/browse/test/sidebar-security.test.ts index 33c64b497..71f2190a0 100644 --- a/browse/test/sidebar-security.test.ts +++ b/browse/test/sidebar-security.test.ts @@ -110,7 +110,7 @@ describe('Sidebar prompt injection defense', () => { // It should NOT rebuild args from scratch (the old bug) expect(AGENT_SRC).toContain('args || ['); // Verify the destructured args come from queueEntry - expect(AGENT_SRC).toContain('const { prompt, args, stateFile, cwd } = queueEntry'); + expect(AGENT_SRC).toContain('const { prompt, args, stateFile, cwd, tabId } = queueEntry'); }); test('sidebar-agent falls back to defaults if queue has no args', () => { diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index bc595e7ae..fe96006ea 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -221,14 +221,14 @@ describe('sidebar agent queue poll (sidebar-agent.ts)', () => { describe('system prompt size', () => { const serverSrc = fs.readFileSync(path.join(ROOT, 'src', 'server.ts'), 'utf-8'); - test('system prompt is compact (under 20 lines)', () => { + test('system prompt is compact (under 30 lines)', () => { const start = serverSrc.indexOf('const systemPrompt = ['); const end = serverSrc.indexOf("].join('\\n');", start); const promptBlock = serverSrc.slice(start, end); const lines = promptBlock.split('\n').length; // Compact prompt = fewer input tokens = faster first response - // Slightly higher limit because of per-tab instruction line - expect(lines).toBeLessThan(20); + // Higher limit accommodates security lines (prompt injection defense, allowed commands) + expect(lines).toBeLessThan(30); }); test('system prompt does not contain verbose narration examples', () => { From f4ab540f9b1643835102a22c054afd724be67058 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 22:28:04 -0700 Subject: [PATCH 10/28] chore: bump version and changelog (v0.13.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 20 ++++++++++++++++++++ VERSION | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c40875d..3d9ca24ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [0.13.9.0] - 2026-03-29 — Sidebar CSS Inspector + Per-Tab Agents + +The sidebar is now a visual design tool. Pick any element on the page and see the full CSS rule cascade, box model, and computed styles right in the Side Panel. Edit styles live and see changes instantly. Each browser tab gets its own independent agent, so you can work on multiple pages simultaneously without cross-talk. + +### Added + +- **CSS Inspector in the sidebar.** Click "Pick Element", hover over anything, click it, and the sidebar shows the full CSS rule cascade with specificity badges, source file:line, box model visualization (gstack palette colors), and computed styles. Like Chrome DevTools, but inside the sidebar. +- **Live style editing.** `$B style .selector property value` modifies CSS rules in real time via CDP. Changes show instantly on the page. Undo with `$B style --undo`. +- **Per-tab agents.** Each browser tab gets its own Claude agent process. Switch tabs in the browser and the sidebar swaps to that tab's chat history. Ask questions about different pages in parallel without agents fighting over which tab is active. +- **Tab tracking.** User-created tabs (Cmd+T, right-click "Open in new tab") are automatically tracked. The sidebar tab bar updates in real time. Click a tab in the sidebar to switch the browser. Close a tab and it disappears from the bar. +- **Page cleanup.** `$B cleanup --all` removes ads, cookie banners, sticky headers, and social widgets for clean screenshots. +- **Pretty screenshots.** `$B prettyscreenshot --cleanup --scroll-to ".pricing" ~/Desktop/hero.png` combines cleanup, scroll positioning, and screenshot in one command. +- **Stop button.** A red stop button appears in the sidebar when an agent is working. Click it to cancel the current task. + +### Changed + +- **Sidebar banner** now says "Browser co-pilot" instead of the old mode-specific text. +- **Input placeholder** is "Ask about this page..." (more inviting than the old placeholder). +- **System prompt** includes prompt injection defense and allowed-commands whitelist from the security audit. + ## [0.13.8.0] - 2026-03-29 — Security Audit Round 2 Browse output is now wrapped in trust boundary markers so agents can tell page content from tool output. Markers are escape-proof. The Chrome extension validates message senders. CDP binds to localhost only. Bun installs use checksum verification. diff --git a/VERSION b/VERSION index f4040e84c..1ef377f37 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.13.8.0 +0.13.9.0 From 6238edd5d78c0298182ebb4ac3ffd387d65804e6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:10:45 -0700 Subject: [PATCH 11/28] fix: add inspector message types to background.js allowlist Pre-existing bug found by Codex: ALLOWED_TYPES in background.js was missing all inspector message types (startInspector, stopInspector, elementPicked, pickerCancelled, applyStyle, toggleClass, injectCSS, resetAll, inspectResult). Messages were silently rejected, making the inspector broken on ALL pages. Also: separate executeScript and insertCSS into individual try blocks in injectInspector(), store inspectorMode for routing, and add content.js fallback when script injection fails (CSP, chrome:// pages). Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/background.js | 45 ++++++++++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/extension/background.js b/extension/background.js index 9e253c87b..4084acaf3 100644 --- a/extension/background.js +++ b/extension/background.js @@ -160,24 +160,41 @@ async function fetchAndRelayRefs() { // ─── Inspector ────────────────────────────────────────────────── +// Track inspector mode per tab — 'full' (inspector.js injected) or 'basic' (content.js fallback) +let inspectorMode = 'full'; + async function injectInspector(tabId) { + // Try full inspector injection first try { await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, files: ['inspector.js'], }); - await chrome.scripting.insertCSS({ - target: { tabId, allFrames: true }, - files: ['inspector.css'], - }); - } catch (err) { - return { error: 'Cannot inspect this page (CSP restriction)' }; + // CSS injection failure alone doesn't need fallback + try { + await chrome.scripting.insertCSS({ + target: { tabId, allFrames: true }, + files: ['inspector.css'], + }); + } catch {} + // Send startPicker to the injected inspector.js + try { + await chrome.tabs.sendMessage(tabId, { type: 'startPicker' }); + } catch {} + inspectorMode = 'full'; + return { ok: true, mode: 'full' }; + } catch { + // Script injection failed (CSP, chrome:// page, etc.) + // Fall back to content.js basic picker (loaded by manifest on most pages) + try { + await chrome.tabs.sendMessage(tabId, { type: 'startBasicPicker' }); + inspectorMode = 'basic'; + return { ok: true, mode: 'basic' }; + } catch { + inspectorMode = 'full'; + return { error: 'Cannot inspect this page' }; + } } - // Send startPicker to all frames - try { - await chrome.tabs.sendMessage(tabId, { type: 'startPicker' }); - } catch {} - return { ok: true }; } async function stopInspector(tabId) { @@ -236,7 +253,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const ALLOWED_TYPES = new Set([ 'getPort', 'setPort', 'getServerUrl', 'fetchRefs', - 'openSidePanel', 'command', 'sidebar-command' + 'openSidePanel', 'command', 'sidebar-command', + // Inspector message types + 'startInspector', 'stopInspector', 'elementPicked', 'pickerCancelled', + 'applyStyle', 'toggleClass', 'injectCSS', 'resetAll', + 'inspectResult' ]); if (!ALLOWED_TYPES.has(msg.type)) { console.warn('[gstack] Rejected unknown message type:', msg.type); From 8d656285a60f42cc472947ccfef53cfb03ffaa38 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:11:01 -0700 Subject: [PATCH 12/28] feat: basic element picker in content.js for CSP-restricted pages When inspector.js can't be injected (CSP, chrome:// pages), content.js provides a basic picker using getComputedStyle + CSSOM: - startBasicPicker/stopBasicPicker message handlers - captureBasicData() with ~30 key CSS properties, box model, matched rules - Hover highlight with outline save/restore (never leaves artifacts) - Click uses e.target directly (no re-querying by selector) - Sends inspectResult with mode:'basic' for sidebar rendering - Escape key cancels picker and restores outlines Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/content.js | 209 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/extension/content.js b/extension/content.js index 3c023f609..a3f887b05 100644 --- a/extension/content.js +++ b/extension/content.js @@ -125,8 +125,217 @@ function renderRefPanel(refs) { container.appendChild(panel); } +// ─── Basic Inspector Picker (CSP fallback) ────────────────── +// When inspector.js can't be injected (CSP, chrome:// pages), content.js +// provides a basic element picker using getComputedStyle + CSSOM. + +let basicPickerActive = false; +let basicPickerOverlay = null; +let basicPickerLastEl = null; +let basicPickerSavedOutline = ''; + +const BASIC_KEY_PROPERTIES = [ + 'display', 'position', 'top', 'right', 'bottom', 'left', + 'width', 'height', 'min-width', 'max-width', 'min-height', 'max-height', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'color', 'background-color', 'background-image', + 'font-family', 'font-size', 'font-weight', 'line-height', + 'text-align', 'text-decoration', + 'overflow', 'overflow-x', 'overflow-y', + 'opacity', 'z-index', + 'flex-direction', 'justify-content', 'align-items', 'flex-wrap', 'gap', + 'grid-template-columns', 'grid-template-rows', + 'box-shadow', 'border-radius', 'transform', +]; + +function captureBasicData(el) { + const computed = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + const computedStyles = {}; + for (const prop of BASIC_KEY_PROPERTIES) { + computedStyles[prop] = computed.getPropertyValue(prop); + } + + const boxModel = { + content: { width: rect.width, height: rect.height }, + padding: { + top: parseFloat(computed.paddingTop) || 0, + right: parseFloat(computed.paddingRight) || 0, + bottom: parseFloat(computed.paddingBottom) || 0, + left: parseFloat(computed.paddingLeft) || 0, + }, + border: { + top: parseFloat(computed.borderTopWidth) || 0, + right: parseFloat(computed.borderRightWidth) || 0, + bottom: parseFloat(computed.borderBottomWidth) || 0, + left: parseFloat(computed.borderLeftWidth) || 0, + }, + margin: { + top: parseFloat(computed.marginTop) || 0, + right: parseFloat(computed.marginRight) || 0, + bottom: parseFloat(computed.marginBottom) || 0, + left: parseFloat(computed.marginLeft) || 0, + }, + }; + + // Matched CSS rules via CSSOM (same-origin only) + const matchedRules = []; + try { + for (const sheet of document.styleSheets) { + try { + const rules = sheet.cssRules || sheet.rules; + if (!rules) continue; + for (const rule of rules) { + if (rule.type !== CSSRule.STYLE_RULE) continue; + try { + if (el.matches(rule.selectorText)) { + const properties = []; + for (let i = 0; i < rule.style.length; i++) { + const prop = rule.style[i]; + properties.push({ + name: prop, + value: rule.style.getPropertyValue(prop), + priority: rule.style.getPropertyPriority(prop), + }); + } + matchedRules.push({ + selector: rule.selectorText, + properties, + source: sheet.href || 'inline', + }); + } + } catch { /* skip rules that can't be matched */ } + } + } catch { /* cross-origin sheet — silently skip */ } + } + } catch { /* CSSOM not available */ } + + return { computedStyles, boxModel, matchedRules }; +} + +function basicBuildSelector(el) { + if (el.id) { + const sel = '#' + CSS.escape(el.id); + try { if (document.querySelectorAll(sel).length === 1) return sel; } catch {} + } + const parts = []; + let current = el; + while (current && current !== document.body && current !== document.documentElement) { + let part = current.tagName.toLowerCase(); + if (current.id) { + parts.unshift('#' + CSS.escape(current.id)); + break; + } + if (current.className && typeof current.className === 'string') { + const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0); + if (classes.length > 0) part += '.' + classes.map(c => CSS.escape(c)).join('.'); + } + const parent = current.parentElement; + if (parent) { + const siblings = Array.from(parent.children).filter(s => s.tagName === current.tagName); + if (siblings.length > 1) { + part += `:nth-child(${Array.from(parent.children).indexOf(current) + 1})`; + } + } + parts.unshift(part); + current = current.parentElement; + } + return parts.join(' > '); +} + +function basicPickerHighlight(el) { + // Restore previous element + if (basicPickerLastEl && basicPickerLastEl !== el) { + basicPickerLastEl.style.outline = basicPickerSavedOutline; + } + if (el) { + basicPickerSavedOutline = el.style.outline; + el.style.outline = '2px solid rgba(59, 130, 246, 0.6)'; + basicPickerLastEl = el; + } +} + +function basicPickerCleanup() { + if (basicPickerLastEl) { + basicPickerLastEl.style.outline = basicPickerSavedOutline; + basicPickerLastEl = null; + basicPickerSavedOutline = ''; + } + basicPickerActive = false; + document.removeEventListener('mousemove', onBasicMouseMove, true); + document.removeEventListener('click', onBasicClick, true); + document.removeEventListener('keydown', onBasicKeydown, true); +} + +function onBasicMouseMove(e) { + if (!basicPickerActive) return; + e.preventDefault(); + e.stopPropagation(); + const el = document.elementFromPoint(e.clientX, e.clientY); + if (el && el !== basicPickerLastEl) { + basicPickerHighlight(el); + } +} + +function onBasicClick(e) { + if (!basicPickerActive) return; + e.preventDefault(); + e.stopPropagation(); + const el = e.target; + + const basicData = captureBasicData(el); + const selector = basicBuildSelector(el); + const tagName = el.tagName.toLowerCase(); + const id = el.id || null; + const classes = el.className && typeof el.className === 'string' + ? el.className.trim().split(/\s+/).filter(c => c.length > 0) + : []; + + basicPickerCleanup(); + + chrome.runtime.sendMessage({ + type: 'inspectResult', + data: { + selector, + tagName, + id, + classes, + basicData, + mode: 'basic', + boxModel: basicData.boxModel, + computedStyles: basicData.computedStyles, + matchedRules: basicData.matchedRules, + }, + }); +} + +function onBasicKeydown(e) { + if (e.key === 'Escape') { + basicPickerCleanup(); + chrome.runtime.sendMessage({ type: 'pickerCancelled' }); + } +} + +function startBasicPicker() { + basicPickerActive = true; + document.addEventListener('mousemove', onBasicMouseMove, true); + document.addEventListener('click', onBasicClick, true); + document.addEventListener('keydown', onBasicKeydown, true); +} + // Listen for messages from background worker chrome.runtime.onMessage.addListener((msg) => { + if (msg.type === 'startBasicPicker') { + startBasicPicker(); + return; + } + if (msg.type === 'stopBasicPicker') { + basicPickerCleanup(); + return; + } if (msg.type === 'refs' && msg.data) { const refs = msg.data.refs || []; const mode = msg.data.mode; From 7e31568c7887a14056fcdd2c5ae5b2f637baa9f4 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:11:20 -0700 Subject: [PATCH 13/28] feat: cleanup + screenshot buttons in sidebar inspector toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two action buttons in the inspector toolbar: - Cleanup (🧹): POSTs cleanup --all to server, shows spinner, chat notification on success, resets inspector state (element may be removed) - Screenshot (📸): POSTs screenshot to server, shows spinner, chat notification with saved file path Shared infrastructure: - .inspector-action-btn CSS with loading spinner via ::after pseudo-element - chat-notification type in addChatEntry() for system messages - package.json version bump to 0.13.9.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/sidepanel.css | 57 +++++++++++++++++++++++++++++ extension/sidepanel.html | 3 ++ extension/sidepanel.js | 78 ++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/extension/sidepanel.css b/extension/sidepanel.css index bb53efa83..9f33ee286 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -221,6 +221,13 @@ body::after { color: #000; border-bottom-right-radius: var(--radius-sm); } +.chat-notification { + text-align: center; + font-size: 11px; + color: var(--text-meta); + padding: 4px 12px; + font-family: var(--font-mono); +} .chat-bubble.assistant { align-self: flex-start; background: var(--bg-surface); @@ -808,6 +815,56 @@ footer { line-height: 1; } +/* ─── Action Buttons (Cleanup, Screenshot) ─────────────────── */ + +.inspector-action-btn { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + width: 28px; + padding: 0; + background: none; + border: 1px solid var(--zinc-600); + border-radius: var(--radius-sm); + color: var(--text-label); + font-size: 14px; + cursor: pointer; + transition: all 150ms; + flex-shrink: 0; +} + +.inspector-action-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-body); + border-color: var(--zinc-400); +} + +.inspector-action-btn:active { + transform: scale(0.95); +} + +.inspector-action-btn.loading { + pointer-events: none; + opacity: 0.5; + position: relative; +} + +.inspector-action-btn.loading::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 2px solid var(--zinc-600); + border-top-color: var(--amber-400); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + .inspector-selected { font-family: var(--font-mono); font-size: 11px; diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 5fe730700..5d82c2b83 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -60,6 +60,9 @@ +
+ +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 1ff287f7f..f1365c88f 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -135,6 +135,16 @@ function addChatEntry(entry) { return; } + // System notifications (cleanup, screenshot, errors) + if (entry.type === 'notification') { + const note = document.createElement('div'); + note.className = 'chat-notification'; + note.textContent = entry.message; + chatMessages.appendChild(note); + note.scrollIntoView({ behavior: 'smooth', block: 'end' }); + return; + } + // Agent streaming events if (entry.role === 'agent') { handleAgentEvent(entry); @@ -1139,6 +1149,74 @@ inspectorSendBtn.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'sidebar-command', message }); }); +// ─── Cleanup Button ───────────────────────────────────────────── + +const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn'); +if (inspectorCleanupBtn) { + inspectorCleanupBtn.addEventListener('click', async () => { + if (inspectorCleanupBtn.classList.contains('loading')) return; + if (!serverUrl || !serverToken) { + addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); + return; + } + inspectorCleanupBtn.classList.add('loading'); + try { + const resp = await fetch(`${serverUrl}/command`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: 'cleanup', args: ['--all'] }), + signal: AbortSignal.timeout(15000), + }); + const text = await resp.text(); + if (resp.ok) { + addChatEntry({ type: 'notification', message: text || 'Page cleaned up' }); + // Reset inspector — selected element may have been removed + inspectorShowEmpty(); + } else { + const err = JSON.parse(text).error || 'Cleanup failed'; + addChatEntry({ type: 'notification', message: 'Error: ' + err }); + } + } catch (err) { + addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message }); + } finally { + inspectorCleanupBtn.classList.remove('loading'); + } + }); +} + +// ─── Screenshot Button ────────────────────────────────────────── + +const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn'); +if (inspectorScreenshotBtn) { + inspectorScreenshotBtn.addEventListener('click', async () => { + if (inspectorScreenshotBtn.classList.contains('loading')) return; + if (!serverUrl || !serverToken) { + addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); + return; + } + inspectorScreenshotBtn.classList.add('loading'); + try { + const resp = await fetch(`${serverUrl}/command`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: 'screenshot', args: [] }), + signal: AbortSignal.timeout(15000), + }); + const text = await resp.text(); + if (resp.ok) { + addChatEntry({ type: 'notification', message: text || 'Screenshot saved' }); + } else { + const err = JSON.parse(text).error || 'Screenshot failed'; + addChatEntry({ type: 'notification', message: 'Error: ' + err }); + } + } catch (err) { + addChatEntry({ type: 'notification', message: 'Screenshot failed: ' + err.message }); + } finally { + inspectorScreenshotBtn.classList.remove('loading'); + } + }); +} + // ─── Section Toggles ──────────────────────────────────────────── document.querySelectorAll('.inspector-section-toggle').forEach(toggle => { diff --git a/package.json b/package.json index 13b85f965..f34218c07 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "0.13.8.0", + "version": "0.13.9.0", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", From 96980950a5a197c807296f064aaf03621d8c724f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:11:39 -0700 Subject: [PATCH 14/28] test: inspector allowlist, CSP fallback, cleanup/screenshot buttons 16 new tests in sidebar-ux.test.ts: - Inspector message allowlist includes all inspector types - content.js basic picker (startBasicPicker, captureBasicData, CSSOM, outline save/restore, inspectResult with mode basic, Escape cleanup) - background.js CSP fallback (separate try blocks, inspectorMode, fallback) - Cleanup button (POST /command, inspector reset after success) - Screenshot button (POST /command, notification rendering) - Chat notification type and CSS styles Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/sidebar-ux.test.ts | 123 +++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index fe96006ea..64b7e025c 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -674,3 +674,126 @@ describe('sidebar CSS (sidepanel.css)', () => { expect(toolSection).not.toContain('font-mono'); }); }); + +// ─── Inspector message allowlist fix ──────────────────────────── + +describe('inspector message allowlist fix', () => { + const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8'); + + test('ALLOWED_TYPES includes inspector message types', () => { + const allowListSection = bgSrc.slice( + bgSrc.indexOf('const ALLOWED_TYPES'), + bgSrc.indexOf(']);', bgSrc.indexOf('const ALLOWED_TYPES')) + 3, + ); + expect(allowListSection).toContain('startInspector'); + expect(allowListSection).toContain('stopInspector'); + expect(allowListSection).toContain('elementPicked'); + expect(allowListSection).toContain('pickerCancelled'); + expect(allowListSection).toContain('applyStyle'); + expect(allowListSection).toContain('inspectResult'); + }); +}); + +// ─── CSP fallback basic picker ────────────────────────────────── + +describe('CSP fallback basic picker', () => { + const contentSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'content.js'), 'utf-8'); + const bgSrc = fs.readFileSync(path.join(ROOT, '..', 'extension', 'background.js'), 'utf-8'); + + test('content.js contains startBasicPicker message handler', () => { + expect(contentSrc).toContain("msg.type === 'startBasicPicker'"); + expect(contentSrc).toContain('startBasicPicker()'); + }); + + test('content.js contains captureBasicData function with getComputedStyle', () => { + expect(contentSrc).toContain('function captureBasicData('); + expect(contentSrc).toContain('getComputedStyle('); + expect(contentSrc).toContain('getBoundingClientRect()'); + }); + + test('content.js contains CSSOM iteration with cross-origin try/catch', () => { + expect(contentSrc).toContain('document.styleSheets'); + expect(contentSrc).toContain('cssRules'); + expect(contentSrc).toContain('cross-origin'); + }); + + test('content.js saves and restores outline on elements', () => { + expect(contentSrc).toContain('basicPickerSavedOutline'); + // Outline is restored in cleanup and highlight functions + expect(contentSrc).toContain('.style.outline = basicPickerSavedOutline'); + }); + + test('content.js basic picker sends inspectResult with mode basic', () => { + expect(contentSrc).toContain("mode: 'basic'"); + expect(contentSrc).toContain("type: 'inspectResult'"); + }); + + test('content.js basic picker cleans up on Escape', () => { + expect(contentSrc).toContain('onBasicKeydown'); + expect(contentSrc).toContain("e.key === 'Escape'"); + expect(contentSrc).toContain('basicPickerCleanup'); + }); + + test('background.js injectInspector has separate try blocks for executeScript and insertCSS', () => { + const injectFn = bgSrc.slice( + bgSrc.indexOf('async function injectInspector('), + bgSrc.indexOf('\n}', bgSrc.indexOf('async function injectInspector(') + 1) + 2, + ); + // executeScript and insertCSS should be in separate try blocks + expect(injectFn).toContain('executeScript'); + expect(injectFn).toContain('insertCSS'); + // Fallback sends startBasicPicker + expect(injectFn).toContain("type: 'startBasicPicker'"); + expect(injectFn).toContain("mode: 'basic'"); + }); + + test('background.js stores inspectorMode for routing', () => { + expect(bgSrc).toContain('inspectorMode'); + }); +}); + +// ─── Cleanup and screenshot buttons ───────────────────────────── + +describe('cleanup and screenshot buttons', () => { + const html = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.html'), 'utf-8'); + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('sidepanel.html contains cleanup and screenshot buttons', () => { + expect(html).toContain('inspector-cleanup-btn'); + expect(html).toContain('inspector-screenshot-btn'); + expect(html).toContain('inspector-action-btn'); + }); + + test('sidepanel.js cleanup handler POSTs to /command with cleanup', () => { + expect(js).toContain("command: 'cleanup'"); + expect(js).toContain("args: ['--all']"); + }); + + test('sidepanel.js screenshot handler POSTs to /command with screenshot', () => { + expect(js).toContain("command: 'screenshot'"); + }); + + test('sidepanel.js cleanup resets inspector state after success', () => { + // After cleanup, inspector data is stale + const cleanupSection = js.slice( + js.indexOf('inspector-cleanup-btn'), + js.indexOf('// ─── Screenshot'), + ); + expect(cleanupSection).toContain('inspectorShowEmpty'); + }); + + test('sidepanel.js has notification rendering for type notification', () => { + expect(js).toContain("entry.type === 'notification'"); + expect(js).toContain('chat-notification'); + }); + + test('sidepanel.css contains inspector-action-btn styles', () => { + expect(css).toContain('.inspector-action-btn'); + expect(css).toContain('.inspector-action-btn.loading'); + }); + + test('sidepanel.css contains chat-notification styles', () => { + expect(css).toContain('.chat-notification'); + }); +}); From fc606515b68445fee14b0f37ab4075b00a2ff5ee Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:12:48 -0700 Subject: [PATCH 15/28] docs: update project documentation for v0.13.9.0 Co-Authored-By: Claude Opus 4.6 (1M context) --- BROWSER.md | 3 ++- CHANGELOG.md | 7 +++++++ CLAUDE.md | 2 +- README.md | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/BROWSER.md b/BROWSER.md index 8e82a6387..cb90aa44e 100644 --- a/BROWSER.md +++ b/BROWSER.md @@ -10,7 +10,8 @@ This document covers the command reference and internals of gstack's headless br | Read | `text`, `html`, `links`, `forms`, `accessibility` | Extract content | | Snapshot | `snapshot [-i] [-c] [-d N] [-s sel] [-D] [-a] [-o] [-C]` | Get refs, diff, annotate | | Interact | `click`, `fill`, `select`, `hover`, `type`, `press`, `scroll`, `wait`, `viewport`, `upload` | Use the page | -| Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf` | Debug and verify | +| Inspect | `js`, `eval`, `css`, `attrs`, `is`, `console`, `network`, `dialog`, `cookies`, `storage`, `perf`, `inspect [selector] [--all]` | Debug and verify | +| Style | `style `, `style --undo [N]`, `cleanup [--all]`, `prettyscreenshot` | Live CSS editing and page cleanup | | Visual | `screenshot [--viewport] [--clip x,y,w,h] [sel\|@ref] [path]`, `pdf`, `responsive` | See what Claude sees | | Compare | `diff ` | Spot differences between environments | | Dialogs | `dialog-accept [text]`, `dialog-dismiss` | Control alert/confirm/prompt handling | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d9ca24ce..327811a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,13 @@ The sidebar is now a visual design tool. Pick any element on the page and see th - **Page cleanup.** `$B cleanup --all` removes ads, cookie banners, sticky headers, and social widgets for clean screenshots. - **Pretty screenshots.** `$B prettyscreenshot --cleanup --scroll-to ".pricing" ~/Desktop/hero.png` combines cleanup, scroll positioning, and screenshot in one command. - **Stop button.** A red stop button appears in the sidebar when an agent is working. Click it to cancel the current task. +- **CSP fallback for inspector.** Sites with strict Content Security Policy (like SF Chronicle) now get a basic picker via the always-loaded content script. You see computed styles, box model, and same-origin CSS rules. Full CDP mode on sites that allow it. +- **Cleanup button in sidebar.** One click removes ads, cookie banners, sticky headers, and social widgets. Spinner while it works, notification when done. +- **Screenshot button in sidebar.** One click captures a screenshot. Shows the saved file path in the chat. + +### Fixed + +- **Inspector message allowlist.** The background.js allowlist was missing all inspector message types, silently rejecting them. The inspector was broken for all pages, not just CSP-restricted ones. (Found by Codex review.) ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index 0ea420c75..b5162aa9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ gstack/ │ ├── src/ # CLI + commands (generate, variants, compare, serve, etc.) │ ├── test/ # Integration tests │ └── dist/ # Compiled binary -├── extension/ # Chrome extension (side panel + activity feed) +├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) ├── lib/ # Shared libraries (worktree.ts) ├── docs/designs/ # Design documents ├── setup-deploy/ # /setup-deploy skill (one-time deploy config) diff --git a/README.md b/README.md index de015e14e..6a9bd313a 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Each skill feeds into the next. `/office-hours` writes a design doc that `/plan- | `/freeze` | **Edit Lock** — restrict file edits to one directory. Prevents accidental changes outside scope while debugging. | | `/guard` | **Full Safety** — `/careful` + `/freeze` in one command. Maximum safety for prod work. | | `/unfreeze` | **Unlock** — remove the `/freeze` boundary. | -| `/connect-chrome` | **Chrome Controller** — launch your real Chrome controlled by gstack with the Side Panel extension. Watch every action live. | +| `/connect-chrome` | **Chrome Controller** — launch Chrome with the Side Panel extension. Watch every action live, inspect CSS on any element, clean up pages, and take screenshots. Each tab gets its own agent. | | `/setup-deploy` | **Deploy Configurator** — one-time setup for `/land-and-deploy`. Detects your platform, production URL, and deploy commands. | | `/gstack-upgrade` | **Self-Updater** — upgrade gstack to latest. Detects global vs vendored install, syncs both, shows what changed. | From 92adc71bbc502072c875c8e5f2a042135364c2ce Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:27:42 -0700 Subject: [PATCH 16/28] feat: cleanup + screenshot buttons in chat toolbar (not just inspector) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quick actions toolbar (🧹 Cleanup, 📸 Screenshot) now appears above the chat input, always visible. Both inspector and chat buttons share runCleanup() and runScreenshot() helper functions. Clicking either set shows loading state on both simultaneously. Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/sidepanel.css | 53 +++++++++++++++++ extension/sidepanel.html | 6 ++ extension/sidepanel.js | 121 ++++++++++++++++++++------------------- 3 files changed, 120 insertions(+), 60 deletions(-) diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 9f33ee286..8c01d41e5 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -590,6 +590,59 @@ body::after { } /* ─── Command Bar ─────────────────────────────────────── */ +/* ─── Quick Actions Toolbar ─────────────────────────────── */ + +.quick-actions { + display: flex; + gap: 6px; + padding: 4px 8px; + background: var(--bg-surface); + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.quick-action-btn { + display: flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 10px; + background: none; + border: 1px solid var(--zinc-600); + border-radius: var(--radius-sm); + color: var(--text-label); + font-family: var(--font-system); + font-size: 11px; + cursor: pointer; + transition: all 150ms; +} + +.quick-action-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-body); + border-color: var(--zinc-400); +} + +.quick-action-btn:active { + transform: scale(0.96); +} + +.quick-action-btn.loading { + pointer-events: none; + opacity: 0.5; +} + +.quick-action-btn.loading::after { + content: ''; + display: inline-block; + width: 10px; + height: 10px; + border: 2px solid var(--zinc-600); + border-top-color: var(--amber-400); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + .command-bar { display: flex; align-items: center; diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 5d82c2b83..c51f7df22 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -136,6 +136,12 @@ Browser co-pilot — controls this browser, reports back to your workspace + +
+ + +
+
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index f1365c88f..81438ff25 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1149,73 +1149,74 @@ inspectorSendBtn.addEventListener('click', () => { chrome.runtime.sendMessage({ type: 'sidebar-command', message }); }); -// ─── Cleanup Button ───────────────────────────────────────────── +// ─── Quick Action Helpers (shared between chat toolbar + inspector) ── -const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn'); -if (inspectorCleanupBtn) { - inspectorCleanupBtn.addEventListener('click', async () => { - if (inspectorCleanupBtn.classList.contains('loading')) return; - if (!serverUrl || !serverToken) { - addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); - return; +async function runCleanup(...buttons) { + if (!serverUrl || !serverToken) { + addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); + return; + } + buttons.forEach(b => b?.classList.add('loading')); + try { + const resp = await fetch(`${serverUrl}/command`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: 'cleanup', args: ['--all'] }), + signal: AbortSignal.timeout(15000), + }); + const text = await resp.text(); + if (resp.ok) { + addChatEntry({ type: 'notification', message: text || 'Page cleaned up' }); + if (typeof inspectorShowEmpty === 'function') inspectorShowEmpty(); + } else { + const err = JSON.parse(text).error || 'Cleanup failed'; + addChatEntry({ type: 'notification', message: 'Error: ' + err }); } - inspectorCleanupBtn.classList.add('loading'); - try { - const resp = await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'cleanup', args: ['--all'] }), - signal: AbortSignal.timeout(15000), - }); - const text = await resp.text(); - if (resp.ok) { - addChatEntry({ type: 'notification', message: text || 'Page cleaned up' }); - // Reset inspector — selected element may have been removed - inspectorShowEmpty(); - } else { - const err = JSON.parse(text).error || 'Cleanup failed'; - addChatEntry({ type: 'notification', message: 'Error: ' + err }); - } - } catch (err) { - addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message }); - } finally { - inspectorCleanupBtn.classList.remove('loading'); + } catch (err) { + addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message }); + } finally { + buttons.forEach(b => b?.classList.remove('loading')); + } +} + +async function runScreenshot(...buttons) { + if (!serverUrl || !serverToken) { + addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); + return; + } + buttons.forEach(b => b?.classList.add('loading')); + try { + const resp = await fetch(`${serverUrl}/command`, { + method: 'POST', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: 'screenshot', args: [] }), + signal: AbortSignal.timeout(15000), + }); + const text = await resp.text(); + if (resp.ok) { + addChatEntry({ type: 'notification', message: text || 'Screenshot saved' }); + } else { + const err = JSON.parse(text).error || 'Screenshot failed'; + addChatEntry({ type: 'notification', message: 'Error: ' + err }); } - }); + } catch (err) { + addChatEntry({ type: 'notification', message: 'Screenshot failed: ' + err.message }); + } finally { + buttons.forEach(b => b?.classList.remove('loading')); + } } -// ─── Screenshot Button ────────────────────────────────────────── +// ─── Wire up all cleanup/screenshot buttons (inspector + chat toolbar) ── +const inspectorCleanupBtn = document.getElementById('inspector-cleanup-btn'); const inspectorScreenshotBtn = document.getElementById('inspector-screenshot-btn'); -if (inspectorScreenshotBtn) { - inspectorScreenshotBtn.addEventListener('click', async () => { - if (inspectorScreenshotBtn.classList.contains('loading')) return; - if (!serverUrl || !serverToken) { - addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); - return; - } - inspectorScreenshotBtn.classList.add('loading'); - try { - const resp = await fetch(`${serverUrl}/command`, { - method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'screenshot', args: [] }), - signal: AbortSignal.timeout(15000), - }); - const text = await resp.text(); - if (resp.ok) { - addChatEntry({ type: 'notification', message: text || 'Screenshot saved' }); - } else { - const err = JSON.parse(text).error || 'Screenshot failed'; - addChatEntry({ type: 'notification', message: 'Error: ' + err }); - } - } catch (err) { - addChatEntry({ type: 'notification', message: 'Screenshot failed: ' + err.message }); - } finally { - inspectorScreenshotBtn.classList.remove('loading'); - } - }); -} +const chatCleanupBtn = document.getElementById('chat-cleanup-btn'); +const chatScreenshotBtn = document.getElementById('chat-screenshot-btn'); + +if (inspectorCleanupBtn) inspectorCleanupBtn.addEventListener('click', () => runCleanup(inspectorCleanupBtn, chatCleanupBtn)); +if (inspectorScreenshotBtn) inspectorScreenshotBtn.addEventListener('click', () => runScreenshot(inspectorScreenshotBtn, chatScreenshotBtn)); +if (chatCleanupBtn) chatCleanupBtn.addEventListener('click', () => runCleanup(chatCleanupBtn, inspectorCleanupBtn)); +if (chatScreenshotBtn) chatScreenshotBtn.addEventListener('click', () => runScreenshot(chatScreenshotBtn, inspectorScreenshotBtn)); // ─── Section Toggles ──────────────────────────────────────────── From b09224e15708b9007b52db55b197adfcb2301834 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:28:01 -0700 Subject: [PATCH 17/28] test: chat toolbar buttons, shared helpers, quick-action-btn styles Tests that chat toolbar exists (chat-cleanup-btn, chat-screenshot-btn, quick-actions container), CSS styles (.quick-action-btn, .quick-action-btn.loading), shared runCleanup/runScreenshot helper functions, and cleanup inspector reset. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/sidebar-ux.test.ts | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index 64b7e025c..a718394ea 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -759,12 +759,18 @@ describe('cleanup and screenshot buttons', () => { const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); - test('sidepanel.html contains cleanup and screenshot buttons', () => { + test('sidepanel.html contains cleanup and screenshot buttons in inspector', () => { expect(html).toContain('inspector-cleanup-btn'); expect(html).toContain('inspector-screenshot-btn'); expect(html).toContain('inspector-action-btn'); }); + test('sidepanel.html contains cleanup and screenshot buttons in chat toolbar', () => { + expect(html).toContain('chat-cleanup-btn'); + expect(html).toContain('chat-screenshot-btn'); + expect(html).toContain('quick-actions'); + }); + test('sidepanel.js cleanup handler POSTs to /command with cleanup', () => { expect(js).toContain("command: 'cleanup'"); expect(js).toContain("args: ['--all']"); @@ -775,12 +781,12 @@ describe('cleanup and screenshot buttons', () => { }); test('sidepanel.js cleanup resets inspector state after success', () => { - // After cleanup, inspector data is stale - const cleanupSection = js.slice( - js.indexOf('inspector-cleanup-btn'), - js.indexOf('// ─── Screenshot'), + // runCleanup should call inspectorShowEmpty after cleanup + const cleanupFn = js.slice( + js.indexOf('async function runCleanup('), + js.indexOf('async function runScreenshot('), ); - expect(cleanupSection).toContain('inspectorShowEmpty'); + expect(cleanupFn).toContain('inspectorShowEmpty'); }); test('sidepanel.js has notification rendering for type notification', () => { @@ -793,6 +799,20 @@ describe('cleanup and screenshot buttons', () => { expect(css).toContain('.inspector-action-btn.loading'); }); + test('sidepanel.css contains quick-action-btn styles for chat toolbar', () => { + expect(css).toContain('.quick-action-btn'); + expect(css).toContain('.quick-action-btn.loading'); + expect(css).toContain('.quick-actions'); + }); + + test('cleanup and screenshot use shared helper functions', () => { + expect(js).toContain('async function runCleanup('); + expect(js).toContain('async function runScreenshot('); + // Both inspector and chat buttons are wired + expect(js).toContain('chatCleanupBtn'); + expect(js).toContain('chatScreenshotBtn'); + }); + test('sidepanel.css contains chat-notification styles', () => { expect(css).toContain('.chat-notification'); }); From 2d2a8083df20a07673f0d4a3da023f9cd5dce26f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:37:22 -0700 Subject: [PATCH 18/28] =?UTF-8?q?feat:=20aggressive=20cleanup=20heuristics?= =?UTF-8?q?=20=E2=80=94=20overlays,=20scroll=20unlock,=20blur=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Massively expanded CLEANUP_SELECTORS with patterns from uBlock Origin and Readability.js research: - ads: 30+ selectors (Google, Amazon, Outbrain, Taboola, Criteo, etc.) - cookies: OneTrust, Cookiebot, TrustArc, Quantcast + generic patterns - overlays (NEW): paywalls, newsletter popups, interstitials, push prompts, app download banners, survey modals - social: follow prompts, share tools - Cleanup now defaults to --all when no args (sidebar button fix) - Uses !important on all display:none (overrides inline styles) - Unlocks body/html scroll (overflow:hidden from modal lockout) - Removes blur/filter effects (paywall content blur) - Removes max-height truncation (article teaser truncation) - Collapses empty ad placeholder whitespace (empty divs after ad removal) - Skips gstack-ctrl indicator in sticky removal Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/write-commands.ts | 162 +++++++++++++++++++++++++++++++---- 1 file changed, 147 insertions(+), 15 deletions(-) diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index f3292cc11..02fba77c9 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -24,34 +24,92 @@ function validateOutputPath(filePath: string): void { } } -/** Common selectors for page clutter removal */ +/** + * Aggressive page cleanup selectors and heuristics. + * Goal: make the page readable and clean while keeping it recognizable. + * Inspired by uBlock Origin filter lists, Readability.js, and reader mode heuristics. + */ const CLEANUP_SELECTORS = { ads: [ + // Google Ads 'ins.adsbygoogle', '[id^="google_ads"]', '[id^="div-gpt-ad"]', 'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]', + '[data-google-query-id]', '.google-auto-placed', + // Generic ad patterns (uBlock Origin common filters) '[class*="ad-banner"]', '[class*="ad-wrapper"]', '[class*="ad-container"]', - '[data-ad]', '[data-ad-slot]', '[class*="sponsored"]', + '[class*="ad-slot"]', '[class*="ad-unit"]', '[class*="ad-zone"]', + '[class*="ad-placement"]', '[class*="ad-holder"]', '[class*="ad-block"]', + '[class*="adbox"]', '[class*="adunit"]', '[class*="adwrap"]', + '[id*="ad-banner"]', '[id*="ad-wrapper"]', '[id*="ad-container"]', + '[id*="ad-slot"]', '[id*="ad_banner"]', '[id*="ad_container"]', + '[data-ad]', '[data-ad-slot]', '[data-ad-unit]', '[data-adunit]', + '[class*="sponsored"]', '[class*="Sponsored"]', '.ad', '.ads', '.advert', '.advertisement', + '#ad', '#ads', '#advert', '#advertisement', + // Common ad network iframes + 'iframe[src*="amazon-adsystem"]', 'iframe[src*="outbrain"]', + 'iframe[src*="taboola"]', 'iframe[src*="criteo"]', + 'iframe[src*="adsafeprotected"]', 'iframe[src*="moatads"]', + // Promoted/sponsored content + '[class*="promoted"]', '[class*="Promoted"]', + '[data-testid*="promo"]', '[class*="native-ad"]', + // Empty ad placeholders (divs with only ad classes, no real content) + 'aside[class*="ad"]', 'section[class*="ad-"]', ], cookies: [ + // Cookie consent frameworks '[class*="cookie-consent"]', '[class*="cookie-banner"]', '[class*="cookie-notice"]', '[id*="cookie-consent"]', '[id*="cookie-banner"]', '[id*="cookie-notice"]', - '[class*="consent-banner"]', '[class*="consent-modal"]', - '[class*="gdpr"]', '[id*="gdpr"]', + '[class*="consent-banner"]', '[class*="consent-modal"]', '[class*="consent-wall"]', + '[class*="gdpr"]', '[id*="gdpr"]', '[class*="GDPR"]', '[class*="CookieConsent"]', '[id*="CookieConsent"]', - '#onetrust-consent-sdk', '.onetrust-pc-dark-filter', - '[class*="cc-banner"]', '[class*="cc-window"]', + // OneTrust (very common) + '#onetrust-consent-sdk', '.onetrust-pc-dark-filter', '#onetrust-banner-sdk', + // Cookiebot + '#CybotCookiebotDialog', '#CybotCookiebotDialogBodyUnderlay', + // TrustArc / TRUSTe + '#truste-consent-track', '.truste_overlay', '.truste_box_overlay', + // Quantcast + '.qc-cmp2-container', '#qc-cmp2-main', + // Generic patterns + '[class*="cc-banner"]', '[class*="cc-window"]', '[class*="cc-overlay"]', + '[class*="privacy-banner"]', '[class*="privacy-notice"]', + '[id*="privacy-banner"]', '[id*="privacy-notice"]', + '[class*="accept-cookies"]', '[id*="accept-cookies"]', + ], + overlays: [ + // Paywall / subscription overlays + '[class*="paywall"]', '[class*="Paywall"]', '[id*="paywall"]', + '[class*="subscribe-wall"]', '[class*="subscription-wall"]', + '[class*="meter-wall"]', '[class*="regwall"]', '[class*="reg-wall"]', + // Newsletter / signup popups + '[class*="newsletter-popup"]', '[class*="newsletter-modal"]', + '[class*="signup-modal"]', '[class*="signup-popup"]', + '[class*="email-capture"]', '[class*="lead-capture"]', + '[class*="popup-modal"]', '[class*="modal-overlay"]', + // Interstitials + '[class*="interstitial"]', '[id*="interstitial"]', + // Push notification prompts + '[class*="push-notification"]', '[class*="notification-prompt"]', + '[class*="web-push"]', + // Survey / feedback popups + '[class*="survey-"]', '[class*="feedback-modal"]', + '[id*="survey-"]', '[class*="nps-"]', + // App download banners + '[class*="app-banner"]', '[class*="smart-banner"]', '[class*="app-download"]', + '[id*="branch-banner"]', '.smartbanner', ], sticky: [ - // Select fixed/sticky positioned elements (except navs and headers at top) - // This is handled via JavaScript evaluation, not pure selectors + // Handled via JavaScript evaluation, not pure selectors ], social: [ '[class*="social-share"]', '[class*="share-buttons"]', '[class*="share-bar"]', - '[class*="social-widget"]', '[class*="social-icons"]', + '[class*="social-widget"]', '[class*="social-icons"]', '[class*="share-tools"]', 'iframe[src*="facebook.com/plugins"]', 'iframe[src*="platform.twitter"]', '[class*="fb-like"]', '[class*="tweet-button"]', '[class*="addthis"]', '[class*="sharethis"]', + // Follow prompts + '[class*="follow-us"]', '[class*="social-follow"]', ], }; @@ -428,10 +486,12 @@ export async function handleWriteCommand( case 'cleanup': { // Parse flags let doAds = false, doCookies = false, doSticky = false, doSocial = false; + let doOverlays = false; let doAll = false; + // Default to --all if no args (most common use case from sidebar button) if (args.length === 0) { - throw new Error('Usage: browse cleanup [--ads] [--cookies] [--sticky] [--social] [--all]'); + doAll = true; } for (const arg of args) { @@ -440,14 +500,15 @@ export async function handleWriteCommand( case '--cookies': doCookies = true; break; case '--sticky': doSticky = true; break; case '--social': doSocial = true; break; + case '--overlays': doOverlays = true; break; case '--all': doAll = true; break; default: - throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --all`); + throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --overlays, --all`); } } if (doAll) { - doAds = doCookies = doSticky = doSocial = true; + doAds = doCookies = doSticky = doSocial = doOverlays = true; } const removed: string[] = []; @@ -457,6 +518,7 @@ export async function handleWriteCommand( if (doAds) selectors.push(...CLEANUP_SELECTORS.ads); if (doCookies) selectors.push(...CLEANUP_SELECTORS.cookies); if (doSocial) selectors.push(...CLEANUP_SELECTORS.social); + if (doOverlays) selectors.push(...CLEANUP_SELECTORS.overlays); if (selectors.length > 0) { const count = await page.evaluate((sels: string[]) => { @@ -465,7 +527,7 @@ export async function handleWriteCommand( try { const els = document.querySelectorAll(sel); els.forEach(el => { - (el as HTMLElement).style.display = 'none'; + (el as HTMLElement).style.setProperty('display', 'none', 'important'); removed++; }); } catch {} @@ -476,6 +538,7 @@ export async function handleWriteCommand( if (doAds) removed.push('ads'); if (doCookies) removed.push('cookie banners'); if (doSocial) removed.push('social widgets'); + if (doOverlays) removed.push('overlays/popups'); } } @@ -488,13 +551,15 @@ export async function handleWriteCommand( const style = getComputedStyle(el); if (style.position === 'fixed' || style.position === 'sticky') { const tag = el.tagName.toLowerCase(); - // Skip main nav/header elements + // Skip main nav/header elements at the top of the page if (tag === 'nav' || tag === 'header') continue; if (el.getAttribute('role') === 'navigation') continue; // Skip elements at the very top that look like navbars const rect = el.getBoundingClientRect(); if (rect.top <= 10 && rect.height < 100 && tag !== 'div') continue; - (el as HTMLElement).style.display = 'none'; + // Skip the gstack control indicator + if (el.id === 'gstack-ctrl') continue; + (el as HTMLElement).style.setProperty('display', 'none', 'important'); removed++; } } @@ -503,6 +568,73 @@ export async function handleWriteCommand( if (stickyCount > 0) removed.push(`${stickyCount} sticky/fixed elements`); } + // Unlock scrolling (many sites lock body scroll when modals are open) + const scrollFixed = await page.evaluate(() => { + let fixed = 0; + // Unlock body and html scroll + for (const el of [document.body, document.documentElement]) { + if (!el) continue; + const style = getComputedStyle(el); + if (style.overflow === 'hidden' || style.overflowY === 'hidden') { + (el as HTMLElement).style.setProperty('overflow', 'auto', 'important'); + (el as HTMLElement).style.setProperty('overflow-y', 'auto', 'important'); + fixed++; + } + // Remove height:100% + position:fixed that locks scroll + if (style.position === 'fixed' && (el === document.body || el === document.documentElement)) { + (el as HTMLElement).style.setProperty('position', 'static', 'important'); + fixed++; + } + } + // Remove blur/filter effects (paywalls often blur the content) + const blurred = document.querySelectorAll('[style*="blur"], [style*="filter"]'); + blurred.forEach(el => { + const s = (el as HTMLElement).style; + if (s.filter?.includes('blur') || s.webkitFilter?.includes('blur')) { + s.setProperty('filter', 'none', 'important'); + s.setProperty('-webkit-filter', 'none', 'important'); + fixed++; + } + }); + // Remove max-height truncation (article truncation) + const truncated = document.querySelectorAll('[class*="truncat"], [class*="preview"], [class*="teaser"]'); + truncated.forEach(el => { + const s = getComputedStyle(el); + if (s.maxHeight && s.maxHeight !== 'none' && parseInt(s.maxHeight) < 500) { + (el as HTMLElement).style.setProperty('max-height', 'none', 'important'); + (el as HTMLElement).style.setProperty('overflow', 'visible', 'important'); + fixed++; + } + }); + return fixed; + }); + if (scrollFixed > 0) removed.push('scroll unlocked'); + + // Remove empty ad placeholder whitespace (divs that are now empty after ad removal) + const collapsedCount = await page.evaluate(() => { + let collapsed = 0; + const candidates = document.querySelectorAll( + 'div[class*="ad"], div[id*="ad"], aside[class*="ad"], div[class*="sidebar"], ' + + 'div[class*="rail"], div[class*="right-col"], div[class*="widget"]' + ); + for (const el of candidates) { + const rect = el.getBoundingClientRect(); + // If the element has significant height but no visible text content, collapse it + if (rect.height > 50 && rect.width > 0) { + const text = (el.textContent || '').trim(); + const images = el.querySelectorAll('img:not([src*="logo"]):not([src*="icon"])'); + const links = el.querySelectorAll('a'); + // Empty or mostly empty: collapse + if (text.length < 20 && images.length === 0 && links.length < 2) { + (el as HTMLElement).style.setProperty('display', 'none', 'important'); + collapsed++; + } + } + } + return collapsed; + }); + if (collapsedCount > 0) removed.push(`${collapsedCount} empty placeholders`); + if (removed.length === 0) return 'No clutter elements found to remove.'; return `Cleaned up: ${removed.join(', ')}`; } From 6c3fdf30cfedea7913bdede34a2448e36ddfcd32 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:37:28 -0700 Subject: [PATCH 19/28] fix: disable action buttons when disconnected, no error spam - setActionButtonsEnabled() toggles .disabled class on all cleanup/screenshot buttons (both chat toolbar and inspector toolbar) - Called with false in updateConnection when server URL is null - Called with true when connection established - runCleanup/runScreenshot silently return when disconnected instead of showing 'Not connected' error notifications - CSS .disabled style: pointer-events:none, opacity:0.3, cursor:not-allowed Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/sidepanel.css | 6 ++++++ extension/sidepanel.js | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 8c01d41e5..2cc94a0fa 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -627,6 +627,12 @@ body::after { transform: scale(0.96); } +.quick-action-btn.disabled, .inspector-action-btn.disabled { + pointer-events: none; + opacity: 0.3; + cursor: not-allowed; +} + .quick-action-btn.loading { pointer-events: none; opacity: 0.5; diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 81438ff25..a1f131802 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1153,7 +1153,7 @@ inspectorSendBtn.addEventListener('click', () => { async function runCleanup(...buttons) { if (!serverUrl || !serverToken) { - addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); + // Don't spam errors, buttons should already be disabled return; } buttons.forEach(b => b?.classList.add('loading')); @@ -1181,7 +1181,6 @@ async function runCleanup(...buttons) { async function runScreenshot(...buttons) { if (!serverUrl || !serverToken) { - addChatEntry({ type: 'notification', message: 'Not connected to browse server' }); return; } buttons.forEach(b => b?.classList.add('loading')); @@ -1263,6 +1262,14 @@ function connectInspectorSSE() { // ─── Server Discovery ─────────────────────────────────────────── +function setActionButtonsEnabled(enabled) { + const btns = document.querySelectorAll('.quick-action-btn, .inspector-action-btn'); + btns.forEach(btn => { + btn.disabled = !enabled; + btn.classList.toggle('disabled', !enabled); + }); +} + function updateConnection(url, token) { const wasConnected = !!serverUrl; serverUrl = url; @@ -1272,6 +1279,7 @@ function updateConnection(url, token) { const port = new URL(url).port; document.getElementById('footer-port').textContent = `:${port}`; setConnState('connected'); + setActionButtonsEnabled(true); connectSSE(); connectInspectorSSE(); if (chatPollInterval) clearInterval(chatPollInterval); @@ -1284,6 +1292,7 @@ function updateConnection(url, token) { } else { document.getElementById('footer-dot').className = 'dot'; document.getElementById('footer-port').textContent = ''; + setActionButtonsEnabled(false); if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } if (tabPollInterval) { clearInterval(tabPollInterval); tabPollInterval = null; } if (wasConnected) { From 80bb25228310e0ee679c061cdf629165b42dd92a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:37:32 -0700 Subject: [PATCH 20/28] test: cleanup heuristics, button disabled state, overlay selectors 17 new tests: - cleanup defaults to --all on empty args - CLEANUP_SELECTORS overlays category (paywall, newsletter, interstitial) - Major ad networks in selectors (doubleclick, taboola, criteo, etc.) - Major consent frameworks (OneTrust, Cookiebot, TrustArc, Quantcast) - !important override for inline styles - Scroll unlock (body overflow:hidden) - Blur removal (paywall content blur) - Article truncation removal (max-height) - Empty placeholder collapse - gstack-ctrl indicator skip in sticky cleanup - setActionButtonsEnabled function - Buttons disabled when disconnected - No error spam from cleanup/screenshot when disconnected - CSS disabled styles for action buttons Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/test/sidebar-ux.test.ts | 96 ++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/browse/test/sidebar-ux.test.ts b/browse/test/sidebar-ux.test.ts index a718394ea..c9011b2f2 100644 --- a/browse/test/sidebar-ux.test.ts +++ b/browse/test/sidebar-ux.test.ts @@ -817,3 +817,99 @@ describe('cleanup and screenshot buttons', () => { expect(css).toContain('.chat-notification'); }); }); + +describe('cleanup heuristics (write-commands.ts)', () => { + const wcSrc = fs.readFileSync(path.join(ROOT, 'src', 'write-commands.ts'), 'utf-8'); + + test('cleanup defaults to --all when no args provided', () => { + // Should not throw on empty args, should default to doAll + expect(wcSrc).toContain('if (args.length === 0)'); + expect(wcSrc).toContain('doAll = true'); + }); + + test('CLEANUP_SELECTORS has overlays category', () => { + expect(wcSrc).toContain('overlays: ['); + expect(wcSrc).toContain('paywall'); + expect(wcSrc).toContain('newsletter'); + expect(wcSrc).toContain('interstitial'); + expect(wcSrc).toContain('push-notification'); + expect(wcSrc).toContain('app-banner'); + }); + + test('CLEANUP_SELECTORS ads has major ad networks', () => { + expect(wcSrc).toContain('doubleclick'); + expect(wcSrc).toContain('googlesyndication'); + expect(wcSrc).toContain('amazon-adsystem'); + expect(wcSrc).toContain('outbrain'); + expect(wcSrc).toContain('taboola'); + expect(wcSrc).toContain('criteo'); + }); + + test('CLEANUP_SELECTORS cookies has major consent frameworks', () => { + expect(wcSrc).toContain('onetrust'); + expect(wcSrc).toContain('CybotCookiebot'); + expect(wcSrc).toContain('truste'); + expect(wcSrc).toContain('qc-cmp2'); + expect(wcSrc).toContain('Quantcast'); + }); + + test('cleanup uses !important to override inline styles', () => { + // Elements with inline style="display:block" need !important to hide + expect(wcSrc).toContain("setProperty('display', 'none', 'important')"); + }); + + test('cleanup unlocks scroll (body overflow:hidden)', () => { + expect(wcSrc).toContain("overflow === 'hidden'"); + expect(wcSrc).toContain("setProperty('overflow', 'auto', 'important')"); + }); + + test('cleanup removes blur effects (paywall blur)', () => { + expect(wcSrc).toContain("filter?.includes('blur')"); + expect(wcSrc).toContain("setProperty('filter', 'none', 'important')"); + }); + + test('cleanup removes article truncation (max-height)', () => { + expect(wcSrc).toContain('truncat'); + expect(wcSrc).toContain("setProperty('max-height', 'none', 'important')"); + }); + + test('cleanup collapses empty ad placeholder whitespace', () => { + expect(wcSrc).toContain('empty placeholders'); + // Should check text content length before collapsing + expect(wcSrc).toContain('text.length < 20'); + }); + + test('sticky cleanup skips gstack control indicator', () => { + expect(wcSrc).toContain("el.id === 'gstack-ctrl'"); + }); +}); + +describe('chat toolbar buttons disabled state', () => { + const js = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.js'), 'utf-8'); + const css = fs.readFileSync(path.join(ROOT, '..', 'extension', 'sidepanel.css'), 'utf-8'); + + test('setActionButtonsEnabled function exists', () => { + expect(js).toContain('function setActionButtonsEnabled(enabled)'); + }); + + test('buttons are disabled when disconnected', () => { + // updateConnection should call setActionButtonsEnabled(false) when no URL + expect(js).toContain('setActionButtonsEnabled(false)'); + expect(js).toContain('setActionButtonsEnabled(true)'); + }); + + test('runCleanup silently returns when disconnected (no error spam)', () => { + // Should NOT show "Not connected" notification, just return silently + const cleanupFn = js.slice( + js.indexOf('async function runCleanup('), + js.indexOf('\n}', js.indexOf('async function runCleanup(') + 1) + 2, + ); + expect(cleanupFn).not.toContain('Not connected to browse server'); + }); + + test('CSS has disabled style for action buttons', () => { + expect(css).toContain('.quick-action-btn.disabled'); + expect(css).toContain('.inspector-action-btn.disabled'); + expect(css).toContain('pointer-events: none'); + }); +}); From fa03b66c617e97d0c7cfe9c56c6463c526190899 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:44:31 -0700 Subject: [PATCH 21/28] =?UTF-8?q?feat:=20LLM-based=20page=20cleanup=20?= =?UTF-8?q?=E2=80=94=20agent=20analyzes=20page=20semantically?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of brittle CSS selectors, the cleanup button now sends a prompt to the sidebar agent (which IS an LLM). The agent: 1. Runs deterministic $B cleanup --all as a quick first pass 2. Takes a snapshot to see what's left 3. Analyzes the page semantically to identify remaining clutter 4. Removes elements intelligently, preserving site branding This means cleanup works correctly on any site without site-specific selectors. The LLM understands that "Your Daily Puzzles" is clutter, "ADVERTISEMENT" is junk, but the SF Chronicle masthead should stay. Co-Authored-By: Claude Opus 4.6 (1M context) --- extension/sidepanel.js | 52 +++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/extension/sidepanel.js b/extension/sidepanel.js index a1f131802..bfc28e883 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -1153,29 +1153,59 @@ inspectorSendBtn.addEventListener('click', () => { async function runCleanup(...buttons) { if (!serverUrl || !serverToken) { - // Don't spam errors, buttons should already be disabled return; } buttons.forEach(b => b?.classList.add('loading')); + + // Smart cleanup: send a chat message to the sidebar agent (an LLM). + // The agent snapshots the page, understands it semantically, and removes + // clutter intelligently. Much better than brittle CSS selectors. + const cleanupPrompt = [ + 'Clean up this page for reading. First run a quick deterministic pass:', + '$B cleanup --all', + '', + 'Then take a snapshot to see what\'s left:', + '$B snapshot -i', + '', + 'Look at the snapshot and identify remaining non-content elements:', + '- Ad placeholders, "ADVERTISEMENT" labels, sponsored content', + '- Cookie/consent banners, newsletter popups, login walls', + '- Audio/podcast player widgets, video autoplay', + '- Sidebar widgets (puzzles, games, "most popular", recommendations)', + '- Social share buttons, follow prompts, "See more on Google"', + '- Floating chat widgets, feedback buttons', + '- Navigation drawers, mega-menus (unless they ARE the page content)', + '- Empty whitespace from removed ads', + '', + 'KEEP: the site header/masthead/logo, article headline, article body,', + 'article images, author byline, date. The page should still look like', + 'the site it is, just without the crap.', + '', + 'For each element to remove, run JavaScript via $B to hide it:', + '$B eval "document.querySelector(\'SELECTOR\').style.display=\'none\'"', + '', + 'Also unlock scrolling if the page is scroll-locked:', + '$B eval "document.body.style.overflow=\'auto\';document.documentElement.style.overflow=\'auto\'"', + ].join('\n'); + try { - const resp = await fetch(`${serverUrl}/command`, { + // Send as a sidebar command (spawns the agent) + const resp = await fetch(`${serverUrl}/sidebar-command`, { method: 'POST', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify({ command: 'cleanup', args: ['--all'] }), - signal: AbortSignal.timeout(15000), + headers: authHeaders(), + body: JSON.stringify({ message: cleanupPrompt }), + signal: AbortSignal.timeout(5000), }); - const text = await resp.text(); if (resp.ok) { - addChatEntry({ type: 'notification', message: text || 'Page cleaned up' }); - if (typeof inspectorShowEmpty === 'function') inspectorShowEmpty(); + addChatEntry({ type: 'notification', message: 'Cleaning up page (agent is analyzing...)' }); } else { - const err = JSON.parse(text).error || 'Cleanup failed'; - addChatEntry({ type: 'notification', message: 'Error: ' + err }); + addChatEntry({ type: 'notification', message: 'Failed to start cleanup' }); } } catch (err) { addChatEntry({ type: 'notification', message: 'Cleanup failed: ' + err.message }); } finally { - buttons.forEach(b => b?.classList.remove('loading')); + // Remove loading after a short delay (agent runs async) + setTimeout(() => buttons.forEach(b => b?.classList.remove('loading')), 2000); } } From 0940d216ea31240b218842b4db2acb49ed8c6ffa Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 29 Mar 2026 23:44:37 -0700 Subject: [PATCH 22/28] feat: aggressive cleanup heuristics + preserve top nav bar Deterministic cleanup improvements (used as first pass before LLM analysis): - New 'clutter' category: audio players, podcast widgets, sidebar puzzles/games, recirculation widgets (taboola, outbrain, nativo), cross-promotion banners - Text-content detection: removes "ADVERTISEMENT", "Article continues below", "Sponsored", "Paid content" labels and their parent wrappers - Sticky fix: preserves the topmost full-width element near viewport top (site nav bar) instead of hiding all sticky/fixed elements. Sorts by vertical position, preserves the first one that spans >80% viewport width. Tests: clutter category, ad label removal, nav bar preservation logic. Co-Authored-By: Claude Opus 4.6 (1M context) --- browse/src/write-commands.ts | 90 +++++++++++++++++++++++++++++----- browse/test/sidebar-ux.test.ts | 52 +++++++++++++++----- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 02fba77c9..19283fef0 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -98,6 +98,26 @@ const CLEANUP_SELECTORS = { // App download banners '[class*="app-banner"]', '[class*="smart-banner"]', '[class*="app-download"]', '[id*="branch-banner"]', '.smartbanner', + // Cross-promotion / "follow us" / "preferred source" widgets + '[class*="promo-banner"]', '[class*="cross-promo"]', '[class*="partner-promo"]', + '[class*="preferred-source"]', '[class*="google-promo"]', + ], + clutter: [ + // Audio/podcast player widgets (not part of the article text) + '[class*="audio-player"]', '[class*="podcast-player"]', '[class*="listen-widget"]', + '[class*="everlit"]', '[class*="Everlit"]', + 'audio', // bare audio elements + // Sidebar games/puzzles widgets + '[class*="puzzle"]', '[class*="daily-game"]', '[class*="games-widget"]', + '[class*="crossword-promo"]', '[class*="mini-game"]', + // "Most Popular" / "Trending" sidebar recirculation (not the top nav trending bar) + 'aside [class*="most-popular"]', 'aside [class*="trending"]', + 'aside [class*="most-read"]', 'aside [class*="recommended"]', + // Related articles / recirculation at bottom + '[class*="related-articles"]', '[class*="more-stories"]', + '[class*="recirculation"]', '[class*="taboola"]', '[class*="outbrain"]', + // Hearst-specific (SF Chronicle, etc.) + '[class*="nativo"]', '[data-tb-region]', ], sticky: [ // Handled via JavaScript evaluation, not pure selectors @@ -486,7 +506,7 @@ export async function handleWriteCommand( case 'cleanup': { // Parse flags let doAds = false, doCookies = false, doSticky = false, doSocial = false; - let doOverlays = false; + let doOverlays = false, doClutter = false; let doAll = false; // Default to --all if no args (most common use case from sidebar button) @@ -501,14 +521,15 @@ export async function handleWriteCommand( case '--sticky': doSticky = true; break; case '--social': doSocial = true; break; case '--overlays': doOverlays = true; break; + case '--clutter': doClutter = true; break; case '--all': doAll = true; break; default: - throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --overlays, --all`); + throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --overlays, --clutter, --all`); } } if (doAll) { - doAds = doCookies = doSticky = doSocial = doOverlays = true; + doAds = doCookies = doSticky = doSocial = doOverlays = doClutter = true; } const removed: string[] = []; @@ -519,6 +540,7 @@ export async function handleWriteCommand( if (doCookies) selectors.push(...CLEANUP_SELECTORS.cookies); if (doSocial) selectors.push(...CLEANUP_SELECTORS.social); if (doOverlays) selectors.push(...CLEANUP_SELECTORS.overlays); + if (doClutter) selectors.push(...CLEANUP_SELECTORS.clutter); if (selectors.length > 0) { const count = await page.evaluate((sels: string[]) => { @@ -539,6 +561,7 @@ export async function handleWriteCommand( if (doCookies) removed.push('cookie banners'); if (doSocial) removed.push('social widgets'); if (doOverlays) removed.push('overlays/popups'); + if (doClutter) removed.push('clutter'); } } @@ -546,22 +569,35 @@ export async function handleWriteCommand( if (doSticky) { const stickyCount = await page.evaluate(() => { let removed = 0; + // Collect all sticky/fixed elements, sort by vertical position + const stickyEls: Array<{ el: Element; top: number; width: number; height: number }> = []; const allElements = document.querySelectorAll('*'); + const viewportWidth = window.innerWidth; for (const el of allElements) { const style = getComputedStyle(el); if (style.position === 'fixed' || style.position === 'sticky') { - const tag = el.tagName.toLowerCase(); - // Skip main nav/header elements at the top of the page - if (tag === 'nav' || tag === 'header') continue; - if (el.getAttribute('role') === 'navigation') continue; - // Skip elements at the very top that look like navbars const rect = el.getBoundingClientRect(); - if (rect.top <= 10 && rect.height < 100 && tag !== 'div') continue; - // Skip the gstack control indicator - if (el.id === 'gstack-ctrl') continue; - (el as HTMLElement).style.setProperty('display', 'none', 'important'); - removed++; + stickyEls.push({ el, top: rect.top, width: rect.width, height: rect.height }); + } + } + // Sort by vertical position (topmost first) + stickyEls.sort((a, b) => a.top - b.top); + let preservedTopNav = false; + for (const { el, top, width, height } of stickyEls) { + const tag = el.tagName.toLowerCase(); + // Always skip nav/header semantic elements + if (tag === 'nav' || tag === 'header') continue; + if (el.getAttribute('role') === 'navigation') continue; + // Skip the gstack control indicator + if ((el as HTMLElement).id === 'gstack-ctrl') continue; + // Preserve the FIRST full-width element near the top (site's main nav bar) + // This catches divs that act as navbars but aren't semantic