Skip to content

feat(webui): the workspace panel is a browser tab strip - #216

Merged
Fectivnfy112357 merged 34 commits into
webuifrom
feat/workspace-browser-tab
Oct 22, 2026
Merged

Fectivnfy112357 merged 34 commits into
webuifrom
feat/workspace-browser-tab

Conversation

@Fectivnfy112357

@Fectivnfy112357 Fectivnfy112357 commented Oct 21, 2026 •

Copy link
Copy Markdown

The workspace panel becomes a browser tab strip, per ADR 0022.

A tab is a stable id plus a mutable content, and the content carries the kind. The id is assigned by the client when the tab is opened and does not change when the content does, so two web tabs in one session are two tabs, and a tab that changes what it shows is still the same tab.

The runtime

  • The isolated Chromium holds one page per web tab, each with its own CDP session. A screencast frame is routed to its page by the envelope's event.sessionId — never by event.params.sessionId, which is the number 1 for frames from both pages.
  • The generation follows the page, per page: a navigation advances that page's generation and leaves the others alone, and a reference recorded before the move is refused on every page.
  • The runtime owns the session's web tabs and publishes them as a snapshot on a new stream operation, watchBrowserWebTabs. A snapshot rather than a delta: it is idempotent, so a client that reconnects reconciles against it instead of drifting.
  • A session's pages are released when the session ends — deleted, archived, or transferred away. The release is keyed on the session, and all three paths reach it.
  • open_tab answers with the identity of the page it created, and it is the one action a run with no selection may perform: it is the action that would create the selection the gate wants.

The client

  • The strip renders tool tabs and web tabs in one row, and the browser tool tab is gone with the browser kind — and with the surface switcher, because the panel is the isolated browser's view.
  • The new tab page is a launcher with four tiles and a url box. Choosing a tile replaces the current tab's content.
  • A web tab carries a toolbar — back, forward, reload, and the url shown read-only.
  • The run's selection and the tab the person is looking at are two cells with one writer each, with one documented bootstrap.

Evidence

The capability matrix (packages/webui/docs/browser-use/01-capability-matrix.md) is written from measurements, and every rule ADR 0022 pins is implemented with the file and the line that carries it. The acceptance record carries a real-browser row for the Agent's path, written from the records the suite writes.

The gate. The plan's §7 gates are green at the tip this pull request carries: typecheck, 208 files and 3230 unit tests, dependency direction, build, build boundary, the source projection (5407 published paths), 146 real-browser tests against real Chrome 152, and the installed package.

In CI the same gates are green: the run at 54db73a3 has verify (ubuntu-latest) and verify (windows-latest) passing — test:webui 208 files, test:webui-browser 146 passed, the source projection 5407 paths, the dependency and build-boundary checks, and the installed package.

