feat(ext): handling page reload/redirect/close

This commit is contained in:
Simon
2026-01-21 18:46:50 +08:00
parent 570c79623b
commit 3fea74faa9
5 changed files with 250 additions and 18 deletions

View File

@@ -22,15 +22,28 @@ import { type RPCClient, createRPCClient } from '../messaging/rpc'
*/ */
export class RemotePageController extends EventTarget { export class RemotePageController extends EventTarget {
private rpc: RPCClient private rpc: RPCClient
private _tabId: number | null = null
private _tabIdPromise: Promise<number>
/** Get the target tab ID (null if not yet resolved) */
get tabId(): number | null {
return this._tabId
}
/** Get the promise that resolves to the target tab ID */
get tabIdPromise(): Promise<number> {
return this._tabIdPromise
}
constructor() { constructor() {
super() super()
// Capture the active tab ID at construction time to avoid issues when tab loses focus // Capture the active tab ID at construction time to avoid issues when tab loses focus
const tabIdPromise = chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => { this._tabIdPromise = chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => {
if (!tab?.id) throw new Error('No active tab found') if (!tab?.id) throw new Error('No active tab found')
this._tabId = tab.id
return tab.id return tab.id
}) })
this.rpc = createRPCClient(tabIdPromise) this.rpc = createRPCClient(this._tabIdPromise)
} }
// ======= State Queries ======= // ======= State Queries =======

View File

