From 449eeb807f85d95af3f5a45d033da1f4312cd800 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:12:20 +0000 Subject: [PATCH 1/3] Initial plan From 997e30184675c600b1dce6f299ef487919c016c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:21:19 +0000 Subject: [PATCH 2/3] Fix page pagination viewport scrolling Agent-Logs-Url: https://github.com/4fuu/open-browser-cli/sessions/f34b1a6f-8d2a-4fc0-a0ad-f00fae026432 Co-authored-by: 4fuu <96584640+4fuu@users.noreply.github.com> --- extension/src/background/service-worker.ts | 1 + extension/src/content/content-script.ts | 32 +++++++ extension/src/shared/types.ts | 10 +- src/cli/commands.rs | 102 ++++++++++++++++++--- src/protocol/messages.rs | 1 + 5 files changed, 131 insertions(+), 15 deletions(-) diff --git a/extension/src/background/service-worker.ts b/extension/src/background/service-worker.ts index 21046aa..4e4c35f 100644 --- a/extension/src/background/service-worker.ts +++ b/extension/src/background/service-worker.ts @@ -82,6 +82,7 @@ async function handleRequest(req: Request): Promise { case 'get_page': return await handleSnapshotRequest(req); case 'click': + case 'scroll': case 'type': case 'wait': return await forwardToContent(req); diff --git a/extension/src/content/content-script.ts b/extension/src/content/content-script.ts index 1941cdb..9c92d60 100644 --- a/extension/src/content/content-script.ts +++ b/extension/src/content/content-script.ts @@ -448,6 +448,8 @@ async function handleMessage(req: ContentRequest): Promise { return await handleSnapshot(req); case 'click': return await handleClick(req); + case 'scroll': + return await handleScroll(req); case 'type': return await handleType(req); case 'wait': @@ -646,6 +648,36 @@ async function handleType(req: ContentRequest): Promise { } } +async function handleScroll(req: ContentRequest): Promise { + const top = optionalNumber(req.params.top); + if (top === undefined) { + return { ok: false, error: 'top is required' }; + } + + cursorAgent.beginTask(); + try { + window.scrollTo({ + top: Math.max(0, top), + left: window.scrollX, + behavior: 'auto', + }); + await nextFrame(); + await nextFrame(); + + return { + ok: true, + data: { + action: 'scroll', + changed: true, + navigated: false, + scroll_top: window.scrollY, + }, + }; + } finally { + cursorAgent.endTask(); + } +} + async function handleWait(req: ContentRequest): Promise { const selector = optionalString(req.params.selector); const timeout = optionalNumber(req.params.timeout) ?? DEFAULT_WAIT_TIMEOUT_MS; diff --git a/extension/src/shared/types.ts b/extension/src/shared/types.ts index 5c45cfe..df86fed 100644 --- a/extension/src/shared/types.ts +++ b/extension/src/shared/types.ts @@ -58,7 +58,15 @@ export interface PageChunk { } export interface ContentRequest { - type: 'snapshot' | 'click' | 'type' | 'wait' | 'presence_start' | 'presence_stop' | 'resolve_url'; + type: + | 'snapshot' + | 'click' + | 'scroll' + | 'type' + | 'wait' + | 'presence_start' + | 'presence_stop' + | 'resolve_url'; params: Record; } diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 1f1444b..773a3b6 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -221,20 +221,19 @@ pub async fn page( actions::GET_PAGE }; let snapshot = fetch_snapshot(session_id, action).await?; - let resolved_page = if next || prev { - let viewport_height = snapshot.viewport.height.max(1.0); - let scroll_height = snapshot.scroll.height.max(viewport_height); - let total_pages = (scroll_height / viewport_height).ceil().max(1.0) as u32; - let current_page = - ((snapshot.scroll.top / viewport_height).floor() as u32 + 1).clamp(1, total_pages); - let target = if next { - (current_page + 1).min(total_pages) - } else { - current_page.saturating_sub(1).max(1) - }; - Some(target) + let resolved_page = resolve_requested_page(&snapshot, page_num, next, prev); + let snapshot = if let Some(target_page) = resolved_page { + send_ok(Request::new( + actions::SCROLL, + json!({ + "session_id": session_id, + "top": scroll_top_for_page(&snapshot, target_page), + }), + )) + .await?; + fetch_snapshot(session_id, actions::GET_PAGE_FRESH).await? } else { - page_num + snapshot }; let page_data = parse_page_from_snapshot(&snapshot, resolved_page)?; println!("{}", crate::cli::output::format_page(&page_data, json_mode, verbose)); @@ -1200,6 +1199,44 @@ fn is_wait_timeout_error(response: &Response) -> bool { .unwrap_or(false) } +fn total_pages_for_snapshot(snapshot: &crate::protocol::messages::RawSnapshot) -> u32 { + let viewport_height = snapshot.viewport.height.max(1.0); + let scroll_height = snapshot.scroll.height.max(viewport_height); + (scroll_height / viewport_height).ceil().max(1.0) as u32 +} + +fn current_page_for_snapshot(snapshot: &crate::protocol::messages::RawSnapshot) -> u32 { + let viewport_height = snapshot.viewport.height.max(1.0); + let total_pages = total_pages_for_snapshot(snapshot); + ((snapshot.scroll.top / viewport_height).floor() as u32 + 1).clamp(1, total_pages) +} + +fn resolve_requested_page( + snapshot: &crate::protocol::messages::RawSnapshot, + page_num: Option, + next: bool, + prev: bool, +) -> Option { + if next || prev { + let current_page = current_page_for_snapshot(snapshot); + let total_pages = total_pages_for_snapshot(snapshot); + Some(if next { + (current_page + 1).min(total_pages) + } else { + current_page.saturating_sub(1).max(1) + }) + } else { + page_num.map(|page| page.clamp(1, total_pages_for_snapshot(snapshot))) + } +} + +fn scroll_top_for_page(snapshot: &crate::protocol::messages::RawSnapshot, page_num: u32) -> f64 { + let viewport_height = snapshot.viewport.height.max(1.0); + let scroll_height = snapshot.scroll.height.max(viewport_height); + let max_scroll_top = (scroll_height - viewport_height).max(0.0); + ((page_num.saturating_sub(1) as f64) * viewport_height).min(max_scroll_top) +} + fn print_json(value: &T) -> Result<()> { println!("{}", serde_json::to_string_pretty(value)?); Ok(()) @@ -1339,7 +1376,23 @@ fn validate_setup_args(browser: &str, extension_id: Option<&str>) -> Result<()> mod tests { use super::*; use crate::page::structure::{Node, StoredBlock}; - use crate::protocol::messages::Response; + use crate::protocol::messages::{RawSnapshot, Response, ScrollState, Viewport}; + + fn snapshot(scroll_top: f64, scroll_height: f64, viewport_height: f64) -> RawSnapshot { + RawSnapshot { + url: "https://example.com".into(), + title: "Example".into(), + viewport: Viewport { + width: 1280.0, + height: viewport_height, + }, + scroll: ScrollState { + top: scroll_top, + height: scroll_height, + }, + nodes: Vec::new(), + } + } #[test] fn native_host_manifest_supports_chrome() { @@ -1515,6 +1568,27 @@ mod tests { assert!(text.contains("--fresh")); } + #[test] + fn resolve_requested_page_advances_to_adjacent_page() { + let snapshot = snapshot(850.0, 2400.0, 800.0); + assert_eq!(resolve_requested_page(&snapshot, None, true, false), Some(3)); + assert_eq!(resolve_requested_page(&snapshot, None, false, true), Some(1)); + } + + #[test] + fn resolve_requested_page_clamps_explicit_page_to_bounds() { + let snapshot = snapshot(0.0, 1500.0, 800.0); + assert_eq!(resolve_requested_page(&snapshot, Some(0), false, false), Some(1)); + assert_eq!(resolve_requested_page(&snapshot, Some(9), false, false), Some(2)); + } + + #[test] + fn scroll_top_for_page_caps_to_maximum_scroll_offset() { + let snapshot = snapshot(0.0, 1500.0, 800.0); + assert_eq!(scroll_top_for_page(&snapshot, 2), 700.0); + assert_eq!(scroll_top_for_page(&snapshot, 9), 700.0); + } + #[test] fn resolve_element_target_accepts_prefixed_id() { let page = PageData { diff --git a/src/protocol/messages.rs b/src/protocol/messages.rs index 95afe0f..e995128 100644 --- a/src/protocol/messages.rs +++ b/src/protocol/messages.rs @@ -149,6 +149,7 @@ pub mod actions { pub const GET_PAGE_FRESH: &str = "get_page_fresh"; pub const SEARCH: &str = "search"; pub const CLICK: &str = "click"; + pub const SCROLL: &str = "scroll"; pub const TYPE: &str = "type"; pub const WAIT: &str = "wait"; pub const GET_TEXT: &str = "get_text"; From ea1c3763800c3171883c22c3cc6d08a71282c09d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 14:25:11 +0000 Subject: [PATCH 3/3] Polish pagination scroll handling Agent-Logs-Url: https://github.com/4fuu/open-browser-cli/sessions/f34b1a6f-8d2a-4fc0-a0ad-f00fae026432 Co-authored-by: 4fuu <96584640+4fuu@users.noreply.github.com> --- extension/src/content/content-script.ts | 1 - src/cli/commands.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/extension/src/content/content-script.ts b/extension/src/content/content-script.ts index 9c92d60..271bdea 100644 --- a/extension/src/content/content-script.ts +++ b/extension/src/content/content-script.ts @@ -662,7 +662,6 @@ async function handleScroll(req: ContentRequest): Promise { behavior: 'auto', }); await nextFrame(); - await nextFrame(); return { ok: true, diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 773a3b6..ddd9a63 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -1217,16 +1217,16 @@ fn resolve_requested_page( next: bool, prev: bool, ) -> Option { + let total_pages = total_pages_for_snapshot(snapshot); if next || prev { let current_page = current_page_for_snapshot(snapshot); - let total_pages = total_pages_for_snapshot(snapshot); Some(if next { (current_page + 1).min(total_pages) } else { current_page.saturating_sub(1).max(1) }) } else { - page_num.map(|page| page.clamp(1, total_pages_for_snapshot(snapshot))) + page_num.map(|page| page.clamp(1, total_pages)) } }