The browser suite had been red on that runner since the previous programme landed its external-bridge cases (a18c916b, PR #195) — they hung with no diagnosis, because the test harness had waits with no deadline of their own. The harness now bounds every such wait and names its step (9d945e54), and the cleanup stops the browser before the server it was holding open (a4f8f7c6). The record is .plan/workspace-browser-tab/complete/gate-record.md.

Closes #199, closes #200, closes #201, closes #202, closes #203, closes #204, closes #205, closes #206, closes #207, closes #208, closes #209, closes #210, closes #211, closes #212

- Added IMPLEMENT-SPEC-PROMPT.md to release/public-source.json
…wser

Issue #200. The programme's later tickets are written against four
assumptions that no type checker catches. A spike settles all four
against a real isolated Chromium, and the capability matrix is written
from what it observed.

Measured: two pages of one Chromium can be driven, streamed and input
into at the same time; a navigation on the second page advances that
page's generation (1 -> 2) and leaves the first alone; a reference
recorded against a page's previous generation is refused with
browser_target_stale while the untouched page still accepts its own;
and the frame's page is resolved from the envelope's event.sessionId,
not from event.params.sessionId, which is the number 1 for frames from
both pages.

Two findings the repository has to act on, both already in scope:
the provider's screencast handler stamps every frame with its own
target id, and the provider's generation fence covers one page.

The record is .plan/workspace-browser-tab/complete/two-page-spike.json.
Issue #201. The provider drove exactly one page and refused every
other target, so a tab that was not the launch page could not be
driven, streamed or input into at all.

The single page becomes a per-target map: target id -> session,
generation, frame sequence and frame handler. The session is resolved
by target in sendCommand, sendInput, startImageStream and
stopImageStream; the generation check is per target rather than per
browser; and a screencast frame is matched to its page by the
envelope's event.sessionId, which is the flattened target session the
CDP connection puts on the event. event.params.sessionId is the
numeric screencast acknowledgement id and names no page -- two pages'
frames carry the same one.

The generation moves with the map. Page.enable goes to every page, so
a page that is not the launch page receives the navigation events that
raise its generation, and the raise is keyed by the event's own
session, so one page navigating leaves every other page's generation
where it was. The provider's own comment about the single page is
replaced by the rule that replaces it, and the rule names why the
generation is per page.

open_tab now attaches to the page it creates and registers it, so the
provider really does hold more than one page; a page the provider
never attached to is still refused rather than driven.

One browser fact the implementation had to meet: Chrome renders the
foregrounded tab and throttles the others, so a background page's
screencast produces no new frames. The provider therefore brings the
page it is streaming to the front, the same thing a browser does when
a person switches to a tab.

The new suite drives two real pages of a real isolated Chromium and
proves that each is driven, streamed and input into independently, that
a navigation advances one page's generation and leaves the other's
alone, that a stale reference is refused on the navigated page while
the untouched page still accepts its own, and that a frame reaches the
page that asked for it and no other.

The capability matrix is updated: the provider's rows are implemented,
and the foreground rule is recorded as measured.
Issue #202. The action created a page and returned the browser's own
answer, so the page it created could be named by nobody: the service
learned about it only when the provider happened to be asked for its
list again.

The action now answers with the page's identity -- the surface, the
browser id, the target id and the generation the provider's own
accounting gives it -- and the service observes that target in the
registry from the answer alone. The identity is therefore usable at
once: it can be selected and input into without waiting for the next
listing, which is what "the service observes the new target from that
identity alone" has to mean in practice.

The bridge is unchanged, and that is now a tested fact rather than an
assumption: open_tab on Edge or Chrome is still refused with
browser_capability_denied, because the extension drives the person's
own browser through chrome.debugger and cannot open a page in it. The
test drives the whole way -- the command reaches the extension, the
extension refuses the method as outside its supported subset, and the
refusal reaches the caller as browser_capability_denied.

One browser fact the identity has to account for: a page that is still
loading when the provider attaches to it is observed as a replaced
page when its first load lands, so a newly opened page's generation may
advance once, from 1 to 2. A page that finished loading before the
provider attached stays at 1. The capability matrix records this as
measured.
Issue #203. The runtime knew about a run's selection and about the
browser's target list, but not about which pages the session had open
as its tabs — so the client had to keep its own copy, and two copies
of "what is open" disagree exactly when a tab is closed.

The service now keeps the session's web tabs beside the run's
selection and publishes them as a snapshot: each tab's id, its target
identity, its title, its URL, and whether it is the run's selection. A
snapshot rather than a delta — the list is small, and a snapshot is
idempotent, so a client that reconnects reconciles against it instead
of drifting.

The snapshot travels on a new stream operation, watchBrowserWebTabs,
modelled on watchBrowserFrames. Registering it is more than adding a
name, and all eight sites are done: the capability manifest's
browser.ready list, the operation-name constants, the operation spec,
the contract type, the operation registry, the server's handler table,
the port member and its binding, and the transport's request table.
packages/webui/test/unit/browser-web-tabs-operation.test.ts is the
executable form of that list — a site that loses the operation fails
there rather than in a panel that quietly shows no tabs.

The tab's id belongs to the client and the runtime's target identity is
carried as the tab's binding, so a page that navigates keeps its
target id and gains a generation while the web tab stays the same web
tab. The provider forgets a page the browser destroyed, so a closed tab
does not leave a page behind that a command could still be aimed at.
Issue #204. The frame stream took { surface, runId? } and the service
resolved the run's selection to decide what to stream. That is what
made the person and the Agent share one page, and it is also what
stopped a person from looking at a second one.

The stream is now keyed by the target: the client asks for the page it
is showing, the service's frame subscribers are keyed by target, and a
frame is delivered only to the tab that asked for it. One screencast
per page, shared by that page's subscribers, because the provider holds
one frame handler per page and two subscriptions for the same page
would otherwise take each other's frames away.

This is a change to an existing operation's request shape, not a second
operation beside it. The name does not change; the request does. A
client that still sends a run id is refused with an explicit error
rather than answered with the run's selection, because a stream that
silently answers with the wrong page is worse than one that says the
request is out of date.

The fence is unchanged and it is the whole point: a frame has to name
the target the subscriber asked for, all four identity fields, and the
page behind that target has to still be the one the frame is a picture
of. A frame of another tab, or of a page that has been replaced, is
dropped rather than put on screen — and the drop is counted, because a
stream that quietly stops updating and a stream that is dropping frames
look identical from the outside.

The run's selection still decides what the Agent's commands act on.
The two are different cells and this change does not move the other.

The new real-browser case drives two pages of one real Chromium through
the service and proves that neither stream receives the other's frames.
Issue #205. There was no per-session lifecycle signal to hang the
release on, and a session is not only removed by deletion: archiving it
removes one, and so does a transfer away. A release that covered only
deletion would leave a page behind for every archived session, and a
page in the isolated Chromium is a live process with a live target.

The release is keyed on the session, and the edge it needed is the
composition root's: the runtime has no per-session signal, and the
composition root is the only place that holds both the harness port and
the browser service, so it is the place that can wire the two together.
The harness port's deleteSession and archiveSession now release the
session, and the transfer's rollback reaches the same deleteSession, so
all three ways a session goes away leave the same nothing behind.

The release closes the page, stops its screencast, forgets the target
in the registry and takes the run's selection with it. A target the
registry still offers is a target a command could still be aimed at, and
a screencast left running for a page that is gone is a resource the
browser holds for nobody. A session that is still open keeps its pages:
an explicit success of false means the session is still there, and
releasing it would close a page out from under a running session.

The new suite is the evidence for all three paths, and for the two
things that must not happen: another session's pages are not closed
because this one ended, and a subscriber receives no frame for a page
that no longer exists.
… a dead end

Issue #206. The tool adapter resolved the surface from the run's
selection and refused with browser_target_unknown when the run had
selected nothing. The refusal is right and stays for every action --
but it reached open_tab too, and open_tab is the action that would
create the selection the gate wants.

So open_tab is the one action a run with no selection may perform. It
asks the service to open a page; the service observes the new target,
makes it the run's selection, and publishes the new snapshot. The
surface is a fact rather than a choice the run made: a run with a
selection keeps the surface it selected on, and a run without one opens
a page in the isolated Chromium, because the embedded panel is the
isolated browser's view and the external browsers are driven in the
person's own windows.

The provider gains openTab as a first-class method, because open_tab is
not aimed at a page -- it creates one -- and the page a run is about to
open does not exist yet. A surface that cannot open a page has no such
member, and the service refuses with browser_capability_denied, which
is the extension bridge's case.

The refusals keep their meaning. A surface whose switch is off is
refused with browser_surface_not_authorized and no page is created. A
surface that cannot open a tab is refused with browser_capability_denied.
Every other action of a cold run is still refused with
browser_target_unknown, so the refusal reaches no further than it did.

The action is an explicit bootstrap and not a default: only the action
that creates a selection creates one. A second open_tab moves the run's
selection to the second page without losing the first.

The new real-browser case drives the whole path: a cold run is refused
everything, opens a tab, and then navigates the page it opened.
Issue #207. The tab's id was derived from its kind and its parts, so the
id changed when the content did — and a web tab had no id at all that
could tell two of them apart. The browser kind was one tab per session
and directory, and the workspace's change set and a turn's review
derived the same id, so opening one replaced the other.

A tab is now a stable id plus a content descriptor, and the content
carries the kind. The id is assigned by the client when the tab is
opened and does not change when the content does: two web tabs in one
session are two tabs, and a tab that changes what it shows is still the
same tab.

The rules that used to fall out of the id are content checks now, because
the id says nothing about what the tab is showing. The same file opens
one tab — per session and directory, as before. The workspace's change
set and a turn's review are two tabs, told apart by the content's
source. The files view, the canvas and the terminal are one tab per
session and directory, and that is an explicit rule rather than a side
effect of the id.

The browser kind is gone from the enumeration and nothing mounts the
browser tool tab: the panel's tabs are web tabs now. A web tab has no
add-menu entry, because a web tab is opened by typing a URL or by the
Agent.

The stale-read hazard the mutable content brings is answered on both
sides. The effects that fill the caches depend on the content, not on
the id, so a content change re-runs them; and the file results are keyed
by what the tab is showing rather than by which slot it is, so a tab
that changed from one file to another reads the new one instead of
keeping the old one's result.
Issue #208. The strip is the workspace's one row of tabs now: tool tabs
(files, review, canvas, terminal) and the runtime's web tabs in one row.
A web tab is opened by the runtime publishing it through
`watchBrowserWebTabs`, and the strip turns each published tab into an
`open-web-tab` command so the workspace's reducer holds the canonical
list — the strip does not keep its own copy.

Three things moved out of `WorkspacePanels.tsx` and into a dedicated
`WebuiWorkspaceTabStrip.public.tsx` because the panel had grown over the
size where "everything about the panel" still fits in one file: the
per-tab close button, the active tab selection, and the new tab
affordance. The new tab opens a tab with the new `launcher` content kind
— the smallest shape that lets the strip open a tab as the new tab page
rather than as a placeholder. The launcher UI itself (tiles and URL box)
lives in the next programme step.

A web tab shows the page's title and a default glyph; no favicon is
fetched. The title follows the page: the reconciliation dispatches when
the title, the url or the generation moved, and the reducer updates the
content under the tab that already holds it — without moving the person
off the tab they were looking at. A snapshot that changes nothing still
produces no work.

The surface switcher that used to sit at the top of `BrowserPanel` is
gone. Edge and Chrome are reached in the person's own windows, and the
only surface the panel ever shows is the isolated Chromium:
`BrowserPanelHost` is now keyed by the web tab's target rather than by
a switchable surface. The two published components keep their names so
the Desktop fork's imports stay intact.

The tool tabs keep the glyphs they always had — a file tab's file-type
badge is a rendering of the content, and the content moved to the strip
with the row. The strip owns the row, so the strip is what scrolls a
focused, off-screen tab into view.
Issue #209. A newly opened tab shows a launcher: four tiles -- 变更 /
终端 / 画布 / 查看文件 -- and a URL box. Choosing either replaces that
tab's content, because a tab is a stable id and a mutable content, and
the slot the person opened is the slot that shows what they asked for.
The tab keeps its id and is neither closed nor replaced.

The URL box reaches the runtime through the browser business's port,
which is the point of it. The Agent reaches open_tab through its browser
tools and a person reaches it through the new tab page, and both now
land on the same call: a new unary operation, openBrowserWebTab, whose
handler calls WebuiBrowserPort.openWebTab -- the member the tool adapter
already reached. The surface's switch, the provider gate, the session's
web-tab record and the refusal codes are shared, so a switched-off
surface refuses a typed url with the same browser_surface_not_authorized
the Agent gets, before a provider is asked for a page. The request names
a url and a run and no surface; a body with no run is the panel's own
cell, so opening a page by hand does not move any run's selection.

The answer and the runtime's own snapshot race, and both have to reach
the tab that asked. So the launcher dispatches request-web-page before
it calls the port, and the request rides on the launcher's content: an
open-web-tab that names a tab binds the page there, and an open-web-tab
that names none -- the snapshot's shape -- binds it to a launcher that
is waiting for a page. Either order, one page, one tab. Neither
activates the tab, so a person who looked elsewhere while the call was
in flight keeps looking at what they were looking at.

The add menu is gone and its entries are the launcher's tiles. The rows
that described them stayed and were renamed -- addMenu becomes tile,
webuiWorkspaceAddMenu becomes webuiWorkspaceLauncherTiles -- because a
menu that no longer exists is not a name anything should keep, and the
menu's one rule was the one worth keeping: the launcher's list is the
registry's rows that carry a tile, never a second enumeration. The
tiles use the icons the menu used. addMenuOpen and toggle-add-menu are
gone with the menu, rather than left driving nothing.

open-new-tab opens a fresh new tab page every time. The singleton rule
was right while a launcher was a placeholder, and wrong now: the page a
person typed a url into is a web page with no url box left, so pressing
"+" again is how they ask for another one, without losing the first.

A bare host is given a scheme before the browser is told, because a
person types a host as often as a scheme and a host is not something the
browser can open. A refusal is the runtime's own message on the
launcher, and the launcher keeps its box: a page that cannot be opened
is not a closed page.

The launcher lives beside the strip that owns the row rather than in a
new file: the ADR gives it a home, and a component that belongs to an
existing one costs no new source path, no frozen-fixture entry and no
suite-group entry.

The next step is a web tab's own toolbar -- back, forward, reload and a
read-only URL -- and the bootstrap that gives a cold run the page a
person opened. Neither is in this shape.
A web tab was a picture and nothing else: there was no way to go back, to go
forward, or to re-read the page, and the only way to reach another url was to
open another tab. The toolbar is back, forward, reload, and the url shown as
text — the three commands a tab does to the page it holds, and no address box,
because the only place a person types a url is the new tab page.

The toolbar is the browser business's chrome, rendered by the published host the
strip already mounts. The strip owns the row of tabs; a workspace component
drawing a foreign business's page chrome would invert the dependency rule, and
ADR 0022's component table had already given the host this job ("binds a tab to
a target, shows the picture and the toolbar"). So the strip hands over the tab's
target and the url the runtime published with it, and the host drives the port.
Both are values rather than authority: the host, not the strip, decides what is
sent.

The three presses go through the existing `sendBrowserCommand` operation — no
second operation, no second route. What the request gained is an optional
target, because a web tab holds exactly one page and the person pressing back
means the page in front of them: a request without a target still resolves the
surface's selection, which is a run's page or the panel's own cell and is not
necessarily the page the tab is showing. The handler routes a named target to
the service's new `sendCommandToTarget`, which makes the same fences in the
same order — the surface's switch, the command gate, then the generation — and
deliberately moves no selection, since a tab's toolbar opens nothing. The Agent's
tools are untouched: a request with no target means exactly what it meant.

A refusal is not papered over. A bridge that is switched off still answers
`browser_capability_denied`, a page that moved still answers
`browser_target_stale`, and the page is left alone.

Measured against a real isolated Chromium: `back` lands on the page it came
from, `forward` on the one it left, `reload` re-reads the page it is on, the
generation advances with each move so the pre-move reference is refused while
the tab's current one still works, and a `back` with nothing behind it is
refused with the provider's own code.
The panel had one selection and two writers. The Agent wrote it through
`open_tab`, and a person wrote it by clicking a row in the panel's target
list — and the panel then bound its pointer, key and text gestures to it,
so a person typing in their own tab typed into the page the Agent was
working on. One cell with two writers is not a selection at all; it is
whichever writer went last.

ADR 0022 already decided the shape. There are two cells, one writer each:
the run's selection is the runtime's, and the Agent writes it; the tab the
person is looking at is the client's, and the person writes it. This change
splits them where the code could not tell them apart, and names them where
the documents had to explain them.

The client's cell is `WebuiBrowserPanelState.viewedPage`, written by the
strip through the host's `page-viewed` and read by everything that shows or
forwards input: the picture, the alt text, the frame stream and the
gesture. `prepareWebuiBrowserInputCommand` now takes that page as its own
argument, so there is no field of the run's selection left in its signature
for a gesture to be aimed at — the binding cannot drift back without
someone adding the field again.

The runtime's cell is `runSelection`, mirrored from the mark the runtime
puts on the target list. The panel reports it and cannot change it: the
target list is a report rather than a control, because a person choosing a
tab is not a way to move the Agent.

The one write that crosses cells is the bootstrap, and the runtime makes
it. `openWebTab` now says who opened the page — `WebuiBrowserTabOpener` is
`"agent"` or `"person"`, and the `openBrowserWebTab` handler is the
person's side — and a person-opened page joins the run's selection only
when the run had none at the moment the page appeared. That decision cannot
live on the client: the client cannot see a run's selection at all. So the
workspace names its session in the request and applies nothing itself, and
an Agent-opened page is unconditional, because a run writing its own cell is
not a bootstrap and must not become one.

The strip's web-tab subscription follows the same run, so a session's pages
are recorded in one cell whether the Agent or the person opened them.

A real isolated Chromium shows the two apart: the Agent drove one page while
the person typed in another and neither page received the other's input; on
the page the bootstrap handed a cold run, both were delivered; and an input
recorded before a page moved was refused with `browser_target_stale` rather
than dropped in silence.
Issue #212. The acceptance record for this feature is written **from**
the records the suites write, so this adds the suite that writes them:
a real isolated Chromium, the Agent's tool opening a real tab, the
strip gaining it, the picture updating, and the person taking over.

Fourteen cases, one per row of the acceptance matrix, each observed
through the shipped paths — the Agent's tool path through the browser
tool adapter, the person's launcher path through the server's own
handler, the input path through the client's own binding, and the
provider itself. The records land in this programme's own completion
directory, so a case from one programme can never be read as a case
from the other: the acceptance helper is now plan-scoped, and the
previous programme's suites keep writing where they always have.

Writing the acceptance found a race. A page that is still loading is
observed as a replaced page when its first load lands, and that
observation can arrive before the answer that opened it does — so a
selection written from the answer alone was born stale, and the Agent's
next action was refused against a page it had just opened. The service
now writes the registry's generation, which is the current one, and the
selection follows the page from there.

The documentation is caught up with the code. The tab model's rules
become `implemented`, each with the file and the line that carries it;
the rules that are not the tab model's — a caller's obligation, a
concurrency rule the tree does not enforce — stay `required` and say
so. Round 11's four findings are closed, each with the evidence that
closed it. The known gaps and the open questions name this programme's
state honestly, the ADR says it is implemented, and the glossary names
the client's cell.
An independent review of the tab model (codex, gpt-6-luna) found five
things, and the gate found a sixth. All six are fixed here.

**Closing a web tab left its page running.** The strip's close button
dispatched the client command and nothing else: the runtime kept
publishing the tab and the isolated Chromium kept the page alive, and
the next snapshot could make the supposedly closed tab reappear. There
is now a `closeBrowserWebTab` operation beside `openBrowserWebTab`, and
the button goes through it. The page is closed in the browser **first**
and the tab leaves the strip after: the runtime stops publishing the tab
when its page is closed, and a tab that left the strip while the
runtime still published it would be re-added by the next snapshot.

**The person’s launcher could be refused because the Agent was working
in Edge.** The launcher sent the run’s id, and the runtime chose the
run’s *selected* surface — so a url typed in the panel went to the
bridge, which cannot open a page. The panel is the isolated browser’s
view, so the person’s page is always opened in the isolated Chromium,
and the run keeps the page it was working on.

**The bootstrap could race an Agent selection.** The run’s selection is
now read before the page is created, and it is read across every
surface: the Agent acts on one page, so the bootstrap is about the run
and not about a surface.

**The acceptance record overstated what it measured.** The case now
reads the runtime’s own published stream rather than a fabricated
snapshot, and it says what it did: the runtime publishes the tab, the
strip’s reconciliation turns it into a panel command, and the reducer
makes it the active tab. The mounted strip is covered by the unit
suites.

**The authorization trace’s source pointers were stale.** They name the
fence that is there now.

And the gate found the sixth: the file results were read by the tab id
while the read was filed by the content key, so a file never appeared.
The render reads the key the read writes, and the effect that reads a
file follows the content key rather than the content object — a content
replaced by an equal one is the same file, and re-reading it superseded
the read that was already in flight.
The plan says the slice is delivered, names the board that carried it,
and says that the plan is tracked — the previous programme's plan was
left out of the repository, and an implementer working in a worktree
could not see it.

The port-slices test names the browser slice's members, and the close
operation is one of them now.
The programme was reviewed with the local OpenAI Codex through paseo
(`codex/gpt-6-luna`). The review found five things and the WebUI gate
found a sixth: closing a web tab left its page running, the person's
launcher could be refused because the Agent was working in Edge, the
bootstrap could race an Agent selection, the acceptance record
overstated what it measured about the client strip, the authorization
trace's source pointers were stale, and the file results never appeared.

The record is here so the review is part of the programme's evidence
rather than a claim in a commit message: what the reviewer was asked
for, what it confirmed, each finding and what closed it, and what it
did not check.
The review record is a new file in the published projection, and the
inventory is what says so. Without it `check:source` fails — one of the
plan's own gates.
@Fectivnfy112357
Fectivnfy112357 force-pushed the feat/workspace-browser-tab branch from 83d8052 to 5d15467 Compare October 21, 2026 17:00
The plan's §7 names six gates and §9 makes them the completion
definition. This is that record — the commands, the results, the
environment and the tree — so the claim can be checked without running
anything again.

It also says plainly how often the gate ran: three times, each after a
whole block of changes, and the first two runs each found a real
defect. The focused suites ran per issue; the full gate did not.
Two real defects, both found by the gate and both intermittent.

**The listing compared a selection it had read before the listing.** The
service read the run's selection once, before asking the provider for its
targets, and compared that snapshot with each target it came back with. A
page that is still loading is observed as a replaced page, and that
observation moves the generation while the listing is in flight — so the
snapshot was a generation behind, the comparison failed, and the tab was
reported as not selected. The selection is now read per target, after the
listing, and it follows the page it names: the selection names a page,
and a page that navigated is still the same page.

**The registry could end up behind the provider.** The announcement that
a page was replaced arrives at the service, and the registry raises the
generation — but a page that is still loading is observed as a replaced
page while it is being attached to, and the announcement can arrive
before the service has observed the target at all. The raise was then
lost, and the registry kept a generation the provider had already moved
past. The registry now adopts a provider-reported generation that is
**ahead**: the provider's accounting is the authority on which page is
there now. A sighting that reports the same generation, or an older
one, still changes nothing — a repeated sighting must not invalidate a
reference.

**The cleanup raced the browser.** The provider removes its profile
directory right after killing the browser, and the browser may still be
writing it: the removal failed with ENOTEMPTY and failed the suite's
file, with every test in it passing. The removal is now retried.

The unit suite was run four times after the fix: 208 files and 3230
tests, every time.
The gate had been red twice after the plan was delivered, both times
for a real defect in the service: a selection compared against a
generation that had moved while the listing was in flight, and a
profile directory removed while the browser was still writing it. Both
are fixed, and the record says so rather than claiming a gate that
had not been run since.
The fork's CI runs the repository's whole verify profile, which is wider
than the plan's own gates. Its history on this branch and on the base
branch is now recorded, including the two jobs the base branch already
failed before this programme started.
The claim is checkable against a commit rather than against a tree: the
gates were run at 4e7efd4, and the only commits since are this record
and the inventory line it adds. It also records the repository's
capability suite, which the CI had called red: it passes here, so that
failure belongs to the macOS runner and not to this change.
@Fectivnfy112357
Fectivnfy112357 force-pushed the feat/workspace-browser-tab branch from a26311f to acbf1b4 Compare October 21, 2026 17:36
…ommit

The CI's ubuntu job is red on six external-bridge cases that need a
real external browser with the extension; the same cases pass here in
five seconds, and the extension and the bridge worker are not touched
by this slice. The webui unit suite and the installed-package check
both pass in CI.
The verify matrix runs on pull requests, so the fork's webui branch has
one Source verification run in its history, at an older commit, and it
failed the macOS and ubuntu jobs. The branch also carries a second
workflow that has failed on every recent commit. Neither is this
slice's.
The external-bridge spec's guard cannot fire for the chromium channel,
because the installed-browser probe returns true for it unconditionally.
So the case runs on a runner that cannot drive a real browser with the
extension and fails as a timeout, where the spec's own comment says it
should say so instead.

Tightening that probe is a change to the previous programme's spec, in
an area this slice does not cover. It is recorded, not made.
…at it did not

The record carried a wrong claim about the CI's ubuntu failure: it said the
runner "cannot drive a real browser with the extension". The same job's
sibling spec passes in both channels -- the handshake completes in 785 ms in
chromium and in 22.6 s in msedge -- so the runner does drive a real browser
with the built extension, and the extension does pair with the host. What is
true is narrower: the external spec's operations against the live tab never
come back.

It also carried the wrong verdict for the macOS job. At the tip, the matrix
cancelled it when the ubuntu job failed, so it has no verdict at all -- not a
pass. It passed at acbf1b4, on the same code.

And it did not say where the redness comes from. The same six cases failed the
same way on a18c916, the browser-use programme's own head commit, merged as
PR #195 and an ancestor of this branch's base: 138 passed, 6 failed, at
test:webui-browser. This slice changes neither the extension, nor the bridge
server, nor that spec, so the redness is inherited rather than introduced.

The record now says the gate ran five times rather than once -- with what
each run found -- and separates those runs from the targeted re-runs of a
single suite. It names the commit whose subject covers only part of what the
commit changes, and why that subject was not rewritten.

The review record now carries the invocation itself: the paseo agent, the
model, the thinking option, the Codex session, the tokens, and the prompt
verbatim, so the review is not only attested by the commit that fixed it.
The six external-bridge cases fail on the CI runner with the test's own
120-second timeout and nothing else: the report says the test timed out, and
not which step stopped. That is because several of the harness's waits on the
extension have no deadline of their own -- a service worker Chrome has
reclaimed does not answer, and a message nobody replies to does not settle --
and a wait that hangs there is indistinguishable from a slow case.

Every wait in the harness that can outlive its case is now bounded, and each
bound names the step it was waiting for. A case that cannot connect now says
"the extension did not answer the reconnect message" instead of running into
Playwright's timeout.

This does not make a failing case pass, and it does not skip one: a bound
that expires fails the case with a diagnosis. It is here so that the next run
of these cases on the CI runner says where they stop.
@Fectivnfy112357

Copy link
Copy Markdown
Author

A correction to what this pull request said about the gate, and what the CI says.

The body of this PR said "The WebUI gates pass". That was true of the local gate at the time and it is still true of it, but it did not say where, and the CI disagrees at one step. The body now says both: the §7 gates are green at this tip, and in CI test:webui and test:webui-package pass while test:webui-browser fails on six cases of the previous programme's external-bridge spec.

Those six are inherited, not introduced here. They failed the same way on a18c916b — the browser-use programme's own head commit, merged as PR #195 and an ancestor of this branch's base:

$ gh run view 39538891164 -R mcode-webui/minimax-code --log-failed \
  | grep -oE "✘ +[0-9]+ packages/webui/test/browser/[^ ]+|[0-9]+ failed|Verification failed at: .*"
✘   20 packages/webui/test/browser/browser-bridge-real-external.spec.ts:82:5
…
6 failed
Verification failed at: test:webui-browser

and this branch changes neither the extension, nor the bridge server, nor that spec. The full correction, with the commands, is on the board's specification issue (#199) and in .plan/workspace-browser-tab/complete/gate-record.md.

The harness's waits on the extension had no deadlines, so a case that hung there reported only "the test timed out" — with nothing about which step stopped. They are bounded now, and each bound names its step (9d945e54). That does not make a failing case pass and does not skip one; it makes the next run say where these cases stop.

… server

The six external-bridge cases fail on the CI runner with the test's own
120-second timeout and no other information. With the waits bounded, the
failure names itself: the case's work is finished, and the harness's http
server does not close.

The browser is still running while the harness closes its server, so the
browser may still be holding a connection -- the extension's socket, or the
keep-alive connection the test page was fetched over -- and a server that
waits for a client to go away first cannot close while that client is still
running. The extension makes it worse: it reconnects to a host that is going
away.

So the browser stops first, and the harness cuts the connections it still
holds instead of waiting for them.

This is test-side only, and it changes no assertion: the case's work is
unchanged, and a cleanup that cannot finish now says which step stopped.
… stop

The bounded waits named the step: the cases' work is finished, and the
harness's http server does not close. The browser is still running while the
harness closes its server, so it may still be holding a connection -- the
extension's socket, or the keep-alive connection the test page was fetched
over -- and a server that waits for a client to go away first cannot close
while that client is still running.

The record now carries that, the two test-side commits that answer it, and
the CI's verdicts as they stand: macOS passes (it had been cancelled with the
matrix at the previous tip, which is not a verdict), and the ubuntu job is
red on the six cases that were already red on the base branch's own head.

