feat(tabs): t<N> prefix for tab ids; --label for named tabs; drop --tab peek flag (#1250)
* fix(tabs): preserve refs across --tab peek and cover outer-tab-closed path Follow-up to #1249 so `--tab <id>` is actually useful for agents: - Save and restore the outer tab's `ref_map`, `iframe_sessions`, and `active_frame_id` across a scoped command instead of clearing them. `snapshot` → `--tab N <cmd>` → `click @e1` now keeps the outer tab's refs intact. Scoped commands still see a clean slate so outer refs can't resolve against the scoped tab's DOM. - Close the coverage gap the Vercel review bot flagged on #1249: the previous `e2e_tab_scoped_command_handles_outer_tab_closed` test used `tab_close`, which is in the scoped-dispatch exclusion list, so it never exercised the restore-skip branch it claimed to test. Renamed to `e2e_tab_close_with_tab_id_closes_active_tab` with an honest docstring, and added `e2e_tab_scoped_command_outer_tab_closed_mid_dispatch` that actually hits the branch via `window.opener.close()` on a script-opened intermediate tab. - Add `e2e_tab_scoped_command_isolates_refs_from_outer_tab` pinning that outer refs don't bleed into the scoped tab's DOM resolution. - Rewrite `e2e_tab_scoped_command_clears_state_on_switch` as `e2e_tab_scoped_command_preserves_outer_tab_state`, verifying the restored @e1 still clicks end-to-end. - Update the 52 `--help` entries for `--tab <id>` to describe peek / restore semantics instead of a vague "Target specific tab ID". - Update README, docs site, config schema, and the agent-facing skills reference with working examples (refs survive the peek) and a "when to use \`--tab <id>\` vs \`tab <id>\`" guide so agents pick the right flag for their workflow. * fix(tabs): use t<N> prefix for tab ids, add --label for named tabs Follow-on to the tab work in #1249 and the prior commit, redesigning the tab handle surface before release since nothing ships these features yet. ## Why Incrementing integer tab ids (`1`, `2`, `3`) look indistinguishable from positional indices in command output, LLM-generated scripts, and docs. In the common single-agent case where position and id coincide, readers have no visual cue for which mental model they're using. Positional indices silently shift when unrelated tabs open/close, so misreading a handle as an index is a correctness hazard. ## Changes **Tab ids are now `t1`, `t2`, `t3` (strings).** Bare integer `tabId` values are rejected with a teaching message rather than silently accepted. The `t` prefix matches the `@e1` element-ref convention and makes ids unmistakably non-positional at a glance. **Labels.** Tabs can be created with a user-assigned label (e.g. `docs`, `app`) via `tab new --label <name> [url]`. Labels are interchangeable with `t<N>` ids everywhere a tab ref is accepted. They're never auto-generated, never rewritten on navigation, and must be unique within a session. **Dashboard fix.** `packages/dashboard/src/types.ts` declared `TabInfo.index: number` but the daemon has been sending `tabId` (not `index`) since #892, making `tab.index` `undefined` and breaking the dashboard's close/switch buttons silently. Updated the TS types and usages to consume `tabId` (string) and optional `label`, restoring the dashboard's tab interactions. ## Surface - `cli/src/native/browser.rs`: `TabRef::parse` / `format_tab_id` / `is_valid_label` / `PageInfo.label` / `BrowserManager::resolve_tab_ref` / `BrowserManager::has_label`. `tab_new` gains an optional label argument with duplicate rejection. All JSON responses use the string form and include the label. - `cli/src/native/actions.rs`: scoped-command pre-dispatch and `handle_tab_{switch,close,new}` parse string refs and resolve to stable ids. - `cli/src/{flags,commands,main,output}.rs`: `--tab` / config `tab` are `String`; `tab` subcommand accepts `t<N>` or a label and supports `tab new --label <name> [url]`. All 52 `--help` entries updated. - `agent-browser.schema.json`: `tab` property type is now `string` with a pattern matching `t<N>` or label form. - `packages/dashboard`: `TabInfo.tabId: string` / `label?: string | null`; `closeTabAtom`/`switchTabAtom` take `tabRef: string`; component props updated. - Docs: README, docs site (`commands/` and `configuration/`), and the agent-facing skills reference rewritten with the new examples. ## Tests - Added `TabRef::parse` / `format_tab_id` / `is_valid_label` unit tests pinning the bare-integer rejection, the teaching error, label rules, and round-tripping. - Added `test_tab_switch_by_id` / `_by_label` / `test_tab_new_with_label` / `_with_label_and_url` / `_with_url_then_label` in `commands.rs`; rewrote `test_tab_unknown_subcommand_errors` since labels make `tab select` a legitimate ref. - Added `e2e_tab_new_with_label_can_be_switched_and_peeked`, `e2e_tab_new_with_duplicate_label_errors`, `e2e_tab_scoped_command_rejects_bare_integer`. - Migrated every existing tab e2e test (and one unit test) from integer `tabId` to the string form. `cargo fmt`, `cargo clippy -- -D warnings`, all 30 non-ignored tab unit tests, all 13 tab e2e tests, and `tsc --noEmit` on the dashboard all pass. * refactor(tabs): drop --tab scoped peek flag; keep t<N> ids and labels After fleshing out `--tab <id|label>` in the previous commits (scoped pre/post-dispatch save/restore, ref preservation, outer-tab-closed edge case, full e2e coverage), the machinery-to-value ratio makes the feature hard to justify. Nixing it now while nothing has shipped. ## Why - Every new daemon feature touching per-tab state has to reason about scoped-dispatch interleaving. `ScopedRestore`, pre/post-dispatch hooks, and the exclusion list add ongoing maintenance tax. - Three separate PRs (#892, #1249, and this one pre-nix) were needed to reach "works correctly." That's a smell. - `tab <id|label>` switch + labels already cover the legible multi-tab workflow case. - `--tab` vs `tab <id>` have opposite lifecycle semantics but look identical, teaching every agent two things where one would do. - "Non-disruptive peek" isn't actually race-free: the daemon does swap active tab during execution, so a concurrent client between pre- and post-dispatch sees the scoped tab as active. - Ref-based interaction with scoped tabs never worked ergonomically — refs are per-tab, so `--tab N click @e1` requires `@e1` to already be on tab N, which means a prior switch, which negates the peek. - Adding a feature back is easy; removing shipped API is hard. If per-tab caching (`HashMap<tab_id, RefMap>`) lands later, `--tab` can be reintroduced essentially for free. That's the right time. ## Removed - `--tab <id|label>` global flag (`cli/src/flags.rs`, `cli/src/main.rs`, all 52 `--help` entries in `cli/src/output.rs`). - `tab` property in `agent-browser.schema.json` and the config-options row in `docs/src/app/configuration/page.mdx`. - `ScopedRestore` struct, pre/post-dispatch save/restore in `execute_command` (`cli/src/native/actions.rs`). - `impl Default for RefMap` in `cli/src/native/element.rs` (only added for `mem::take` in the scoped machinery). - `e2e_tab_global_targeting`, `_snapshot`, `_snapshot_non_contiguous`, `e2e_tab_scoped_command_preserves_outer_tab_state`, `_isolates_refs_from_outer_tab`, `_restores_active_tab`, `_outer_tab_closed_mid_dispatch`. 590 lines. - The "When to use `--tab` vs `tab <id|label>`" sections in README, docs site, and skills reference. ## Kept - Stable tab ids (`t1`, `t2`, `t3`) with bare-integer rejection. - User-assigned labels (`tab new --label docs [url]`), with duplicate rejection and interchangeable use everywhere a tab ref is accepted. - `BrowserManager::{active_tab_id, has_tab_id, resolve_tab_ref, has_label}` accessors (still used by the remaining tab handlers). - `TabRef::parse`, `format_tab_id`, `is_valid_label` and their unit tests. - Dashboard TS fix (`TabInfo.tabId` + `label`). - `e2e_tab_close_with_tab_id_closes_active_tab` (renamed docstring to drop the gone exclusion-list reference). - `e2e_tab_new_with_label_can_be_switched_and_closed` (rewrite of the previous `_and_peeked` test — now exercises only switch and close). - `e2e_tab_switch_rejects_bare_integer` (rewrite targeting the `tab_switch` daemon handler rather than the removed scoped path). net: -900 lines across 12 files. `cargo fmt`, `cargo clippy -D warnings`, all 25 non-ignored tab unit tests, all 6 tab e2e tests, and `tsc --noEmit` on the dashboard all pass.
This commit is contained in:
+229
-6
@@ -159,6 +159,12 @@ pub fn to_ai_friendly_error(error: &str) -> String {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PageInfo {
|
||||
pub tab_id: u32,
|
||||
/// Optional user-assigned label (e.g. "docs", "app"). Set via
|
||||
/// `tab new --label <name>`. Labels are agent-assigned and never
|
||||
/// auto-generated, never rewritten on navigation, and unique within a
|
||||
/// session. Agents use labels instead of `t<N>` for readable multi-tab
|
||||
/// workflows.
|
||||
pub label: Option<String>,
|
||||
pub target_id: String,
|
||||
pub session_id: String,
|
||||
pub url: String,
|
||||
@@ -166,6 +172,77 @@ pub struct PageInfo {
|
||||
pub target_type: String, // "page" or "webview"
|
||||
}
|
||||
|
||||
/// Canonical string form of a stable tab id: `t1`, `t2`, ... The `t` prefix
|
||||
/// disambiguates stable ids from positional indices (which the CLI no longer
|
||||
/// accepts) and matches the `@e<N>` convention used for element refs.
|
||||
pub fn format_tab_id(tab_id: u32) -> String {
|
||||
format!("t{}", tab_id)
|
||||
}
|
||||
|
||||
/// A tab reference as parsed from CLI/JSON input. Either a stable id like
|
||||
/// `t2` or a user-assigned label like `docs`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TabRef {
|
||||
Id(u32),
|
||||
Label(String),
|
||||
}
|
||||
|
||||
impl TabRef {
|
||||
/// Parse a user-supplied string tab reference. Rejects bare integers
|
||||
/// with a teaching error so agents and scripts don't silently confuse
|
||||
/// stable ids with positional indices.
|
||||
pub fn parse(input: &str) -> Result<Self, String> {
|
||||
let input = input.trim();
|
||||
if input.is_empty() {
|
||||
return Err("Empty tab reference; expected `t<N>` (e.g. `t2`) or a label".to_string());
|
||||
}
|
||||
if let Some(digits) = input.strip_prefix('t').or_else(|| input.strip_prefix('T')) {
|
||||
if !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()) {
|
||||
let id: u32 = digits.parse().map_err(|_| {
|
||||
format!(
|
||||
"Tab id `{}` out of range; ids are incrementing positive integers",
|
||||
input
|
||||
)
|
||||
})?;
|
||||
if id == 0 {
|
||||
return Err(format!(
|
||||
"Tab id `{}` is invalid; tab ids start at t1",
|
||||
input
|
||||
));
|
||||
}
|
||||
return Ok(TabRef::Id(id));
|
||||
}
|
||||
}
|
||||
if input.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(format!(
|
||||
"Expected a tab id like `t{}` or a label; positional integers are not accepted \
|
||||
(run `agent-browser tab` to list stable tab ids)",
|
||||
input
|
||||
));
|
||||
}
|
||||
if !is_valid_label(input) {
|
||||
return Err(format!(
|
||||
"Invalid tab label `{}`; labels must start with a letter and contain only \
|
||||
letters, digits, `-`, and `_`",
|
||||
input
|
||||
));
|
||||
}
|
||||
Ok(TabRef::Label(input.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Labels must look like identifiers: start with a letter, contain only
|
||||
/// letters/digits/dashes/underscores. This keeps them distinguishable from
|
||||
/// `t<N>` ids at a glance and safe to pass through shells without quoting.
|
||||
pub fn is_valid_label(s: &str) -> bool {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() => {}
|
||||
_ => return false,
|
||||
}
|
||||
chars.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WaitUntil {
|
||||
Load,
|
||||
@@ -395,6 +472,7 @@ impl BrowserManager {
|
||||
let tab_id = manager.assign_tab_id();
|
||||
manager.pages.push(PageInfo {
|
||||
tab_id,
|
||||
label: None,
|
||||
target_id: "provider-page".to_string(),
|
||||
session_id: String::new(),
|
||||
url: String::new(),
|
||||
@@ -463,6 +541,7 @@ impl BrowserManager {
|
||||
self.next_tab_id += 1;
|
||||
self.pages.push(PageInfo {
|
||||
tab_id,
|
||||
label: None,
|
||||
target_id: result.target_id,
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
@@ -489,6 +568,7 @@ impl BrowserManager {
|
||||
self.next_tab_id += 1;
|
||||
self.pages.push(PageInfo {
|
||||
tab_id,
|
||||
label: None,
|
||||
target_id: target.target_id.clone(),
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: target.url.clone(),
|
||||
@@ -837,6 +917,7 @@ impl BrowserManager {
|
||||
self.next_tab_id += 1;
|
||||
self.pages.push(PageInfo {
|
||||
tab_id,
|
||||
label: None,
|
||||
target_id: result.target_id,
|
||||
session_id: attach_result.session_id.clone(),
|
||||
url: "about:blank".to_string(),
|
||||
@@ -879,7 +960,8 @@ impl BrowserManager {
|
||||
.enumerate()
|
||||
.map(|(i, p)| {
|
||||
json!({
|
||||
"tabId": p.tab_id,
|
||||
"tabId": format_tab_id(p.tab_id),
|
||||
"label": p.label,
|
||||
"title": p.title,
|
||||
"url": p.url,
|
||||
"type": p.target_type,
|
||||
@@ -889,7 +971,61 @@ impl BrowserManager {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn tab_new(&mut self, url: Option<&str>) -> Result<Value, String> {
|
||||
/// Resolve a user-supplied `TabRef` (either `t<N>` or a label) to the
|
||||
/// stable numeric `tab_id`. Returns a teaching error for unknown tabs.
|
||||
pub fn resolve_tab_ref(&self, tab_ref: &TabRef) -> Result<u32, String> {
|
||||
match tab_ref {
|
||||
TabRef::Id(id) => {
|
||||
if self.has_tab_id(*id) {
|
||||
Ok(*id)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Tab {} not found; run `agent-browser tab` to list open tabs",
|
||||
format_tab_id(*id)
|
||||
))
|
||||
}
|
||||
}
|
||||
TabRef::Label(name) => self
|
||||
.pages
|
||||
.iter()
|
||||
.find(|p| p.label.as_deref() == Some(name.as_str()))
|
||||
.map(|p| p.tab_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No tab with label `{}`; run `agent-browser tab` to list open tabs",
|
||||
name
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true iff a tab already carries the given label.
|
||||
pub fn has_label(&self, label: &str) -> bool {
|
||||
self.pages.iter().any(|p| p.label.as_deref() == Some(label))
|
||||
}
|
||||
|
||||
pub async fn tab_new(
|
||||
&mut self,
|
||||
url: Option<&str>,
|
||||
label: Option<&str>,
|
||||
) -> Result<Value, String> {
|
||||
if let Some(label) = label {
|
||||
if !is_valid_label(label) {
|
||||
return Err(format!(
|
||||
"Invalid tab label `{}`; labels must start with a letter and contain only \
|
||||
letters, digits, `-`, and `_`",
|
||||
label
|
||||
));
|
||||
}
|
||||
if self.has_label(label) {
|
||||
return Err(format!(
|
||||
"Label `{}` is already used by another tab; labels must be unique within a \
|
||||
session",
|
||||
label
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let target_url = url.unwrap_or("about:blank");
|
||||
|
||||
let result: CreateTargetResult = self
|
||||
@@ -920,8 +1056,10 @@ impl BrowserManager {
|
||||
let tab_id = self.next_tab_id;
|
||||
self.next_tab_id += 1;
|
||||
let index = self.pages.len();
|
||||
let label = label.map(|s| s.to_string());
|
||||
self.pages.push(PageInfo {
|
||||
tab_id,
|
||||
label: label.clone(),
|
||||
target_id: result.target_id,
|
||||
session_id: attach.session_id,
|
||||
url: target_url.to_string(),
|
||||
@@ -930,7 +1068,12 @@ impl BrowserManager {
|
||||
});
|
||||
self.active_page_index = index;
|
||||
|
||||
Ok(json!({ "tabId": tab_id, "url": target_url, "total": self.pages.len() }))
|
||||
Ok(json!({
|
||||
"tabId": format_tab_id(tab_id),
|
||||
"label": label,
|
||||
"url": target_url,
|
||||
"total": self.pages.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn tab_switch(&mut self, index: usize) -> Result<Value, String> {
|
||||
@@ -960,8 +1103,13 @@ impl BrowserManager {
|
||||
page.title = title.clone();
|
||||
}
|
||||
|
||||
let tab_id = self.pages[index].tab_id;
|
||||
Ok(json!({ "tabId": tab_id, "url": url, "title": title }))
|
||||
let page = &self.pages[index];
|
||||
Ok(json!({
|
||||
"tabId": format_tab_id(page.tab_id),
|
||||
"label": page.label,
|
||||
"url": url,
|
||||
"title": title,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn tab_close(&mut self, index: Option<usize>) -> Result<Value, String> {
|
||||
@@ -978,6 +1126,7 @@ impl BrowserManager {
|
||||
let page = self.pages.remove(target_index);
|
||||
self.update_active_page_after_removal(target_index);
|
||||
let closed_tab_id = page.tab_id;
|
||||
let closed_label = page.label.clone();
|
||||
let _ = self
|
||||
.client
|
||||
.send_command_typed::<_, Value>(
|
||||
@@ -992,7 +1141,11 @@ impl BrowserManager {
|
||||
let session_id = self.pages[self.active_page_index].session_id.clone();
|
||||
self.enable_domains(&session_id).await?;
|
||||
|
||||
Ok(json!({ "tabId": closed_tab_id, "closed": true }))
|
||||
Ok(json!({
|
||||
"tabId": format_tab_id(closed_tab_id),
|
||||
"label": closed_label,
|
||||
"closed": true,
|
||||
}))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1557,6 +1710,75 @@ mod tests {
|
||||
use super::*;
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[test]
|
||||
fn test_format_tab_id() {
|
||||
assert_eq!(format_tab_id(1), "t1");
|
||||
assert_eq!(format_tab_id(42), "t42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_id() {
|
||||
assert_eq!(TabRef::parse("t1"), Ok(TabRef::Id(1)));
|
||||
assert_eq!(TabRef::parse("t42"), Ok(TabRef::Id(42)));
|
||||
assert_eq!(TabRef::parse("T7"), Ok(TabRef::Id(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_label() {
|
||||
assert_eq!(TabRef::parse("docs"), Ok(TabRef::Label("docs".to_string())));
|
||||
assert_eq!(
|
||||
TabRef::parse("app-2"),
|
||||
Ok(TabRef::Label("app-2".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
TabRef::parse("my_tab"),
|
||||
Ok(TabRef::Label("my_tab".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_rejects_bare_integer() {
|
||||
let err = TabRef::parse("2").unwrap_err();
|
||||
assert!(
|
||||
err.contains("positional integers are not accepted"),
|
||||
"error should teach the user to use `t<N>`: {}",
|
||||
err
|
||||
);
|
||||
assert!(err.contains("t2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_rejects_empty() {
|
||||
assert!(TabRef::parse("").is_err());
|
||||
assert!(TabRef::parse(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_rejects_zero() {
|
||||
let err = TabRef::parse("t0").unwrap_err();
|
||||
assert!(err.contains("start at t1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tab_ref_rejects_invalid_label() {
|
||||
assert!(TabRef::parse("2docs").is_err());
|
||||
assert!(TabRef::parse("-docs").is_err());
|
||||
assert!(TabRef::parse("docs!").is_err());
|
||||
assert!(TabRef::parse("docs space").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_label() {
|
||||
assert!(is_valid_label("docs"));
|
||||
assert!(is_valid_label("Docs"));
|
||||
assert!(is_valid_label("app-2"));
|
||||
assert!(is_valid_label("my_tab"));
|
||||
assert!(!is_valid_label(""));
|
||||
assert!(!is_valid_label("2docs"));
|
||||
assert!(!is_valid_label("-docs"));
|
||||
assert!(!is_valid_label("docs!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_track_popup_target_with_empty_url() {
|
||||
let target = TargetInfo {
|
||||
@@ -1589,6 +1811,7 @@ mod tests {
|
||||
fn test_update_page_target_info_in_pages_updates_existing_page() {
|
||||
let mut pages = vec![PageInfo {
|
||||
tab_id: 1,
|
||||
label: None,
|
||||
target_id: "popup-1".to_string(),
|
||||
session_id: "session-1".to_string(),
|
||||
url: String::new(),
|
||||
|
||||
Reference in New Issue
Block a user