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:
Chris Tate
2026-04-16 14:33:43 -05:00
committed by GitHub
parent c201623710
commit 585d93a02b
15 changed files with 709 additions and 673 deletions
+23 -64
View File
@@ -665,6 +665,7 @@ impl DaemonState {
let tab_id = mgr.assign_tab_id();
mgr.add_page(super::browser::PageInfo {
tab_id,
label: None,
target_id: te.target_info.target_id.clone(),
session_id: attach.session_id,
url: te.target_info.url.clone(),
@@ -1275,44 +1276,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
);
}
// Pre-dispatch: if `tabId` is set on a non-tab command, temporarily switch
// to that tab for the duration of the command and restore the original
// active tab after. This lets `--tab N <cmd>` target a specific tab
// without stealing the user's active-tab context.
//
// We save the current tab's stable `tab_id` (not its array index) so a
// tab close during the scoped command doesn't leave us restoring to a
// shifted position. If the saved tab was closed, we skip the restore.
let restore_tab_id: Option<u32> = if !matches!(
action,
"tab_list" | "tab_new" | "tab_switch" | "tab_close" | "launch" | "close"
) {
if let Some(target_tab_id) = cmd.get("tabId").and_then(|v| v.as_u64()) {
let target_tab_id = target_tab_id as u32;
let current_tab_id = state.browser.as_ref().and_then(|mgr| mgr.active_tab_id());
if current_tab_id == Some(target_tab_id) {
// Already on the target tab; nothing to do.
None
} else {
// Clear per-tab daemon state before switching so refs from the
// outer tab can't resolve against the target tab's DOM.
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
if let Some(ref mut mgr) = state.browser {
if let Err(e) = mgr.tab_switch_by_id(target_tab_id).await {
return error_response(&id, &e);
}
}
current_tab_id
}
} else {
None
}
} else {
None
};
let result = match action {
"launch" => handle_launch(cmd, state).await,
"navigate" => handle_navigate(cmd, state).await,
@@ -1471,24 +1434,6 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
_ => Err(format!("Not yet implemented: {}", action)),
};
// Post-dispatch: if we temporarily switched tabs for this command, restore
// the original active tab. Skip silently if the tab no longer exists (e.g.
// the scoped command closed it).
if let Some(restore_tab_id) = restore_tab_id {
let still_exists = state
.browser
.as_ref()
.is_some_and(|mgr| mgr.has_tab_id(restore_tab_id));
if still_exists {
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
if let Some(ref mut mgr) = state.browser {
let _ = mgr.tab_switch_by_id(restore_tab_id).await;
}
}
}
let mut resp = match result {
Ok(data) => success_response(&id, data),
Err(e) => error_response(&id, &super::browser::to_ai_friendly_error(&e)),
@@ -1545,7 +1490,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
/// subsequent navigations don't hijack the user's existing tabs.
async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
let mut mgr = BrowserManager::connect_auto().await?;
mgr.tab_new(None).await?;
mgr.tab_new(None, None).await?;
let session_id = mgr.active_session_id()?.to_string();
let _ = mgr
.client
@@ -2648,7 +2593,7 @@ async fn handle_click(cmd: &Value, state: &mut DaemonState) -> Result<Value, Str
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
state.ref_map.clear();
mgr.tab_new(Some(&href)).await?;
mgr.tab_new(Some(&href), None).await?;
return Ok(json!({ "clicked": selector, "newTab": true, "url": href }));
}
@@ -3697,18 +3642,21 @@ async fn handle_tab_list(state: &DaemonState) -> Result<Value, String> {
async fn handle_tab_new(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
let url = cmd.get("url").and_then(|v| v.as_str());
let label = cmd.get("label").and_then(|v| v.as_str());
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
mgr.tab_new(url).await
mgr.tab_new(url, label).await
}
async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
let tab_id = cmd
let tab_ref_str = cmd
.get("tabId")
.and_then(|v| v.as_u64())
.ok_or("Missing 'tabId' parameter")? as u32;
.and_then(|v| v.as_str())
.ok_or("Missing 'tabId' parameter (expected `t<N>` or a label)")?;
let tab_ref = super::browser::TabRef::parse(tab_ref_str)?;
let tab_id = mgr.resolve_tab_ref(&tab_ref)?;
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
@@ -3737,7 +3685,13 @@ async fn handle_tab_switch(cmd: &Value, state: &mut DaemonState) -> Result<Value
async fn handle_tab_close(cmd: &Value, state: &mut DaemonState) -> Result<Value, String> {
let mgr = state.browser.as_mut().ok_or("Browser not launched")?;
let tab_id = cmd.get("tabId").and_then(|v| v.as_u64()).map(|i| i as u32);
let tab_id = match cmd.get("tabId").and_then(|v| v.as_str()) {
Some(s) => {
let tab_ref = super::browser::TabRef::parse(s)?;
Some(mgr.resolve_tab_ref(&tab_ref)?)
}
None => None,
};
state.ref_map.clear();
state.iframe_sessions.clear();
state.active_frame_id = None;
@@ -4123,6 +4077,7 @@ async fn handle_recording_start(cmd: &Value, state: &mut DaemonState) -> Result<
let tab_id = mgr.assign_tab_id();
mgr.add_page(super::browser::PageInfo {
tab_id,
label: None,
target_id: create_result.target_id,
session_id: new_session_id.clone(),
url: nav_url.clone(),
@@ -6036,6 +5991,7 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
let tab_id = mgr.assign_tab_id();
mgr.add_page(super::browser::PageInfo {
tab_id,
label: None,
target_id: create_result.target_id,
session_id: attach.session_id,
url: "about:blank".to_string(),
@@ -6063,7 +6019,10 @@ async fn handle_window_new(cmd: &Value, state: &mut DaemonState) -> Result<Value
let total = mgr.page_count();
state.ref_map.clear();
Ok(json!({ "tabId": tab_id, "total": total }))
Ok(json!({
"tabId": super::browser::format_tab_id(tab_id),
"total": total,
}))
}
async fn handle_diff_screenshot(cmd: &Value, state: &DaemonState) -> Result<Value, String> {