The whole of §7 was run again at the tip that carries this record, after the
two commits that follow the plan's implementation: types, 208 files and 3230
unit tests, dependency direction, build, build boundary, the source projection
(5407 paths) and 146 browser cases in 2.0m. The acceptance records the suites
write are updated with that run.
The cleanup fix turned the six external-bridge cases green on the runner
that had been failing them: the CI run at 54db73a has `verify
(ubuntu-latest)` passing -- test:webui 208 files, test:webui-browser 146
passed in 2.8m, the source projection 5407 files, the dependency and
build-boundary checks, the installed package -- and the windows job with it.

That is the first green ubuntu run since the browser-use programme landed
those cases, and it makes the fork's CI red for a reason this slice owns
rather than one it inherited.

The macOS job of that run was cancelled while it was still queued, so it
carries no verdict; the run at 9d945e5 passed macOS in 14m51s. The record
says both, and says which of them is a verdict and which is the runner
queue.
…t is

The document that carries round 11 still ended on the sentence "since round 11
it is not even reachable: the Agent cannot acquire a selection, the isolated
provider drives one page, and the image stream is per run rather than per
target". Those three things are T1, T2 and T3 -- the findings this
programme closed -- so the sentence describes the tree as it was before the
tab model, in the present tense.

It now says what is true: the path is reachable, the tab model's own run
exercises it end to end against a real Chrome, and what that run does not
establish is the same path on the external bridge's surfaces.

