master
John Lauer v2.0.643 (insiders): landed with a DOM probe (Log in link = not signed in; login form = sign-in page), on open/navigate… 5598ee3 1mo ago
//! Tab verbs: pup_open_tab, pup_list_tabs, pup_switch_tab, pup_close_tab, pup_switch_window.
//! Shapes follow docs/contract/pup_{open_tab,list_tabs,switch_tab,close_tab,switch_window}.json.

use crate::{attach_tab, browser_for, refresh_tab, resolve, session_row, touch, Ctx};
use pup_core::{err, ok, Caller};
use serde_json::{json, Value};
use std::time::Duration;

fn tab_row(ctx: &Ctx, s: &pup_session::Session, t: &pup_session::Tab, active: bool) -> Value {
    let errs = ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0);
    json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": active, "errorCount": errs, "fav": t.fav, "opener": "agent", "openerTabId": Value::Null, "sessionId": s.id, "window": s.id, "aiThread": s.owner,
        // 2.0.458: true only for the tabs pup keeps focus-emulated (the active one, plus the one a verb is
        // driving). A tab WITHOUT it is throttled and discardable by Chrome, which is what keeps a many-tab
        // pup window off the user's CPU and out of their RAM.
        "focusEmulated": crate::focus_emulated(ctx, &s.id, &t.tab_id) })
}

pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    if crate::transport::is_ext(&s) {
        let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
        let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();
        // 2.0.537: the same URL-dedup rule the CDP lane has had since 2.0.478. A recovery relaunch that raced a
        // caller's opens re-added a saved tab list on top of them (ti.com twice; 33 tabs for 20 sites, John,
        // 2026-09-13). A second open of a URL the window already holds activates that tab instead.
        if url != "about:blank" {
            if let Some(t) = s.tabs.iter().find(|t| t.url.trim_end_matches('/') == url.trim_end_matches('/')).cloned() {
                let _ = crate::transport::activate(&lane, &t);
                ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
                return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tab": tab_row(ctx, &s, &t, true), "tabId": t.tab_id, "count": s.tabs.len(), "transport": "ext", "reused": true, "landed": crate::landed_ext(ctx, &s, &t, &t.url, &t.title), "_hint": "This window already had a tab at that URL; it was activated instead of opening a duplicate." }));
            }
        }
        let mut sess = s.clone();
        let t = match crate::transport::open_tab(&lane, &mut sess, &url) { Ok(t) => t, Err(e) => return e };
        sess.tabs.push(t.clone()); sess.active_tab = sess.tabs.len() - 1;
        let _ = crate::transport::activate(&lane, &t);
        ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);
        crate::identity::overlay(ctx, &sess.id, caller);
        touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));
        return ok(json!({ "sessionId": sess.id, "window": sess.id, "aiThread": sess.owner, "tab": tab_row(ctx, &sess, &t, true), "tabId": t.tab_id, "count": sess.tabs.len(), "transport": "ext", "landed": crate::landed_ext(ctx, &sess, &t, &t.url, &t.title) }));
    }
    let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };
    let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();
    let target = match b.cdp.create_target(&url, false, None, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };
    let mut sess = s.clone();
    let tab_id = match attach_tab(&mut sess, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };
    // 2.0.467: the new tab is tracked the moment it is attached, BEFORE the activate and the load wait (up to
    // 15 s). Until now the session was written back only at the end, so the events pump's tab adoption (which
    // looks 400 ms after Target.targetCreated) saw pup's own new target as "opened outside pup" and attached it
    // a second time: after a crash recovery or pup_restart_browser a 3-tab window came back with 6 entries,
    // half of them duplicates on the same targets (ConfRoomROG and arav-rog soaks, 2026-09-11).
    ctx.sessions.insert(sess.clone());
    if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); }   // owner chip + floaty ride every new document from here on
    let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": target }), Duration::from_secs(5));
    if url != "about:blank" { if let Some(t) = sess.tabs.last_mut() { let rx = b.cdp.subscribe(Some(&t.cdp_session), Some("Page.loadEventFired")); let _ = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(15)); refresh_tab(&b.cdp, t); } }
    if let Some(cs) = sess.tabs.last().map(|t| t.cdp_session.clone()) { crate::inject_page_scripts(ctx, &sess, &b.cdp, &cs); }
    let row = sess.tabs.last().map(|t| tab_row(ctx, &sess, t, true)).unwrap_or(Value::Null);
    let count = sess.tabs.len();
    sess.active_tab = sess.tabs.len().saturating_sub(1);   // Target.activateTarget above put the new tab in front; the badge follows it
    ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);
    crate::focus_emulate(ctx, &sess.id, None);   // 2.0.458: the new tab is the active one now
    touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));
    crate::identity::overlay(ctx, &sess.id, caller);
    if let Some(a) = ctx.arc() { crate::identity::overlay_retries(a, sess.id.clone(), caller.clone()); }
    pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "memory": crate::memory_report(ctx, Some(&sess.profile)), "renderCheck": sess.tabs.last().map(|t| crate::render_check(&b.cdp, &t.cdp_session, None)).unwrap_or(Value::Null), "_verifyRender": crate::VERIFY_RENDER, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] }))
}

pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let mut s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::refresh_all(&lane, &mut s); ctx.sessions.with_mut(&s.id, |x| { x.tabs = s.tabs.clone(); x.active_tab = s.active_tab; }); }
    let active = s.active().map(|t| t.tab_id.clone());
    let rows: Vec<Value> = s.tabs.iter().map(|t| tab_row(ctx, &s, t, Some(&t.tab_id) == active.as_ref())).collect();
    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update() }))
}

pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let Some(tab_id) = args.get("tabId").and_then(|t| t.as_str()) else { return err("tab_id_required", "pup_switch_tab needs {tabId}", Some("pup_list_tabs shows the ids.")) };
    let Some(t) = s.tab_by_id(tab_id).cloned() else { return err("tab_not_found", format!("no tab \"{tab_id}\" in window \"{}\"", s.id), Some("pup_list_tabs shows the ids.")) };
    if crate::transport::is_ext(&s) {
        let lane = match crate::transport::lane_for(ctx, &s) { Ok(l) => l, Err(e) => return e };
        let activated = crate::transport::activate(&lane, &t).is_ok();
        ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
        if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
        crate::identity::overlay(ctx, &s.id, caller);
        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "activated": activated, "transport": "ext" }));
    }
    let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };
    let activated = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)).is_ok();
    ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });
    crate::focus_emulate(ctx, &s.id, None);
    touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));
    let mut t2 = t.clone(); refresh_tab(&b.cdp, &mut t2);
    crate::identity::overlay(ctx, &s.id, caller);   // Node: the badge follows the ACTIVE tab
    pup_core::merge(ok(tab_row(ctx, &s, &t2, true)), json!({ "activated": activated, "_hint": "Tab activated within its window (the window itself was NOT raised)." }))
}

pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    let t = match args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()) { Some(t) => t, None => return err("tab_not_found", "no such tab", None) };
    if s.tabs.len() <= 1 { return crate::close_window(ctx, caller, &json!({ "window": s.id })); }
    if let Ok(lane) = crate::transport::lane_for(ctx, &s) { crate::transport::close_tab(&lane, &t); }
    let remaining = ctx.sessions.with_mut(&s.id, |x| { x.tabs.retain(|tt| tt.tab_id != t.tab_id); if x.active_tab >= x.tabs.len() { x.active_tab = x.tabs.len().saturating_sub(1); } x.tabs.len() }).unwrap_or(0);
    ctx.tabstate.lock().unwrap().remove(&t.cdp_session);
    let active = ctx.sessions.get(&s.id).and_then(|x| x.active().map(|tt| tt.tab_id.clone()));
    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }
    crate::focus_emulate(ctx, &s.id, None);   // 2.0.458: a different tab is active now
    crate::identity::overlay(ctx, &s.id, caller);   // the active tab changed
    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "closedTab": t.tab_id, "remaining": remaining, "activeTabId": active }))
}

pub fn switch_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {
    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };
    ctx.sessions.set_active(&s.id);
    pup_core::merge(ok(session_row(ctx, &s)), json!({ "activeTabId": s.active().map(|t| t.tab_id.clone()), "background": if s.foreground { "foreground" } else { "backgrounded" }, "ownerSource": "session", "emulatedViewport": Value::Null, "errorCount": 0, "lastAgentUpdate": s.last_agent_update(), "_hint": "This window is now the active one for verbs that default to it. It was NOT raised." }))
}