Consistent Tab IDs & Global Tag Targeting (#892)

Introduces stable per-tab IDs and a global `--tab <id>` flag for scoping individual commands to a specific tab.

Breaking change: response payloads for `tab_list`, `tab_new`, `tab_switch`, `tab_close`, and `window_new` now use `tabId` instead of `index`. `tab_close` returns `{tabId, closed: true}` instead of `{closed, activeIndex}`. `agent-browser tab <unknown>` now errors instead of silently listing tabs.

Follow-up PR to land immediately after this fixes a compile error on the provider direct-page path, clears per-tab daemon state around scoped switches, and implements active-tab restoration so `--tab N` is non-intrusive as intended.
This commit is contained in:
Daniel Hails
2026-04-16 12:02:55 -05:00
committed by GitHub
parent c691b269cb
commit 67dc631977
7 changed files with 618 additions and 45 deletions
+64 -20
View File
@@ -1010,28 +1010,35 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
}
// === Tabs ===
"tab" => match rest.first().copied() {
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
"tab" => {
const VALID: &[&str] = &["list", "new", "close", "<id>"];
match rest.first().copied() {
Some("new") => {
let mut cmd = json!({ "id": id, "action": "tab_new" });
if let Some(url) = rest.get(1) {
cmd["url"] = json!(url);
}
Ok(cmd)
}
Ok(cmd)
}
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(index) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["index"] = json!(index);
Some("list") => Ok(json!({ "id": id, "action": "tab_list" })),
Some("close") => {
let mut cmd = json!({ "id": id, "action": "tab_close" });
if let Some(tab_id) = rest.get(1).and_then(|s| s.parse::<i32>().ok()) {
cmd["tabId"] = json!(tab_id);
}
Ok(cmd)
}
Ok(cmd)
Some(n) if n.parse::<i32>().is_ok() => {
let tab_id = n.parse::<i32>().expect("already checked parse succeeds");
Ok(json!({ "id": id, "action": "tab_switch", "tabId": tab_id }))
}
Some(sub) => Err(ParseError::UnknownSubcommand {
subcommand: sub.to_string(),
valid_options: VALID,
}),
None => Ok(json!({ "id": id, "action": "tab_list" })),
}
Some(n) if n.parse::<i32>().is_ok() => {
let index = n.parse::<i32>().expect("already checked parse succeeds");
Ok(json!({ "id": id, "action": "tab_switch", "index": index }))
}
_ => Ok(json!({ "id": id, "action": "tab_list" })),
},
}
// === Window ===
"window" => {
@@ -2335,6 +2342,7 @@ mod tests {
fn default_flags() -> Flags {
Flags {
session: "test".to_string(),
tab: None,
json: false,
headed: false,
debug: false,
@@ -2847,7 +2855,7 @@ mod tests {
fn test_tab_switch() {
let cmd = parse_command(&args("tab 2"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_switch");
assert_eq!(cmd["index"], 2);
assert_eq!(cmd["tabId"], 2);
}
#[test]
@@ -2856,6 +2864,42 @@ mod tests {
assert_eq!(cmd["action"], "tab_close");
}
#[test]
fn test_tab_close_with_id() {
let cmd = parse_command(&args("tab close 2"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_close");
assert_eq!(cmd["tabId"], 2);
}
#[test]
fn test_tab_switch_sends_tab_id() {
let cmd = parse_command(&args("tab 2"), &default_flags()).unwrap();
assert_eq!(cmd["tabId"], 2);
assert!(cmd.get("index").is_none());
}
#[test]
fn test_tab_close_sends_tab_id() {
let cmd = parse_command(&args("tab close 3"), &default_flags()).unwrap();
assert_eq!(cmd["tabId"], 3);
assert!(cmd.get("index").is_none());
}
#[test]
fn test_tab_no_args_defaults_to_list() {
let cmd = parse_command(&args("tab"), &default_flags()).unwrap();
assert_eq!(cmd["action"], "tab_list");
}
#[test]
fn test_tab_unknown_subcommand_errors() {
let result = parse_command(&args("tab select 3"), &default_flags());
assert!(
result.is_err(),
"tab select should error, not silently fall through to tab_list"
);
}
// === Network ===
#[test]