diff --git a/extension/src/background.ts b/extension/src/background.ts index 58bcc74a..a06c298f 100644 --- a/extension/src/background.ts +++ b/extension/src/background.ts @@ -317,30 +317,26 @@ function setWorkspaceSession(workspace: string, session: Pick { +async function resolveTab(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { // Even when an explicit tabId is provided, validate it is still debuggable. - // This prevents issues when extensions hijack the tab URL to chrome-extension:// - // or when the tab has been closed by the user. if (tabId !== undefined) { try { const tab = await chrome.tabs.get(tabId); const session = automationSessions.get(workspace); const matchesSession = session ? tab.windowId === session.windowId : false; - if (isDebuggableUrl(tab.url) && matchesSession) return tabId; + if (isDebuggableUrl(tab.url) && matchesSession) return { tabId, tab }; if (session && !matchesSession) { console.warn(`[opencli] Tab ${tabId} is not bound to workspace ${workspace}, re-resolving`); } else if (!isDebuggableUrl(tab.url)) { - // Tab exists but URL is not debuggable — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`); } } catch { - // Tab was closed — fall through to auto-resolve console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`); } } @@ -351,17 +347,16 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia // Prefer an existing debuggable tab const tabs = await chrome.tabs.query({ windowId }); const debuggableTab = tabs.find(t => t.id && isDebuggableUrl(t.url)); - if (debuggableTab?.id) return debuggableTab.id; + if (debuggableTab?.id) return { tabId: debuggableTab.id, tab: debuggableTab }; // No debuggable tab — another extension may have hijacked the tab URL. - // Try to reuse by navigating to a data: URI (not interceptable by New Tab Override). const reuseTab = tabs.find(t => t.id); if (reuseTab?.id) { await chrome.tabs.update(reuseTab.id, { url: BLANK_PAGE }); await new Promise(resolve => setTimeout(resolve, 300)); try { const updated = await chrome.tabs.get(reuseTab.id); - if (isDebuggableUrl(updated.url)) return reuseTab.id; + if (isDebuggableUrl(updated.url)) return { tabId: reuseTab.id, tab: updated }; console.warn(`[opencli] data: URI was intercepted (${updated.url}), creating fresh tab`); } catch { // Tab was closed during navigation @@ -371,7 +366,13 @@ async function resolveTabId(tabId: number | undefined, workspace: string, initia // Fallback: create a new tab const newTab = await chrome.tabs.create({ windowId, url: BLANK_PAGE, active: true }); if (!newTab.id) throw new Error('Failed to create tab in automation window'); - return newTab.id; + return { tabId: newTab.id, tab: newTab }; +} + +/** Convenience wrapper returning just the tabId (used by most handlers) */ +async function resolveTabId(tabId: number | undefined, workspace: string, initialUrl?: string): Promise { + const resolved = await resolveTab(tabId, workspace, initialUrl); + return resolved.tabId; } async function listAutomationTabs(workspace: string): Promise { @@ -408,9 +409,10 @@ async function handleNavigate(cmd: Command, workspace: string): Promise return { id: cmd.id, ok: false, error: 'Blocked URL scheme -- only http:// and https:// are allowed' }; } // Pass target URL so that first-time window creation can start on the right domain - const tabId = await resolveTabId(cmd.tabId, workspace, cmd.url); + const resolved = await resolveTab(cmd.tabId, workspace, cmd.url); + const tabId = resolved.tabId; - const beforeTab = await chrome.tabs.get(tabId); + const beforeTab = resolved.tab ?? await chrome.tabs.get(tabId); const beforeNormalized = normalizeUrlForComparison(beforeTab.url); const targetUrl = cmd.url; diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index b8a47611..8f57ca4b 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -27,6 +27,8 @@ import { formatSnapshot } from '../snapshotFormatter.js'; export abstract class BasePage implements IPage { protected _lastUrl: string | null = null; + /** Cached previous snapshot hashes for incremental diff marking */ + private _prevSnapshotHashes: string | null = null; // ── Transport-specific methods (must be implemented by subclasses) ── @@ -137,10 +139,19 @@ export abstract class BasePage implements IPage { maxTextLength: opts.maxTextLength ?? 120, includeScrollInfo: true, bboxDedup: true, + previousHashes: this._prevSnapshotHashes, }); try { - return await this.evaluate(snapshotJs); + const result = await this.evaluate(snapshotJs); + // Read back the hashes stored by the snapshot for next diff + try { + const hashes = await this.evaluate('window.__opencli_prev_hashes') as string | null; + this._prevSnapshotHashes = typeof hashes === 'string' ? hashes : null; + } catch { + // Non-fatal: diff is best-effort + } + return result; } catch { return this._basicSnapshot(opts); }