@@ -17,11 +17,14 @@ import {
type AgentStatus, type AgentStatus,
type HistoricalEvent, type HistoricalEvent,
agentCommands, agentCommands,
contentScriptQuery,
} from '../messaging/protocol' } from '../messaging/protocol'
import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '../utils/constants' import { DEMO_API_KEY, DEMO_BASE_URL, DEMO_MODEL } from '../utils/constants'
// Agent instance (singleton for now - single page control) // Agent instance (singleton for now - single page control)
let agent: PageAgentCore | null = null let agent: PageAgentCore | null = null
// Track the target tab ID for event filtering
let targetTabId: number | null = null
// LLM configuration (persisted in storage) // LLM configuration (persisted in storage)
interface LLMConfig { interface LLMConfig {
@@ -46,6 +49,12 @@ export default defineBackground(() => {
// Register command handlers // Register command handlers
registerCommandHandlers() registerCommandHandlers()
// Register tab event listeners for page reload/close detection
registerTabEventListeners()
// Register content script notification handlers
registerContentScriptHandlers()
// Open sidepanel on action click // Open sidepanel on action click
chrome.sidePanel chrome.sidePanel
.setPanelBehavior({ openPanelOnActionClick: true }) .setPanelBehavior({ openPanelOnActionClick: true })
@@ -99,6 +108,12 @@ function getAgentState(): AgentState {
function createAgent(): PageAgentCore { function createAgent(): PageAgentCore {
const pageController = new RemotePageController() const pageController = new RemotePageController()
// Track the target tab ID for event filtering
pageController.tabIdPromise.then((tabId) => {
targetTabId = tabId
console.log('[PageAgentExt] Tracking tab:', tabId)
})
const newAgent = new PageAgentCore({ const newAgent = new PageAgentCore({
...llmConfig, ...llmConfig,
pageController: pageController as any, // Type assertion for interface compatibility pageController: pageController as any, // Type assertion for interface compatibility
@@ -122,6 +137,7 @@ function createAgent(): PageAgentCore {
newAgent.addEventListener('dispose', () => { newAgent.addEventListener('dispose', () => {
if (agent === newAgent) { if (agent === newAgent) {
agent = null agent = null
targetTabId = null
} }
eventBroadcaster.status('idle') eventBroadcaster.status('idle')
}) })
@@ -180,3 +196,53 @@ function registerCommandHandlers(): void {
console.log('[PageAgentExt] Command handlers registered') console.log('[PageAgentExt] Command handlers registered')
} }
/**
* Register tab event listeners for detecting page reload/navigation/close
*/
function registerTabEventListeners(): void {
// Listen for tab updates (page reload, navigation)
chrome.tabs.onUpdated.addListener((tabId, changeInfo, _tab) => {
// Only handle events for the target tab when agent is running
if (!agent || agent.disposed || tabId !== targetTabId) return
if (changeInfo.status === 'loading') {
// Page is reloading or navigating
console.log('[PageAgentExt] Target page is reloading/navigating')
agent.pushObservation(
'⚠️ Page is reloading. DOM state will change - wait for page to stabilize before next action.'
)
}
})
// Listen for tab close
chrome.tabs.onRemoved.addListener((tabId, _removeInfo) => {
// Only handle events for the target tab when agent is running
if (!agent || agent.disposed || tabId !== targetTabId) return
console.log('[PageAgentExt] Target page was closed')
agent.pushObservation(
'⚠️ Target page was closed by user. If this page is required for the task, consider marking the task as failed.'
)
// Clear target tab ID since it no longer exists
targetTabId = null
})
console.log('[PageAgentExt] Tab event listeners registered')
}
/**
* Register handlers for content script queries
*/
function registerContentScriptHandlers(): void {
// Handle shouldShowMask query - content script asks if mask should be shown
contentScriptQuery.onMessage('content:shouldShowMask', async ({ sender }) => {
const tabId = sender.tab?.id
// Check if there's an active task for this tab
const shouldShow = Boolean(tabId && agent && !agent.disposed && tabId === targetTabId)
console.log('[PageAgentExt] shouldShowMask query:', { tabId, targetTabId, shouldShow })
return shouldShow
})
console.log('[PageAgentExt] Content script handlers registered')
}

View File

@@ -9,13 +9,13 @@
*/ */
import { PageController } from '@page-agent/page-controller' import { PageController } from '@page-agent/page-controller'
import { pageControllerRPC } from '../messaging/protocol' import { contentScriptQuery, pageControllerRPC } from '../messaging/protocol'
export default defineContentScript({ export default defineContentScript({
matches: ['<all_urls>'], matches: ['<all_urls>'],
runAt: 'document_idle', runAt: 'document_idle',
main() { async main() {
console.log('[PageAgentExt] Content script loaded') console.log('[PageAgentExt] Content script loaded')
// Lazy-initialized controller - created on demand, disposed between tasks // Lazy-initialized controller - created on demand, disposed between tasks
@@ -40,6 +40,24 @@ export default defineContentScript({
} }
) )
// Check if there's an active task that needs mask to be shown
// This handles page reload/navigation during task execution
setTimeout(async () => {
try {
const shouldShowMask = await contentScriptQuery.sendMessage(
'content:shouldShowMask',
undefined
)
if (shouldShowMask) {
console.log('[PageAgentExt] Restoring mask after page reload')
await getController().showMask()
}
} catch (error) {
// Ignore errors - background may not be ready
console.log('[PageAgentExt] shouldShowMask check skipped:', error)
}
}, 100)
// Cleanup on page unload // Cleanup on page unload
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
controller?.dispose() controller?.dispose()

View File

@@ -132,6 +132,16 @@ export interface AgentCommandProtocol {
'agent:configure': (config: { apiKey: string; baseURL: string; model: string }) => void 'agent:configure': (config: { apiKey: string; baseURL: string; model: string }) => void
} }
// ============================================================================
// Content Script Query Protocol: ContentScript -> Background
// Used by ContentScript to query Background state
// ============================================================================
export interface ContentScriptQueryProtocol {
/** Check if there's an active task for this tab, returns true if mask should be shown */
'content:shouldShowMask': () => boolean
}
// ============================================================================ // ============================================================================
// Event Protocol: Background -> SidePanel // Event Protocol: Background -> SidePanel
// Used by Background to push updates to SidePanel // Used by Background to push updates to SidePanel
@@ -165,3 +175,9 @@ export const agentCommands = defineExtensionMessaging<AgentCommandProtocol>()
* Background sends, SidePanel receives * Background sends, SidePanel receives
*/ */
export const agentEvents = defineExtensionMessaging<AgentEventProtocol>() export const agentEvents = defineExtensionMessaging<AgentEventProtocol>()
/**
* Content script query messaging
* ContentScript sends, Background receives
*/
export const contentScriptQuery = defineExtensionMessaging<ContentScriptQueryProtocol>()

View File

@@ -12,6 +12,91 @@ import type {
ScrollOptions, ScrollOptions,
} from './protocol' } from './protocol'
/** RPC call configuration */
const RPC_CONFIG = {
/** Maximum retry attempts for transient failures */
maxRetries: 3,
/** Base delay between retries in ms (exponential backoff) */
retryDelayMs: 500,
/** Timeout for waiting for content script to be ready */
readyTimeoutMs: 5000,
}
/**
* Error thrown when RPC call fails due to tab/content script issues
*/
export class RPCError extends Error {
constructor(
message: string,
public readonly code: 'TAB_CLOSED' | 'CONTENT_SCRIPT_NOT_READY' | 'RPC_FAILED'
) {
super(message)
this.name = 'RPCError'
}
}
/**
* Sleep for a given number of milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* Check if a tab exists
*/
async function tabExists(tabId: number): Promise<boolean> {
try {
await chrome.tabs.get(tabId)
return true
} catch {
return false
}
}
/**
* Wrap an RPC call with error handling and retry logic
*/
async function withRetry<T>(tabId: number, operation: string, fn: () => Promise<T>): Promise<T> {
let lastError: Error | null = null
for (let attempt = 0; attempt < RPC_CONFIG.maxRetries; attempt++) {
try {
return await fn()
} catch (error) {
lastError = error as Error
const message = lastError.message || String(error)
// Check if tab still exists
if (!(await tabExists(tabId))) {
throw new RPCError(`Tab ${tabId} was closed during ${operation}`, 'TAB_CLOSED')
}
// Check for content script not ready errors
if (
message.includes('Could not establish connection') ||
message.includes('Receiving end does not exist')
) {
console.log(
`[RPC] Content script not ready for ${operation}, attempt ${attempt + 1}/${RPC_CONFIG.maxRetries}`
)
// Wait before retry with exponential backoff
await sleep(RPC_CONFIG.retryDelayMs * Math.pow(2, attempt))
continue
}
// For other errors, throw immediately
throw new RPCError(`RPC ${operation} failed: ${message}`, 'RPC_FAILED')
}
}
// All retries exhausted
throw new RPCError(
`Content script not ready after ${RPC_CONFIG.maxRetries} attempts for ${operation}`,
'CONTENT_SCRIPT_NOT_READY'
)
}
/** /**
* Create an RPC client bound to a specific tab. * Create an RPC client bound to a specific tab.
* The tabId is captured at creation time to ensure messages are sent to the correct tab * The tabId is captured at creation time to ensure messages are sent to the correct tab
@@ -22,76 +107,110 @@ export function createRPCClient(tabIdPromise: Promise<number>): RPCClient {
// State queries // State queries
async getCurrentUrl(): Promise<string> { async getCurrentUrl(): Promise<string> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId) return withRetry(tabId, 'getCurrentUrl', () =>
pageControllerRPC.sendMessage('rpc:getCurrentUrl', undefined, tabId)
)
}, },
async getLastUpdateTime(): Promise<number> { async getLastUpdateTime(): Promise<number> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId) return withRetry(tabId, 'getLastUpdateTime', () =>
pageControllerRPC.sendMessage('rpc:getLastUpdateTime', undefined, tabId)
)
}, },
async getBrowserState(): Promise<BrowserState> { async getBrowserState(): Promise<BrowserState> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId) return withRetry(tabId, 'getBrowserState', () =>
pageControllerRPC.sendMessage('rpc:getBrowserState', undefined, tabId)
)
}, },
// DOM operations // DOM operations
async updateTree(): Promise<string> { async updateTree(): Promise<string> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId) return withRetry(tabId, 'updateTree', () =>
pageControllerRPC.sendMessage('rpc:updateTree', undefined, tabId)
)
}, },
async cleanUpHighlights(): Promise<void> { async cleanUpHighlights(): Promise<void> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId) return withRetry(tabId, 'cleanUpHighlights', () =>
pageControllerRPC.sendMessage('rpc:cleanUpHighlights', undefined, tabId)
)
}, },
// Element actions // Element actions
async clickElement(index: number): Promise<ActionResult> { async clickElement(index: number): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:clickElement', index, tabId) return withRetry(tabId, 'clickElement', () =>
pageControllerRPC.sendMessage('rpc:clickElement', index, tabId)
)
}, },
async inputText(index: number, text: string): Promise<ActionResult> { async inputText(index: number, text: string): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId) return withRetry(tabId, 'inputText', () =>
pageControllerRPC.sendMessage('rpc:inputText', { index, text }, tabId)
)
}, },
async selectOption(index: number, optionText: string): Promise<ActionResult> { async selectOption(index: number, optionText: string): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId) return withRetry(tabId, 'selectOption', () =>
pageControllerRPC.sendMessage('rpc:selectOption', { index, optionText }, tabId)
)
}, },
async scroll(options: ScrollOptions): Promise<ActionResult> { async scroll(options: ScrollOptions): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scroll', options, tabId) return withRetry(tabId, 'scroll', () =>
pageControllerRPC.sendMessage('rpc:scroll', options, tabId)
)
}, },
async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> { async scrollHorizontally(options: ScrollHorizontallyOptions): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId) return withRetry(tabId, 'scrollHorizontally', () =>
pageControllerRPC.sendMessage('rpc:scrollHorizontally', options, tabId)
)
}, },
async executeJavascript(script: string): Promise<ActionResult> { async executeJavascript(script: string): Promise<ActionResult> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId) return withRetry(tabId, 'executeJavascript', () =>
pageControllerRPC.sendMessage('rpc:executeJavascript', script, tabId)
)
}, },
// Mask operations // Mask operations
async showMask(): Promise<void> { async showMask(): Promise<void> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId) return withRetry(tabId, 'showMask', () =>
pageControllerRPC.sendMessage('rpc:showMask', undefined, tabId)
)
}, },
async hideMask(): Promise<void> { async hideMask(): Promise<void> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId) // Don't retry hideMask - if content script is gone, mask is already hidden
try {
return await pageControllerRPC.sendMessage('rpc:hideMask', undefined, tabId)
} catch {
// Ignore errors - mask is effectively hidden if content script is gone
}
}, },
// Lifecycle // Lifecycle
async dispose(): Promise<void> { async dispose(): Promise<void> {
const tabId = await tabIdPromise const tabId = await tabIdPromise
return pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId) // Don't retry dispose - best effort cleanup
try {
return await pageControllerRPC.sendMessage('rpc:dispose', undefined, tabId)
} catch {
// Ignore errors - resources are already cleaned up if content script is gone
}
}, },
} }
} }