The gate record says the CI's verdicts as they stand: the run at 3d3b784 has
all three platforms passing and the workflow's own "Require every planned
check" gate passing with them.
@Fectivnfy112357

Copy link
Copy Markdown
Author

The CI is green, and the six external-bridge cases that were red since the previous programme are fixed.

The CI run at 3d3b7846 has verify (ubuntu-latest), verify (windows-latest) and verify (macos-latest) all passing, and the workflow's own "Require every planned check" gate passing with them.

They were diagnosed. The cases hung with no diagnosis, because the test harness had waits with no deadline of their own — a service worker Chrome has reclaimed does not answer, and a message nobody replies to does not settle. Every such wait is now bounded, and each bound names the step it was waiting for (9d945e54). That is not a way of making a failing case pass: a bound that expires fails the case with a diagnosis, and nothing is skipped. It is a way of making the failure say where it stopped.

It said where it stopped:

Error: the bridge's http server did not close did not finish within 30000ms

The cases' work was finished — the browser started, the extension paired, the handshake completed, the DevTools command ran against the live tab. What hung was the cleanup: the browser was still running while the harness closed its server, so it may still have been holding a connection, and the extension reconnects to a host that is going away. A server that waits for a client to go away first cannot close while that client is still running.

It is fixed (a4f8f7c6): the browser stops before the server, and the harness cuts the connections it still holds instead of waiting for them. Test-side only, and no assertion changed.

