Skip to content

Commit 78525a7

Browse files
arunpatroArun Patromiguelg719
authored
feat: don't close new opened tabs (#161)
* feat: port new page handling from JS Stagehand PR #844 - Add live page proxy that dynamically tracks the focused page - Implement context event listener to initialize new pages automatically - Remove automatic tab closing behavior in act_handler_utils and cua_handler - Keep both original and new tabs open when new pages are created - Ensure stagehand.page always references the current active page This implementation matches the behavior of browserbase/stagehand#844 * Update stagehand/handlers/cua_handler.py * Update stagehand/handlers/act_handler_utils.py --------- Co-authored-by: Arun Patro <[email protected]> Co-authored-by: Miguel <[email protected]>
1 parent 15fd40b commit 78525a7

File tree

5 files changed

+201
-23
lines changed

5 files changed

+201
-23
lines changed

stagehand/browser.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,16 +237,18 @@ async def connect_local_browser(
237237

238238
# Apply stealth scripts
239239
await apply_stealth_scripts(context, logger)
240+
241+
# Initialize StagehandContext
242+
stagehand_context = await StagehandContext.init(context, stagehand_instance)
240243

241244
# Get the initial page (usually one is created by default)
242245
if context.pages:
243246
playwright_page = context.pages[0]
244247
logger.debug("Using initial page from local context.")
248+
page = await stagehand_context.get_stagehand_page(playwright_page)
245249
else:
246250
logger.debug("No initial page found, creating a new one.")
247-
playwright_page = await context.new_page()
248-
249-
page = StagehandPage(playwright_page, stagehand_instance)
251+
page = await stagehand_context.new_page()
250252

251253
return browser, context, stagehand_context, page, temp_user_data_dir
252254

stagehand/context.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ async def inject_custom_scripts(self, pw_page: Page):
4040
async def get_stagehand_page(self, pw_page: Page) -> StagehandPage:
4141
if pw_page not in self.page_map:
4242
return await self.create_stagehand_page(pw_page)
43-
return self.page_map[pw_page]
43+
stagehand_page = self.page_map[pw_page]
44+
# Update active page when getting a page
45+
self.set_active_page(stagehand_page)
46+
return stagehand_page
4447

4548
async def get_stagehand_pages(self) -> list:
4649
# Return a list of StagehandPage wrappers for all pages in the context
@@ -53,24 +56,96 @@ async def get_stagehand_pages(self) -> list:
5356

5457
def set_active_page(self, stagehand_page: StagehandPage):
5558
self.active_stagehand_page = stagehand_page
56-
# Optionally update the active page in the stagehand client if needed
59+
# Update the active page in the stagehand client
5760
if hasattr(self.stagehand, "_set_active_page"):
5861
self.stagehand._set_active_page(stagehand_page)
62+
self.stagehand.logger.debug(
63+
f"Set active page to: {stagehand_page.url}",
64+
category="context"
65+
)
66+
else:
67+
self.stagehand.logger.debug(
68+
"Stagehand does not have _set_active_page method",
69+
category="context"
70+
)
5971

6072
def get_active_page(self) -> StagehandPage:
6173
return self.active_stagehand_page
6274

6375
@classmethod
6476
async def init(cls, context: BrowserContext, stagehand):
77+
stagehand.logger.debug("StagehandContext.init() called", category="context")
6578
instance = cls(context, stagehand)
6679
# Pre-initialize StagehandPages for any existing pages
80+
stagehand.logger.debug(f"Found {len(instance._context.pages)} existing pages", category="context")
6781
for pw_page in instance._context.pages:
6882
await instance.create_stagehand_page(pw_page)
6983
if instance._context.pages:
7084
first_page = instance._context.pages[0]
7185
stagehand_page = await instance.get_stagehand_page(first_page)
7286
instance.set_active_page(stagehand_page)
87+
88+
# Add event listener for new pages (popups, new tabs from window.open, etc.)
89+
def handle_page_event(pw_page):
90+
instance.stagehand.logger.debug(
91+
f"Page event fired for URL: {pw_page.url}",
92+
category="context"
93+
)
94+
instance._handle_new_page(pw_page)
95+
96+
instance.stagehand.logger.debug(
97+
f"Setting up page event listener on context (ID: {id(context)})",
98+
category="context"
99+
)
100+
context.on("page", handle_page_event)
101+
instance.stagehand.logger.debug(
102+
"Page event listener setup complete",
103+
category="context"
104+
)
105+
73106
return instance
107+
108+
def _handle_new_page(self, pw_page: Page):
109+
"""
110+
Handle new pages created by the browser (popups, window.open, etc.).
111+
This runs synchronously in the event handler context.
112+
"""
113+
async def _async_handle():
114+
try:
115+
self.stagehand.logger.debug(
116+
f"Creating StagehandPage for new page with URL: {pw_page.url}",
117+
category="context"
118+
)
119+
stagehand_page = await self.create_stagehand_page(pw_page)
120+
self.set_active_page(stagehand_page)
121+
self.stagehand.logger.log(
122+
"New page detected and initialized",
123+
level=2,
124+
category="context",
125+
auxiliary={"url": {"value": pw_page.url, "type": "string"}}
126+
)
127+
except Exception as e:
128+
self.stagehand.logger.error(
129+
f"Failed to initialize new page: {str(e)}",
130+
category="context"
131+
)
132+
import traceback
133+
self.stagehand.logger.error(
134+
f"Traceback: {traceback.format_exc()}",
135+
category="context"
136+
)
137+
138+
# Schedule the async work
139+
import asyncio
140+
try:
141+
loop = asyncio.get_running_loop()
142+
loop.create_task(_async_handle())
143+
except RuntimeError:
144+
# No event loop running, which shouldn't happen in normal operation
145+
self.stagehand.logger.error(
146+
"No event loop available to handle new page",
147+
category="context"
148+
)
74149

75150
def __getattr__(self, name):
76151
# Forward attribute lookups to the underlying BrowserContext

stagehand/handlers/act_handler_utils.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,10 +471,6 @@ async def handle_possible_page_navigation(
471471
category="action",
472472
auxiliary={"url": {"value": new_opened_tab.url, "type": "string"}},
473473
)
474-
new_tab_url = new_opened_tab.url
475-
await new_opened_tab.close()
476-
await stagehand_page._page.goto(new_tab_url)
477-
await stagehand_page._page.wait_for_load_state("domcontentloaded")
478474

479475
try:
480476
await stagehand_page._wait_for_settled_dom(dom_settle_timeout_ms)

stagehand/handlers/cua_handler.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -559,21 +559,19 @@ async def handle_page_navigation(
559559
pass # The action that might open a page has already run. We check if one was caught.
560560
newly_opened_page = await new_page_info.value
561561

562-
new_page_url = newly_opened_page.url
563-
await newly_opened_page.close()
564-
await self.page.goto(new_page_url, timeout=dom_settle_timeout_ms)
565-
# After navigating, the DOM needs to settle on the new URL.
566-
await self._wait_for_settled_dom(timeout_ms=dom_settle_timeout_ms)
562+
# Don't close the new tab - let it remain open and be handled by the context
563+
# The StagehandContext will automatically make this the active page via its event listener
564+
self.logger.debug(
565+
f"New page detected with URL: {newly_opened_page.url}",
566+
category=StagehandFunctionName.AGENT,
567+
)
567568

568569
except asyncio.TimeoutError:
569570
newly_opened_page = None
570571
except Exception:
571572
newly_opened_page = None
572573

573-
# If no new tab was opened and handled by navigating, or if we are on the original page after handling a new tab,
574-
# then proceed to wait for DOM settlement on the current page.
575-
if not newly_opened_page:
576-
await self._wait_for_settled_dom(timeout_ms=dom_settle_timeout_ms)
574+
await self._wait_for_settled_dom(timeout_ms=dom_settle_timeout_ms)
577575

578576
final_url = self.page.url
579577
if final_url != initial_url:

stagehand/main.py

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,81 @@
3333
load_dotenv()
3434

3535

36+
class LivePageProxy:
37+
"""
38+
A proxy object that dynamically delegates all operations to the current active page.
39+
This mimics the behavior of the JavaScript Proxy in the original implementation.
40+
"""
41+
42+
def __init__(self, stagehand_instance):
43+
# Use object.__setattr__ to avoid infinite recursion
44+
object.__setattr__(self, '_stagehand', stagehand_instance)
45+
46+
def __getattr__(self, name):
47+
"""Delegate all attribute access to the current active page."""
48+
stagehand = object.__getattribute__(self, '_stagehand')
49+
50+
# Get the current active page
51+
if hasattr(stagehand, '_active_page') and stagehand._active_page:
52+
active_page = stagehand._active_page
53+
elif hasattr(stagehand, '_original_page') and stagehand._original_page:
54+
active_page = stagehand._original_page
55+
else:
56+
raise RuntimeError("No active page available")
57+
58+
# Get the attribute from the active page
59+
attr = getattr(active_page, name)
60+
61+
# If it's a method, bind it to the active page
62+
if callable(attr):
63+
return attr
64+
65+
return attr
66+
67+
def __setattr__(self, name, value):
68+
"""Delegate all attribute setting to the current active page."""
69+
if name.startswith('_'):
70+
# Internal attributes are set on the proxy itself
71+
object.__setattr__(self, name, value)
72+
else:
73+
stagehand = object.__getattribute__(self, '_stagehand')
74+
75+
# Get the current active page
76+
if hasattr(stagehand, '_active_page') and stagehand._active_page:
77+
active_page = stagehand._active_page
78+
elif hasattr(stagehand, '_original_page') and stagehand._original_page:
79+
active_page = stagehand._original_page
80+
else:
81+
raise RuntimeError("No active page available")
82+
83+
# Set the attribute on the active page
84+
setattr(active_page, name, value)
85+
86+
def __dir__(self):
87+
"""Return attributes of the current active page."""
88+
stagehand = object.__getattribute__(self, '_stagehand')
89+
90+
if hasattr(stagehand, '_active_page') and stagehand._active_page:
91+
active_page = stagehand._active_page
92+
elif hasattr(stagehand, '_original_page') and stagehand._original_page:
93+
active_page = stagehand._original_page
94+
else:
95+
return []
96+
97+
return dir(active_page)
98+
99+
def __repr__(self):
100+
"""Return representation of the current active page."""
101+
stagehand = object.__getattribute__(self, '_stagehand')
102+
103+
if hasattr(stagehand, '_active_page') and stagehand._active_page:
104+
return f"<LivePageProxy -> {repr(stagehand._active_page)}>"
105+
elif hasattr(stagehand, '_original_page') and stagehand._original_page:
106+
return f"<LivePageProxy -> {repr(stagehand._original_page)}>"
107+
else:
108+
return "<LivePageProxy -> No active page>"
109+
110+
36111
class Stagehand:
37112
"""
38113
Main Stagehand class.
@@ -166,7 +241,8 @@ def __init__(
166241
self._browser = None
167242
self._context: Optional[BrowserContext] = None
168243
self._playwright_page: Optional[PlaywrightPage] = None
169-
self.page: Optional[StagehandPage] = None
244+
self._original_page: Optional[StagehandPage] = None
245+
self._active_page: Optional[StagehandPage] = None
170246
self.context: Optional[StagehandContext] = None
171247
self.use_api = self.config.use_api
172248
self.experimental = self.config.experimental
@@ -181,6 +257,7 @@ def __init__(
181257

182258
self._initialized = False # Flag to track if init() has run
183259
self._closed = False # Flag to track if resources have been closed
260+
self._live_page_proxy = None # Live page proxy
184261

185262
# Setup LLM client if LOCAL mode
186263
self.llm = None
@@ -415,15 +492,16 @@ async def init(self):
415492
self._browser,
416493
self._context,
417494
self.context,
418-
self.page,
495+
self._original_page,
419496
) = await connect_browserbase_browser(
420497
self._playwright,
421498
self.session_id,
422499
self.browserbase_api_key,
423500
self,
424501
self.logger,
425502
)
426-
self._playwright_page = self.page._page
503+
self._playwright_page = self._original_page._page
504+
self._active_page = self._original_page
427505
except Exception:
428506
await self.close()
429507
raise
@@ -435,15 +513,16 @@ async def init(self):
435513
self._browser,
436514
self._context,
437515
self.context,
438-
self.page,
516+
self._original_page,
439517
self._local_user_data_dir_temp,
440518
) = await connect_local_browser(
441519
self._playwright,
442520
self.local_browser_launch_options,
443521
self,
444522
self.logger,
445523
)
446-
self._playwright_page = self.page._page
524+
self._playwright_page = self._original_page._page
525+
self._active_page = self._original_page
447526
except Exception:
448527
await self.close()
449528
raise
@@ -623,6 +702,34 @@ def _handle_llm_metrics(
623702

624703
self.update_metrics_from_response(function_enum, response, inference_time_ms)
625704

705+
def _set_active_page(self, stagehand_page: StagehandPage):
706+
"""
707+
Internal method called by StagehandContext to update the active page.
708+
709+
Args:
710+
stagehand_page: The StagehandPage to set as active
711+
"""
712+
self._active_page = stagehand_page
713+
714+
715+
@property
716+
def page(self) -> Optional[StagehandPage]:
717+
"""
718+
Get the current active page. This property returns a live proxy that
719+
always points to the currently focused page when multiple tabs are open.
720+
721+
Returns:
722+
A LivePageProxy that delegates to the active StagehandPage or None if not initialized
723+
"""
724+
if not self._initialized:
725+
return None
726+
727+
# Create the live page proxy if it doesn't exist
728+
if not self._live_page_proxy:
729+
self._live_page_proxy = LivePageProxy(self)
730+
731+
return self._live_page_proxy
732+
626733

627734
# Bind the imported API methods to the Stagehand class
628735
Stagehand._create_session = _create_session

0 commit comments

Comments
 (0)