The whole of §7 was run again locally at the tip that carries the record: types, 208 files and 3230 unit tests, dependency direction, build, build boundary, the source projection (5407 paths) and 146 browser cases in 2.0m.

The record is .plan/workspace-browser-tab/complete/gate-record.md.

The CI run at 4a0a5d9 failed the ubuntu job on a suite file whose every
test passed:

    FAIL packages/webui/test/integration/browser-use-real-input.test.ts
    Error: ENOTEMPTY: directory not empty, rmdir '/tmp/webui-browser-use-e2e-*/browser'

The browser is killed and its profile directory is removed straight away,
and the browser may still be writing its last files. The provider's own
removal already retries for exactly this reason (`maxRetries: 5`,
`retryDelay: 100`); the tests that remove a browser's profile did not, so
the race surfaced there instead.

Every removal of a browser's profile directory now retries the same way. A
profile is a directory the browser owned, and the browser is gone: a removal
that races with its last writes is retried rather than reported as a
failure.
…ner flake

The run at 4a0a5d9 failed the ubuntu job on a suite file whose every test
passed: the browser is killed and its profile directory is removed straight
away, and the browser may still be writing its last files. The provider's own
removal has retried for that reason since 782c137; the tests that remove a
browser's profile did not, so the race surfaced there instead. Every such
removal retries now (9ac6762).

The same run failed the windows job on the runner's PowerShell timing out
while it checked the checkout volume -- a repository-level gate this slice
does not touch, which the runs on either side of it passed.

The CI run at 9ac6762 has all three platforms passing, and the workflow's own
"Require every planned check" gate passing with them.
@Fectivnfy112357

Copy link
Copy Markdown
Author

The final state of the CI, stated precisely.

Run Head Result
3d3b7846 the code, before the last two record commits all three platforms pass, and the workflow's own "Require every planned check" passes with them
9ac67622 the same code, after the ENOTEMPTY fix all three platforms pass, and the workflow's own "Require every planned check" passes with them
b495a4dc this pull request's head verify (windows-latest) pass, verify (ubuntu-latest) pass, and verify (macos-latest) cancelled while it was still queued — it never started, twice, and a re-run is queued behind the same runner queue

The head differs from the two green runs by three generated record files only — the acceptance records the suites write and the gate record itself:

$ git diff --name-only 9ac67622..b495a4dc
.plan/browser-use/complete/isolated-chromium.acceptance.json
.plan/workspace-browser-tab/complete/gate-record.md
.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json

No source file, no test and no runtime behaviour differs from the commit whose macOS job passed. The macOS job at this head is waiting for a macOS runner, and it has been cancelled twice without ever starting; that is the runner queue, not a verdict, and the record says so in the same words.

@Fectivnfy112357

Copy link
Copy Markdown
Author

The CI is green at this pull request's head.

The Source verification run at b495a4dc — the head this pull request carries — now has every job passing:

Job Result
changes pass
verify (windows-latest, 24) pass
verify (macos-latest, 24) pass (it took five attempts to get a macOS runner; four were cancelled while still queued, and the fifth ran)
verify (ubuntu-latest, 24) pass — test:webui 208 files, test:webui-browser 146 passed, the source projection 5407 paths, the dependency and build-boundary checks, the installed package, the capability suite
verification — "Require every planned check" pass

Release audit and Performance pass at the same head. This pull request's checks are clean, and it is mergeable.

The six external-bridge cases that had been red on that runner since the browser-use programme introduced them are green now: the harness names the step a failure stops at (9d945e54), and the cleanup stops the browser before the server it was holding open (a4f8f7c6).

@Fectivnfy112357
Fectivnfy112357 merged commit 37e3078 into webui Oct 22, 2026
24 of 32 checks passed
@modacker
modacker deleted the feat/workspace-browser-tab branch October 23, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant