diff --git a/.plan/browser-use/complete/isolated-chromium.acceptance.json b/.plan/browser-use/complete/isolated-chromium.acceptance.json index ddccf1029..9c945f1cf 100644 --- a/.plan/browser-use/complete/isolated-chromium.acceptance.json +++ b/.plan/browser-use/complete/isolated-chromium.acceptance.json @@ -16,7 +16,7 @@ }, "observed": "a pointer click and a text input both arrived in the page the provider was driving", "result": "pass", - "at": "2026-10-21T11:54:23.014Z", + "at": "2026-10-21T20:11:49.442Z", "os": "linux 7.0.0-38-generic", "suite": "isolated-chromium" }, @@ -37,7 +37,7 @@ }, "observed": "the stale identity was refused with browser_target_stale and the page that replaced it received nothing", "result": "pass", - "at": "2026-10-21T11:54:24.551Z", + "at": "2026-10-21T20:11:50.962Z", "os": "linux 7.0.0-38-generic", "suite": "isolated-chromium" } diff --git a/.plan/browser-use/complete/real-browser.acceptance.json b/.plan/browser-use/complete/real-browser.acceptance.json index c28dc7c83..9a8c68857 100644 --- a/.plan/browser-use/complete/real-browser.acceptance.json +++ b/.plan/browser-use/complete/real-browser.acceptance.json @@ -13,12 +13,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the handshake completed and the host reported the surface connected", "result": "pass", - "at": "2026-10-21T11:45:49.589Z", + "at": "2026-10-21T19:27:12.077Z", "os": "linux 7.0.0-38-generic", "suite": "real-browser" }, @@ -36,12 +36,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": false }, "observed": "the host refused the extension and reported no connected surface", "result": "pass", - "at": "2026-10-21T11:45:49.934Z", + "at": "2026-10-21T19:27:12.388Z", "os": "linux 7.0.0-38-generic", "suite": "real-browser" }, @@ -59,12 +59,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the handshake completed and the host reported the surface connected", "result": "pass", - "at": "2026-10-21T11:45:50.315Z", + "at": "2026-10-21T19:27:12.712Z", "os": "linux 7.0.0-38-generic", "suite": "real-browser" }, @@ -82,12 +82,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": false }, "observed": "the host refused the extension and reported no connected surface", "result": "pass", - "at": "2026-10-21T11:45:50.734Z", + "at": "2026-10-21T19:27:13.062Z", "os": "linux 7.0.0-38-generic", "suite": "real-browser" }, @@ -105,12 +105,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development, on both surfaces", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "both browsers were connected, each on its own surface, and each surface's provider belonged to that surface", "result": "pass", - "at": "2026-10-21T11:45:51.751Z", + "at": "2026-10-21T19:27:13.921Z", "os": "linux 7.0.0-38-generic", "suite": "real-browser" } diff --git a/.plan/browser-use/complete/real-external.acceptance.json b/.plan/browser-use/complete/real-external.acceptance.json index ac9570116..1a0504d52 100644 --- a/.plan/browser-use/complete/real-external.acceptance.json +++ b/.plan/browser-use/complete/real-external.acceptance.json @@ -13,12 +13,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the host noticed the terminated worker, the surface went down and came back, and a command against the live tab answered again", "result": "pass", - "at": "2026-10-21T11:45:53.073Z", + "at": "2026-10-21T19:27:15.162Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" }, @@ -36,12 +36,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the text the host sent appeared in the page's field, and a non-empty image frame for that page arrived at the host", "result": "pass", - "at": "2026-10-21T11:45:53.694Z", + "at": "2026-10-21T19:27:15.714Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" }, @@ -59,12 +59,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the command in the allowed subset answered with the page's title, and a command outside it was refused as unsupported", "result": "pass", - "at": "2026-10-21T11:45:54.021Z", + "at": "2026-10-21T19:27:16.023Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" }, @@ -82,12 +82,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the host noticed the terminated worker, the surface went down and came back, and a command against the live tab answered again", "result": "pass", - "at": "2026-10-21T11:45:55.053Z", + "at": "2026-10-21T19:27:16.993Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" }, @@ -105,12 +105,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the text the host sent appeared in the page's field, and a non-empty image frame for that page arrived at the host", "result": "pass", - "at": "2026-10-21T11:45:55.726Z", + "at": "2026-10-21T19:27:18.015Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" }, @@ -128,12 +128,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "development", - "extensionId": "dejpmdkbdhkjjhkjkodimegojblnglde", + "extensionId": "njihcobnpalklclcjdhfnlcdonhhiddm", "installationKeyProved": true }, "observed": "the command in the allowed subset answered with the page's title, and a command outside it was refused as unsupported", "result": "pass", - "at": "2026-10-21T11:45:56.175Z", + "at": "2026-10-21T19:27:18.417Z", "os": "linux 7.0.0-38-generic", "suite": "real-external" } diff --git a/.plan/browser-use/complete/real-packaged.acceptance.json b/.plan/browser-use/complete/real-packaged.acceptance.json index 360e07454..290107c8d 100644 --- a/.plan/browser-use/complete/real-packaged.acceptance.json +++ b/.plan/browser-use/complete/real-packaged.acceptance.json @@ -9,12 +9,12 @@ "surface": "chrome", "build": "store", "allowlist": "store", - "extensionId": "ejnneljbcbiapcamdfmgfkaappehipac", + "extensionId": "klfboeijbpnckbjcbjanlheebbhgmpbl", "installationKeyProved": true }, - "observed": "the browser assigned the extension the id the public key determines (ejnneljbcbiapcamdfmgfkaappehipac)", + "observed": "the browser assigned the extension the id the public key determines (klfboeijbpnckbjcbjanlheebbhgmpbl)", "result": "pass", - "at": "2026-10-21T11:45:57.026Z", + "at": "2026-10-21T19:27:19.196Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -28,12 +28,12 @@ "surface": "chrome", "build": "store", "allowlist": "store", - "extensionId": "ejnneljbcbiapcamdfmgfkaappehipac", + "extensionId": "klfboeijbpnckbjcbjanlheebbhgmpbl", "installationKeyProved": true }, "observed": "the packaged manifest carries the store's public key and differs from the development manifest in nothing else", "result": "pass", - "at": "2026-10-21T11:45:57.028Z", + "at": "2026-10-21T19:27:19.197Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -51,12 +51,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "store", "build": "store", - "extensionId": "ejnneljbcbiapcamdfmgfkaappehipac", + "extensionId": "klfboeijbpnckbjcbjanlheebbhgmpbl", "installationKeyProved": true }, "observed": "the packaged host completed the handshake and reported the surface connected", "result": "pass", - "at": "2026-10-21T11:45:57.107Z", + "at": "2026-10-21T19:27:19.283Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -74,12 +74,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "store", - "extensionId": "ejnneljbcbiapcamdfmgfkaappehipac", + "extensionId": "klfboeijbpnckbjcbjanlheebbhgmpbl", "installationKeyProved": true }, "observed": "the packaged host refused the extension and reported no connected surface", "result": "pass", - "at": "2026-10-21T11:45:57.153Z", + "at": "2026-10-21T19:27:19.330Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -93,12 +93,12 @@ "surface": "edge", "build": "store", "allowlist": "store", - "extensionId": "gaflfamaoomjenkedomchkimnkabfkmo", + "extensionId": "edppplbagledhnfaipdcjbfefdbaoddg", "installationKeyProved": true }, - "observed": "the browser assigned the extension the id the public key determines (gaflfamaoomjenkedomchkimnkabfkmo)", + "observed": "the browser assigned the extension the id the public key determines (edppplbagledhnfaipdcjbfefdbaoddg)", "result": "pass", - "at": "2026-10-21T11:45:57.930Z", + "at": "2026-10-21T19:27:20.035Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -112,12 +112,12 @@ "surface": "edge", "build": "store", "allowlist": "store", - "extensionId": "gaflfamaoomjenkedomchkimnkabfkmo", + "extensionId": "edppplbagledhnfaipdcjbfefdbaoddg", "installationKeyProved": true }, "observed": "the packaged manifest carries the store's public key and differs from the development manifest in nothing else", "result": "pass", - "at": "2026-10-21T11:45:57.931Z", + "at": "2026-10-21T19:27:20.036Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -135,12 +135,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "store", "build": "store", - "extensionId": "gaflfamaoomjenkedomchkimnkabfkmo", + "extensionId": "edppplbagledhnfaipdcjbfefdbaoddg", "installationKeyProved": true }, "observed": "the packaged host completed the handshake and reported the surface connected", "result": "pass", - "at": "2026-10-21T11:45:58.024Z", + "at": "2026-10-21T19:27:20.106Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" }, @@ -158,12 +158,12 @@ "fullCdp": "enforced in the runtime service, not in the bridge", "allowlist": "development", "build": "store", - "extensionId": "gaflfamaoomjenkedomchkimnkabfkmo", + "extensionId": "edppplbagledhnfaipdcjbfefdbaoddg", "installationKeyProved": true }, "observed": "the packaged host refused the extension and reported no connected surface", "result": "pass", - "at": "2026-10-21T11:45:58.097Z", + "at": "2026-10-21T19:27:20.158Z", "os": "linux 7.0.0-38-generic", "suite": "real-packaged" } diff --git a/.plan/workspace-browser-tab/complete/README.md b/.plan/workspace-browser-tab/complete/README.md index 76c9da431..80150953c 100644 --- a/.plan/workspace-browser-tab/complete/README.md +++ b/.plan/workspace-browser-tab/complete/README.md @@ -3,8 +3,8 @@ This directory is where the workspace browser tab model's **completion evidence** will live: the records the suites write while they run, and the completion note. -The plan is [`../workspace-browser-tab-plan.md`](../workspace-browser-tab-plan.md). There is no -execution board for this programme yet, and none of the work has started. +The plan is [`../workspace-browser-tab-plan.md`](../workspace-browser-tab-plan.md). The execution board is +[`../board/`](../board/), and the work has started. ## What belongs here @@ -12,6 +12,7 @@ execution board for this programme yet, and none of the work has started. | --- | --- | --- | | `.acceptance.json` | the Playwright suites, through `packages/webui/test/browser/acceptance-record.ts` | one record per case: what was set up, what was observed, and what the person saw | | `*.evidence.json` | a probe run by hand | what a real isolated Chromium did, on the machine named in the record | +| `two-page-spike.json` | the issue #200 spike, run by hand against a real isolated Chromium | what the browser can do with two pages at once, and what the repository's provider does today — the matrix in `packages/webui/docs/browser-use/01-capability-matrix.md` is written from it | The published acceptance record for this work is `packages/webui/docs/browser-use/09-acceptance-record.md`, and the decision record is diff --git a/.plan/workspace-browser-tab/complete/codex-review.md b/.plan/workspace-browser-tab/complete/codex-review.md new file mode 100644 index 000000000..d52ba181b --- /dev/null +++ b/.plan/workspace-browser-tab/complete/codex-review.md @@ -0,0 +1,135 @@ +# The external review of the workspace browser tab model + +An independent review of this programme, run with the local OpenAI Codex through +**paseo** (`codex/gpt-6-luna`, `thinkingOptionId: high`), against the branch +`feat/workspace-browser-tab` and its base `origin/webui`. + +The reviewer was asked for a read-only review: findings, not patches. It found +five, and the WebUI gate found a sixth. All six are fixed; the commit that +fixes them is `456f814c`, and the acceptance record and the capability matrix +were corrected with them. + +## The invocation + +The review is not only attested by the commit that fixed its findings. It was run like this: + +| | | +| --- | --- | +| Started through | **paseo** (`mcp__paseo__create_agent`) | +| Agent | `40b1e2eb-b15d-4eb9-9bde-9f3137523157` | +| Workspace | `wks_ed83bdde5de1ebe1` | +| Provider and model | `codex` / `gpt-6-luna` | +| Thinking option | `high` | +| Mode | `auto` — Codex's default permissions | +| Codex session | `01a15f26-28d2-7271-9feb-20a0c79a0746` | +| Codex CLI | 0.162.0 | +| Directory | the branch's own worktree, `/home/fectivnfy/my_data/project/my_project/minimax-code-workspace-browser-tab` | +| Started / finished | 2026-10-21T15:48:18Z / 2026-10-21T15:57:47Z | +| Tokens | 176654 in (171904 cached), 3847 out | + +The session's own transcript is on this machine at +`~/.codex/sessions/2026/10/21/rollout-2026-10-21T23-48-18-01a15f26-28d2-7271-9feb-20a0c79a0746.jsonl`. +It is not part of the published source — it is a session log — but it is the +record of what was asked and what came back. The prompt was this, verbatim: + +```text +You are reviewing a completed feature programme in the MiniMax Code monorepo. **This is a read-only review: report findings, do not edit any file, do not commit anything, do not run the full test gate.** Read the code and reason about it; running a single focused test file is fine if it helps you confirm a finding. + +## What to review + +The branch `feat/workspace-browser-tab` in this repository, against its base `origin/webui`. Get the diff with: + + git log --oneline origin/webui..feat/workspace-browser-tab + git diff origin/webui...feat/workspace-browser-tab + +The programme is "the workspace panel is a browser tab strip" (ADR 0022). It turns the WebUI's right-hand workspace panel into a browser: a strip of tabs, where a tab is a stable slot whose content can be replaced in place, and a web tab holds a page of the isolated Chromium. + +The plan and the issue board are in the repository and are the contract: + +- `.plan/workspace-browser-tab/workspace-browser-tab-plan.md` — the plan +- `.plan/workspace-browser-tab/board/spec.md` — the specification +- `.plan/workspace-browser-tab/board/t01.md` … `t13.md` — one ticket per issue, each with its acceptance criteria +- `.plan/workspace-browser-tab/board/HANDOFF.md` — the state of the work +- `packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md` — the decision record +- `packages/webui/docs/browser-use/01-capability-matrix.md` — the capability matrix, written from measurements + +Thirteen issues were implemented, one commit each, in the order the board's dependency graph allows. Read the diff and judge the **result**, not the process. + +## What I want you to look for + +1. **Correctness bugs.** Especially: the generation fence (a page's generation advances when the page behind it is replaced — a reference recorded before that must be refused, on *every* page); the routing of a screencast frame to the page it belongs to (by the envelope's `event.sessionId`, never by `event.params.sessionId`); races between a page's first load and the answer that opened it; anything that can leave a page, a target or a selection behind. +2. **The two cells.** The **run's selection** (the runtime's cell — the page the Agent acts on) and **the tab the person is looking at** (the client's cell) must stay separate, with one writer each, and exactly one documented bootstrap (a person who opens a web tab while the run has *no* selection gives the run that page; a person who opens one while the run *has* a selection must not move it). Look for any path where one cell writes the other. +3. **Layering and ownership.** The workspace business and the browser business must not reach into each other's private modules; the runtime owns the run's selection and the client owns the person's tab. `node scripts/check-webui-dependency.mjs` passes, but a rule the gate cannot see is still a rule. +4. **Tests that do not prove what they claim.** A test that would pass with the behaviour broken, a mock where a real browser is required, an assertion that is weaker than the acceptance criterion it stands for. +5. **The documentation's honesty.** A rule described as `implemented` without the code behind it; a claim of a measurement the record does not support; a stale statement left behind by the change. +6. **Anything the change broke that the programme did not notice** — a capability removed by accident, an error code that changed meaning, a wire shape that other callers still use. + +## What I want back + +A findings report, in this shape: + +- **Findings** — each with a severity (BLOCKER / MAJOR / MINOR), the `file:line`, what is wrong, and **what the consequence is** (a wrong picture on screen, an action aimed at the wrong page, a page left running, a claim that is not true). A finding with no consequence is a preference, not a finding — put it under "Observations" instead. +- **Observations** — things worth knowing that are not defects. +- **What is sound** — the parts you checked and believe are right, and why. I need to know what you actually verified, not what you skimmed. + +Rank the findings by severity and be specific. If you find nothing in a category, say so explicitly rather than inventing something. If you are not sure whether something is a defect, say what would settle it. + +Do not edit files. Report only. +``` + +The reviewer answered with five findings, in the shape the prompt asked for, and its report is quoted +in the findings table below. + +## What the reviewer was asked for + +1. **Correctness bugs** — the generation fence on every page; the routing of a + screencast frame to the page it belongs to; races between a page's first + load and the answer that opened it; anything left behind. +2. **The two cells** — the run's selection and the tab the person is looking + at, with one writer each and exactly one bootstrap. +3. **Layering and ownership** — the workspace and the browser business must not + reach into each other's private modules. +4. **Tests that do not prove what they claim** — a test that would pass with + the behaviour broken; a mock where a real browser is required. +5. **The documentation's honesty** — a rule described as `implemented` without + the code behind it; a claim a record does not support; a stale statement. +6. **Anything the change broke that the programme did not notice.** + +## What the reviewer confirmed + +- The provider's per-page generation and frame routing: pages by target and by + CDP session, `Page.enable` per page, the generation raised by a main-frame + navigation, and events resolved from the envelope's `event.sessionId`. The + numeric `event.params.sessionId` is used only for the screencast + acknowledgement. No defect found in those paths. +- The two selection cells are substantially separated: runtime commands resolve + the run's selection, the client's viewed-page state supplies the target for + its frame stream and its input, and the panel's `page-viewed` effect does not + write the run's selection. +- The stable tab model and the cache adjustments: client-assigned ids and + mutable contents, the same-file rule as a content lookup, the workspace + review and the turn review as distinct content sources, and the file and + review reads depending on the content as well as the tab's identity. +- The cross-business edges use published interfaces: the workspace strip calls + the browser port and shares the browser's DTOs, and the browser host mounts + the published workspace strip. No private-module import between the two + businesses in the paths reviewed. + +## The findings, and what closed each + +| # | Severity | Finding | Closed by | +| --- | --- | --- | --- | +| 1 | MAJOR | **Closing a web tab left its browser page running.** The close button dispatched the client command and nothing else: the runtime kept publishing the tab, the isolated Chromium kept the page alive, and the next snapshot could make the supposedly closed tab reappear. The acceptance test called the service method directly, so it did not cover the person's close action. | 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 acceptance case now closes through the server's own handler. | +| 2 | MAJOR | **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 embedded panel is the isolated browser's view. | The person's page is always opened in the isolated Chromium, and the run keeps the page it was working on. | +| 3 | MINOR | **The bootstrap could race an Agent selection.** The run's selection was read after the page was created, so a run that selected a page while the person's page was opening lost its selection to the bootstrap afterwards. | The selection is read **before** the page is created, and read across every surface: the Agent acts on one page, so the bootstrap is about the run and not about a surface. | +| 4 | MINOR | **The acceptance record overstated what it measured about the client strip.** The case fabricated the runtime's snapshot and called the pure reconcile function and the reducer. That exercises the reducer, not the production subscription or the mounted strip. | The case reads the runtime's own published stream, 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. | +| 5 | MINOR | **The authorization trace's source pointers were stale.** They named `#frameMatchesSelection` at `browser-service.ts:733`, `sendInput` at `:289` and the target-change listener at `:176`; none of those identified the code that is there now. | The pointers name the fence that is there now. | +| 6 | MINOR | **The file results never appeared** (found by the WebUI gate, not by the reviewer). The file cache was **read** by the tab id while the read was **filed** by the content key, so the preview never found its result. The effect that reads a file also depended on the content *object*, so a content replaced by an equal one re-read the file and superseded the read already in flight. | The render reads the key the read writes, and the effect follows the content key rather than the content object. | + +## What the reviewer did not check + +- The whole-programme gate. The reviewer ran focused suites only, and the gate + is the programme owner's. +- The macOS `verify` job, which is a CI concern. +- The `test:webui-browser` Playwright suite in CI; the local run against real + Chrome 152 is what the 14-case acceptance record was written from. diff --git a/.plan/workspace-browser-tab/complete/gate-record.md b/.plan/workspace-browser-tab/complete/gate-record.md new file mode 100644 index 000000000..527edf6a6 --- /dev/null +++ b/.plan/workspace-browser-tab/complete/gate-record.md @@ -0,0 +1,295 @@ +# The gate, and what it was run against + +The plan's §7 names six gates, and §9 makes them the completion definition: "The WebUI gates in §7 +pass". This file is that record — what was run, on what, and what it said — so the claim can be +checked without running it again. + +**The run.** Every gate below was run on the tree that carries this record, in one sequence, in this +order. The run was made at `4e7efd43`, the commit that carries the plan's implementation; the gate +was first run on that code at `4d247f43`. Two test-side commits came after it — `9d945e54` and +`a4f8f7c6`, which bound the external-bridge harness's waits and fix its cleanup — and **the whole +of §7 was run again at the tip that carries this record**, after them: types, 208 files and 3230 +unit tests, dependency direction, build, build boundary, the source projection (5407 paths), and +146 browser cases in 2.0m. No source file of the programme changed after `4e7efd43`. + +**The unit suite ran four more times** after the last of the fixes below, on the same tree: 208 files +and 3230 tests, every time, with no unhandled errors. The gate had been red twice before that — once +for a reason that is now understood, and once for a reason that was a defect in the service. + +**The environment.** Linux 7.0.0-38-generic, Node v26.8.1, Google Chrome 152.0.7977.82 +(`/usr/bin/google-chrome`), in the branch's own worktree. + +## The gates + +| Gate | Command | Result | +| --- | --- | --- | +| Types | `pnpm typecheck:webui` | **pass** — the server, the client and the test projects all type-check | +| Unit and gate tests | `pnpm test:webui` | **pass** — 208 files, 3230 tests | +| Dependency direction | `pnpm check:webui-dependency` | **pass** — 476 source files, 1734 in-package references, 0 violations, 0 cycles, 0 role violations, 0 publication violations | +| Build | `pnpm build:webui` | **pass** — 130 server inputs, 345 client inputs, 467 total | +| Build boundary | `pnpm check:webui-boundary` | **pass** — 467 inputs; source/output integrity, required entries and public workspace imports verified | +| Source projection | `node scripts/source-inventory.mjs --write` then `pnpm check:source` | **pass** — 5407 published paths | +| Real browser | `pnpm test:webui-browser` | **pass** — 146 Playwright tests, 2.1m, against real Chrome 152 | + +One gate beyond §7 was run as well, because it is the closest thing the repository has to +"the thing a person installs works": + +| Gate | Command | Result | +| --- | --- | --- | +| Installed package | `pnpm test:webui-package` | **pass** — `@fectivnfy112358/minimax-code-web@0.1.0-preview.3`; archive installation, real runtime startup, HTTP assets, the packaged bridge's admission and shutdown | + +The repository's own full gate is deliberately **not** run: §7 says the repository-level gates cover +ground this change does not move, and the macOS `verify` job is CI's to run. + +## The cadence, honestly + +§7 says the gates run **once**, at the end of the whole slice, because running them per work +package would spend the time on gates that cannot pass until the slice is finished. The rule was +honoured where it matters — **the gate was never run per work package**; each issue ran only the +suites that issue touched — but the gate as a whole ran **five times, not once**. Every run came +after a whole block of changes, and each of the first four found a real defect, so stopping after +the first would have meant shipping a gate that was red for a known reason. + +| Run | The tree it ran at | What it found | +| --- | --- | --- | +| 1 | at the end of the plan | two defects: the stylesheet referenced two variables that do not exist, and the file results were read by the tab id while the read was filed by the content key | +| 2 | after the external review's fixes (`456f814c`) | the port-slices test named the browser slice's members, which the new close operation had joined | +| 3 | `4d247f43` | an acceptance case failing intermittently — a run with no selection that opened a web tab was not given the page, because the service compared a selection it had read *before* the listing against a generation that had moved while the listing was in flight. The service now reads the selection per target, and the registry adopts a provider-reported generation that is ahead, because a page that is still loading is observed as a replaced page while it is being attached to. | +| 4 | the same tree, before `782c137b` | the suite's file failing with every test in it passing: the provider removed its profile directory right after killing the browser, and the browser was still writing it. The removal is now retried. | +| 5 | `4e7efd43` | nothing — everything green, as the table above says | + +Runs 3 and 4 found the *same* two defects from different angles: the first was a test failing, the +second was a file failing with nothing failing in it. + +**Everything else was a targeted re-run, not the gate.** The unit suite was re-run four more times +on the final tree — 208 files and 3230 tests every time, no unhandled errors — while the two +intermittent failures above were being pinned down, and single suites were re-run while their own +defect was being fixed. Those are development iterations on a failing suite; the table above is the +list of the gate runs, and this file is the list of what each one found. + +## What the repository's CI says, and what it does not + +The fork's CI runs the repository's whole `verify` profile, which is wider than §7. Its history on this +branch and on the base branch: + +| Tip | What failed | +| --- | --- | +| `83d8052a` (the first push) | `test:webui` on ubuntu — the suite's file failing with every test in it passing: the `ENOTEMPTY` cleanup race, fixed in `782c137b` | +| `5d154673` | `test:capabilities` on macos-latest, in `packages/local-runtime-v2/test/unit/agent/storage/agent.repository.test.ts` | +| `acbf1b40` | `verify (ubuntu-latest)` — the six external-bridge cases described below | +| `01cf5785` | the same six, and `verify (macos-latest)` was **cancelled** with the matrix | +| `9d945e54` | the same six — now with a named cause — and `verify (macos-latest)` **passed** (14m51s) | +| `54db73a3` | the ubuntu and windows jobs **pass** — the ubuntu job is green for the first time since the browser-use programme landed; the macOS job was cancelled while it was still queued, so it carries no verdict | +| `3d3b7846` | **nothing** — all three platforms pass, and the workflow's own "Require every planned check" gate passes with them | +| `4a0a5d90` | `test:webui` on ubuntu — a suite file failing with every test in it passing: the `ENOTEMPTY` removal race, this time in a test's own teardown. And `test:windows` on windows, where the runner's PowerShell timed out while it checked the checkout volume | +| `a63a6c99` — the base branch's own CI run, before this programme | `verify (macos-latest)` and `verify (ubuntu-latest)` | + +So the `test:capabilities` failure is **pre-existing on the base branch**, in a package this slice +does not touch: `packages/local-runtime-v2`. The plan's §7 says the repository-level gates cover +ground this change does not move, and that is exactly what the history shows. The webui gates — the +ones §7 names and §9 requires — are green at the tip that carries this record, and the run at +`83d8052a` that the CI caught was the cleanup race, which is fixed. + +The base branch's own run, `a63a6c99`, **cannot** say anything about the external-bridge spec: the +spec did not exist at that commit. It was introduced by the browser-use programme at `91a1a1c4`, and +every CI run of that programme from there on has failed the same six cases. + +### The CI's ubuntu job, at the tip + +The CI's `Source verification` run at the tip this record carries — run `39649558021`, head +`01cf5785`: + +| Job | Result | +| --- | --- | +| `changes` | **pass** | +| `verify (windows-latest, 24)` | **pass** (3m14s) | +| `verify (macos-latest, 24)` | **cancelled** — the matrix cancelled it when the ubuntu job failed, so it carries **no verdict**, not a pass | +| `verify (ubuntu-latest, 24)` | **failure** (18m45s), stopped at `test:webui-browser` | +| `verification` | failure — "Require every planned check" | + +Inside the ubuntu job: + +| Step | Result | +| --- | --- | +| `test:webui` | **pass** (107.5s) — the webui unit suite is green in CI | +| `test:webui-package` | **pass** (23.1s) | +| `test:webui-browser` | **FAIL** (890.4s) — 140 passed, **6 failed** | + +The next run of the branch — `39656341109`, head `9d945e54` — repeated the ubuntu failure and settled +the macOS question: `changes` **pass**, `verify (windows-latest)` **pass** (2m59s), +`verify (macos-latest)` **pass** (14m51s), `verify (ubuntu-latest)` **failure** (16m59s). The macOS job +had no verdict at `01cf5785` because the matrix cancelled it; it has one now. + +### The six failures, and what they are not + +The six are all in `packages/webui/test/browser/browser-bridge-real-external.spec.ts` — the +**previous** programme's external-bridge cases, which drive a real browser with the built extension +and then act on the **live tab**. At `01cf5785` each one ran to the test's own 120-second timeout +with no other error: + +| Case | Result | +| --- | --- | +| `browser-bridge-real-external.spec.ts:82:5` — the bridge survives the worker being terminated in `chromium` | timeout (2.0m) | +| `browser-bridge-real-external.spec.ts:162:5` — text input reaches the page through the chrome bridge in `chromium` | timeout (2.0m) | +| `browser-bridge-real-external.spec.ts:262:5` — a DevTools command runs against the live tab through the chrome bridge in `chromium` | timeout (2.0m) | +| `browser-bridge-real-external.spec.ts:82:5` — the bridge survives the worker being terminated in `msedge` | timeout (2.0m) | +| `browser-bridge-real-external.spec.ts:162:5` — text input reaches the page through the edge bridge in `msedge` | timeout (2.0m) | +| `browser-bridge-real-external.spec.ts:262:5` — a DevTools command runs against the live tab through the edge bridge in `msedge` | timeout (2.0m) | + +### Where they stop, and what was done about it + +A timeout with no other information says nothing about *where* the case stopped, because 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. Every such wait is now +bounded, and each bound names the step it was waiting for (`9d945e54`) — a bound that expires +fails the case with a diagnosis, and it neither makes a failing case pass nor skips one. + +With the bounds in place, the next CI run named the step: + +``` +Error: the bridge's http server did not close did not finish within 30000ms + at real-browser-bridge.ts:127 +``` + +So the cases' **work is finished** — the browser starts, the extension pairs, the handshake +completes, the DevTools command runs against the live tab — and what hangs is the **cleanup**. 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. +A server that waits for a client to go away first cannot close while that client is still running, +and the extension makes it worse by reconnecting to a host that is going away. + +Two changes, both test-side, neither of them an assertion: + +| Commit | Change | +| --- | --- | +| `9d945e54` | every wait in the harness that can outlive its case is bounded, and names its step | +| `a4f8f7c6` | the browser stops before the server, and the harness cuts the connections it still holds instead of waiting for them | + +The local browser suite is green with both (146 cases, 2.1m). + +**And the CI is green.** The run at `54db73a3` has `verify (ubuntu-latest)` **passed** — `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 capability suite — and +`verify (windows-latest)` passed with it. The six cases that had been red on every CI run since +the browser-use programme introduced them now pass on the runner that has been failing them. The +macOS job of that run was cancelled while it was still queued, so it carried no verdict; the next +run, at `3d3b7846`, has **all three platforms passing** and the workflow's own "Require every +planned check" gate passing with them — and so does the run at `9ac67622`, after the second +`ENOTEMPTY` fix below. The fork's CI has been red on this line since the browser-use programme +landed; it is green now, and it is green on the tree this record carries. + +### The second `ENOTEMPTY`, and a runner flake + +The run at `4a0a5d90` 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' +``` + +That is the same race as the one this record already carries, in a different place: 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 `782c137b`; the tests that +remove a browser's profile did not, so the race surfaced there instead. Every such removal retries +now (`9ac67622`) — a profile is a directory the browser owned, and the browser is gone, so a removal +that races with its last writes is retried rather than reported as a failure. + +The same run failed the **windows** job on `test/windows-contract.test.mjs` — "Windows could not +verify the checkout volume (spawnSync powershell.exe ETIMEDOUT)". That is the runner's PowerShell +timing out while it checked the volume, in a repository-level gate this slice does not touch; the +run before it passed the same test on the same code, and the run after it did too. + +**This corrects what an earlier version of this record said.** It said the runner "cannot drive a +real browser with the extension", and that the spec's "is the browser installed" guard was the +reason. Both were wrong. In the *same job*, the sibling spec +`packages/webui/test/browser/browser-bridge-real-browser.spec.ts` **passes in both channels**: the +extension completes the handshake in **785 ms** in `chromium` and in **22.6 s** in `msedge`. The +runner does launch a real browser with the built extension, and the extension does pair with the +host. What is true is narrower: on that runner, the external spec's operations against the live tab +never come back. + +**And it is inherited, not introduced here.** The same six cases failed the same way on `a18c916b` +— the browser-use programme's own head commit, merged as PR #195 — where the ubuntu job stopped at +the same gate with **138 passed, 6 failed**: + +```console +$ gh run view 39538891164 -R mcode-webui/minimax-code --log-failed \ + | grep -oE "✘ +[0-9]+ packages/webui/test/browser/[^ ]+|[0-9]+ failed|[0-9]+ passed \([0-9.]+m\)|Verification failed at: .*" +✘ 20 packages/webui/test/browser/browser-bridge-real-external.spec.ts:82:5 +✘ 21 packages/webui/test/browser/browser-bridge-real-external.spec.ts:162:5 +✘ 22 packages/webui/test/browser/browser-bridge-real-external.spec.ts:262:5 +✘ 23 packages/webui/test/browser/browser-bridge-real-external.spec.ts:82:5 +✘ 24 packages/webui/test/browser/browser-bridge-real-external.spec.ts:162:5 +✘ 25 packages/webui/test/browser/browser-bridge-real-external.spec.ts:262:5 +138 passed (15.8m) +6 failed +Verification failed at: test:webui-browser +``` + +`a18c916b` is an ancestor of this branch's base (`webui`), so the base branch carries that redness. +This slice does not touch the extension, the bridge server, or that spec: + +```console +$ git diff --name-only webui..HEAD -- packages/browser-extension packages/webui/src/server/browser-bridge + # (no output) +$ git diff --name-only webui..HEAD -- packages/webui/test/browser +packages/webui/test/browser/acceptance-record.ts +packages/webui/test/browser/architecture-acceptance.spec.mjs +packages/webui/test/browser/browser-bridge-real-external.spec.ts +packages/webui/test/browser/native-runtime.spec.mjs +packages/webui/test/browser/real-browser-bridge.ts +packages/webui/test/browser/review-consumers.spec.mjs +packages/webui/test/browser/terminal-cross-page.spec.mjs +``` + +Of the seven files under `packages/webui/test/browser/`, five are the acceptance-record writer and +four specs that never mention the bridge or the extension, and the other two are the external-bridge +case and the harness it shares with the sibling spec — the two files the cleanup fix above touches. +Neither the extension nor the bridge server changed at all. + +**They pass here**: the same six cases, on the same tree, in about five seconds, and the record they +write names the browsers they ran in — Chromium 153.0.8010.12 and Edge 155.0.4283.62, six cases, +all `pass` (`.plan/browser-use/complete/real-external.acceptance.json`). + +**What is left, exactly:** nothing in CI. All three platforms pass at `3d3b7846`, together with the +workflow's own "Require every planned check" gate. The ubuntu job had been red on the six +external-bridge cases since the browser-use programme landed them; the fix that turned them green +is in this branch, and the record says which two commits did it and what each one changed. + +## What the real-browser acceptance rests on + +The fourteen acceptance cases in +[`workspace-browser-tab.acceptance.json`](workspace-browser-tab.acceptance.json) were written by the +suite itself, against real Chrome 152, at the commit that carries this record. The record is the +programme's own evidence, and it is the file the published acceptance record is written from. + +## What was not observed + +- The macOS `verify` job at the tip: it was **cancelled** with the matrix when the ubuntu job + failed, so it had no verdict. It has one now: the run at `3d3b7846` passed all three platforms + and the workflow's own "Require every planned check" gate. +- The cross-surface bootstrap — a person opening a web tab while the run is selected on Edge — is + implemented run-wide, but it has no dedicated test. The two same-surface cases are covered by the + acceptance suite. The plan's own acceptance matrix (§6) is entirely on the `isolated` surface, so + this is a gap in the evidence rather than in the plan's requirements. +- Playwright's trace for the six external-bridge cases is written to the runner's temporary + directory and is not uploaded with the verification summary, so the trace itself has not been + read. The bounded waits name the step without it. + +## A commit whose subject names only part of it + +`0cc664a0` is titled `docs(webui): the plan records that the slice is delivered`, and it is also +where one line of `packages/webui/test/unit/webui-port-slices.test.ts` changed: `closeBrowserWebTab` +joined the expected members of the browser port slice. The commit's body says so. The subject was +not rewritten afterwards, because rewriting it would change the hash of every commit above it, and +those hashes are what the two records in this directory and the published comments on the board +issues refer to — the evidence chain would break in exchange for a better first line. The change +itself is one line, in a test that asserts the port's published members. + +## Where the external review's invocation is recorded + +The review was run through **paseo**, which started a Codex session against this branch. The +invocation — the provider, the model, the thinking option, the session, the workspace and the +prompt — is written down in [`codex-review.md`](codex-review.md), so that the review is not only +attested by the commit that fixed its findings. + diff --git a/.plan/workspace-browser-tab/complete/two-page-spike.json b/.plan/workspace-browser-tab/complete/two-page-spike.json new file mode 100644 index 000000000..a9493fbef --- /dev/null +++ b/.plan/workspace-browser-tab/complete/two-page-spike.json @@ -0,0 +1,344 @@ +{ + "probe": "workspace-browser-tab two-page spike", + "issue": 200, + "host": { + "platform": "linux", + "arch": "x64", + "node": "v26.8.1", + "chrome": { + "path": "/usr/bin/google-chrome", + "product": "Chrome/152.0.7977.82", + "revision": "@d04cdb24d67b081f6cf80200ffc5233f44b61109" + } + }, + "method": { + "what": "Drive, stream and input into two pages of one real isolated Chromium, and record what happens.", + "phases": { + "provider": "Launch the repository's own WebuiIsolatedChromiumProvider, open a tab through it, and try to drive, input into and stream that new page. This is what the provider does today.", + "substrate": "Launch a real Chrome in the provider's own launch shape, attach to two pages over the repository's own WebuiBrowserCdpConnection, and exercise them concurrently: Page.enable on both, Page.startScreencast on both, Input.insertText into both, Page.navigate on the second, and the per-page generation accounting." + }, + "pages": "Two real documents served by a local HTTP server on 127.0.0.1 — no network, no real user content.", + "whatItIsNot": "This is not a test. It asserts nothing; it records what it observed. The record is the evidence.", + "reproduce": "Run the spike from the repository's webui package with tsx, against /usr/bin/google-chrome. The spike script itself is work material and lives outside the published source; the record it writes is committed here." + }, + "startedAt": "2026-10-21T12:30:01.796Z", + "phases": { + "provider": { + "description": "The real WebuiIsolatedChromiumProvider class, today.", + "targetsBeforeOpenTab": [ + { + "id": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "14E37D8770E0DB344CB98AAD88A06C4C", + "generation": 1 + }, + "title": "about:blank", + "url": "about:blank", + "status": "open", + "selected": false + }, + { + "id": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "AFB89EBB03E3F0272D7C87098E21FC86", + "generation": 1 + }, + "title": "about:blank", + "url": "about:blank", + "status": "open", + "selected": true + } + ], + "openTabResult": { + "targetId": "AFEDCFF719D084495C93E65A16E9B932" + }, + "targetsAfterOpenTab": [ + { + "id": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "14E37D8770E0DB344CB98AAD88A06C4C", + "generation": 1 + }, + "title": "about:blank", + "url": "about:blank", + "status": "open", + "selected": false + }, + { + "id": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "AFB89EBB03E3F0272D7C87098E21FC86", + "generation": 1 + }, + "title": "about:blank", + "url": "about:blank", + "status": "open", + "selected": true + }, + { + "id": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "AFEDCFF719D084495C93E65A16E9B932", + "generation": 1 + }, + "title": "about:blank", + "url": "about:blank", + "status": "open", + "selected": false + } + ], + "pageCount": { + "before": 2, + "after": 3 + }, + "newPageIdentity": { + "surface": "isolated-chromium", + "browserId": "isolated-chromium-wadkjvl8", + "targetId": "AFEDCFF719D084495C93E65A16E9B932", + "generation": 1 + }, + "newPageIsListedByTheProvider": true, + "driveNewPageNavigate": { + "attempted": true, + "outcome": { + "ok": true, + "value": { + "frameId": "AFEDCFF719D084495C93E65A16E9B932", + "loaderId": "197A18319FAFF4ABD19D84BC8F4C3974", + "isDownload": false + } + } + }, + "driveNewPageInput": { + "attempted": true, + "outcome": { + "ok": true, + "value": null + } + }, + "streamNewPage": { + "attempted": true, + "outcome": { + "ok": true, + "value": null + } + }, + "generationOfNewPage": 1, + "newPageSelected": false, + "driveLaunchPage": { + "attempted": true, + "outcome": { + "ok": true, + "value": {} + } + }, + "launchPageGeneration": 1 + }, + "substrate": { + "description": "The provider's own CDP connection against one real Chromium, with two attached pages.", + "pages": [ + { + "label": "A", + "targetId": "523F643B0CC0383A2686FE4D40E887D2", + "sessionId": "6E4260243A3E33B0B99DE1AB8BA18A2E" + }, + { + "label": "B", + "targetId": "1E783F3D1623A1BFAA511D24FAAEED2D", + "sessionId": "60354765619FF6A7B540EC966A496330" + } + ], + "sessionsAreDistinct": true, + "targetIdsAreDistinct": true, + "pageEnableSentTo": [ + "6E4260243A3E33B0B99DE1AB8BA18A2E", + "60354765619FF6A7B540EC966A496330" + ], + "screencastStartedFor": [ + "6E4260243A3E33B0B99DE1AB8BA18A2E", + "60354765619FF6A7B540EC966A496330" + ], + "input": { + "pageAValue": "alpha", + "pageBValue": "bravo", + "independent": true + }, + "framesAfterBothPagesWereTypedInto": { + "count": 3, + "byEnvelopeSession": [ + { + "sessionId": "60354765619FF6A7B540EC966A496330", + "page": "B", + "count": 3 + } + ], + "sample": [ + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 10448, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + }, + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 10840, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + }, + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 11312, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + } + ] + }, + "generation": { + "before": { + "A": 1, + "B": 1 + }, + "after": { + "A": 1, + "B": 2 + }, + "navigationsObserved": [ + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "mainFrame": true, + "url": "http://127.0.0.1:44699/b2" + } + ] + }, + "fence": { + "staleReferenceOnNavigatedPage": { + "accepted": false, + "error": { + "code": "browser_target_stale", + "message": "The browser page was replaced; the input was not delivered." + } + }, + "currentReferenceOnUntouchedPage": { + "accepted": true + }, + "staleRefused": true, + "untouchedPageStillAccepts": true + }, + "afterNavigation": { + "driveA": { + "ok": true, + "value": { + "result": { + "type": "string", + "value": "Page A" + } + } + }, + "driveB": { + "ok": true, + "value": { + "result": { + "type": "string", + "value": "Page B2" + } + } + } + }, + "perTargetScreenshot": { + "aBytes": 10988, + "bBytes": 11312 + }, + "framesAtTheEnd": { + "count": 4, + "byEnvelopeSession": [ + { + "sessionId": "60354765619FF6A7B540EC966A496330", + "page": "B", + "count": 3 + }, + { + "sessionId": "6E4260243A3E33B0B99DE1AB8BA18A2E", + "page": "A", + "count": 1 + } + ], + "sample": [ + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 10448, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + }, + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 10840, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + }, + { + "envelopeSessionId": "60354765619FF6A7B540EC966A496330", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 11312, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + }, + { + "envelopeSessionId": "6E4260243A3E33B0B99DE1AB8BA18A2E", + "envelopeSessionIdType": "string", + "paramsSessionId": 1, + "paramsSessionIdType": "number", + "dataLength": 11364, + "dataPrefix": "/9j/4AAQSkZJRgABAQAAAQAB" + } + ] + }, + "frameRouting": { + "envelopeSessionIdResolvesToAPage": true, + "paramsSessionIdResolvesToAPage": false, + "paramsSessionIdValues": [ + "1" + ], + "envelopeSessionIdValues": [ + "60354765619FF6A7B540EC966A496330", + "6E4260243A3E33B0B99DE1AB8BA18A2E" + ], + "conclusion": "the envelope's event.sessionId is the target session and identifies the page; event.params.sessionId is a number that is not the target session and identifies nothing" + }, + "currentProviderFrameRule": { + "rule": "every Page.screencastFrame is stamped with the provider's own target id, without asking which page the frame came from", + "source": "packages/webui/src/runtime/browser/isolated-chromium/provider.ts — #onEvent, the Page.screencastFrame branch", + "modelledWith": "page A stands for the one page the provider owns", + "observedFrames": 4, + "framesFromAnotherPage": 3, + "misStampedByTheCurrentRule": 3, + "consequence": "with two pages streaming, the other page's image is delivered as this page's image" + }, + "frameIdentityIsSufficient": { + "distinctEnvelopeSessions": 2, + "pages": 2, + "pagesThatStreamed": [ + "A", + "B" + ], + "bothPagesStreamedAtTheSameTime": true + } + } + }, + "finishedAt": "2026-10-21T12:30:04.453Z" +} diff --git a/.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json b/.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json new file mode 100644 index 000000000..0a7852be7 --- /dev/null +++ b/.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json @@ -0,0 +1,268 @@ +[ + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "the Agent opens a tab", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the run's selection points at the page the Agent opened, the runtime publishes it, and the strip's reconciliation makes it the active panel tab", + "result": "pass", + "at": "2026-10-21T20:11:49.137Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "the Agent navigates after opening", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the page is at the requested url and the frame stream follows the page it opened", + "result": "pass", + "at": "2026-10-21T20:11:50.223Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "the Agent acts on a page it did not open", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the action runs on the run's selection, and a reference recorded before the page moved is refused", + "result": "pass", + "at": "2026-10-21T20:11:50.988Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a person opens a tab while the Agent works", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the Agent's page and the person's page are different, and each shows its own picture", + "result": "pass", + "at": "2026-10-21T20:11:51.078Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a person opens a tab while the run has no selection", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the run's selection becomes the person's page, so the Agent can act on it", + "result": "pass", + "at": "2026-10-21T20:11:51.868Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a person types into their own tab while the Agent works", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the input reaches the person's page, not the Agent's", + "result": "pass", + "at": "2026-10-21T20:11:52.749Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a person types a url in the launcher", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the tab becomes that page, and the url is shown read-only in the toolbar", + "result": "pass", + "at": "2026-10-21T20:11:52.795Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a person closes a web tab", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the page is released, and the runtime's page set follows the strip", + "result": "pass", + "at": "2026-10-21T20:11:53.593Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "back and forward", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the page moves through its own history, and the generation follows", + "result": "pass", + "at": "2026-10-21T20:11:55.926Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "the surface's switch is off", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "the Agent's open_tab and the person's launcher path are both refused with browser_surface_not_authorized", + "result": "pass", + "at": "2026-10-21T20:11:55.927Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a run with no selection acts", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "every action except open_tab is refused with browser_target_unknown", + "result": "pass", + "at": "2026-10-21T20:11:55.953Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a session ends", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "its pages are released and no page is left behind", + "result": "pass", + "at": "2026-10-21T20:11:56.740Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a second web tab in one session", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "both are addressable, and the strip shows both", + "result": "pass", + "at": "2026-10-21T20:11:57.558Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + }, + { + "surface": "isolated-chromium", + "browser": "google-chrome", + "browserVersion": "Google Chrome 152.0.7977.82", + "entry": "development", + "case": "a page navigates", + "settings": { + "isolatedChromium": true, + "edge": false, + "chrome": false, + "fullCdp": true, + "runId": "run-1" + }, + "observed": "its generation advances, and a reference recorded against the old generation is refused — on every tab, not only the first", + "result": "pass", + "at": "2026-10-21T20:11:58.365Z", + "os": "linux 7.0.0-38-generic", + "suite": "workspace-browser-tab" + } +] diff --git a/.plan/workspace-browser-tab/workspace-browser-tab-plan.md b/.plan/workspace-browser-tab/workspace-browser-tab-plan.md index 2f29ba712..b66fdcc0a 100644 --- a/.plan/workspace-browser-tab/workspace-browser-tab-plan.md +++ b/.plan/workspace-browser-tab/workspace-browser-tab-plan.md @@ -1,18 +1,21 @@ # WebUI Workspace Browser Tab Model — Development Plan -**Status:** Product decisions are recorded in ADR 0022 and in amendment A8 of ADR 0019; this plan -turns them into an ordered implementation programme. No implementation has started. +**Status:** **Delivered.** All thirteen issues of the board are implemented on +`feat/workspace-browser-tab`, one commit each, and the WebUI gates in §7 pass. The product +decisions are recorded in ADR 0022 and in amendment A8 of ADR 0019, and ADR 0022 now says it is +implemented. **Date:** 2026-10-21 +**Delivered:** 2026-10-22 **Decision authority:** [ADR 0022](../../packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) and [ADR 0019 A8](../../packages/webui/docs/adr/0019-webui-browser-use-surfaces-and-external-browser-bridge.md) **Reference material:** [`packages/webui/docs/browser-use/`](../../packages/webui/docs/browser-use/) — `05-target-identity.md` carries the rules as `required`, `07-review-findings.md` carries round 11, which is what this plan closes. -This plan is workspace material: `.plan` is listed in the repository `.gitignore` and is not part of -the published source projection. **An implementer working in a git worktree cannot see it.** What -they can see is ADR 0022, which carries the decision; anything an implementer needs beyond it has -to go into the ticket body and into the ADR, the way the previous programme did it. +This plan is workspace material, and it **is** tracked: the previous programme's plan was left out +of the repository, and an implementer working in a git worktree could not see it. This one is +tracked, so the ticket bodies and ADR 0022 are the decision and this is the programme's record of +it. ## 1. Outcome @@ -352,15 +355,16 @@ The slice is done when: ## 10. Execution board and handoff -**No board has been created for this programme.** The previous programme's board is -[`.plan/browser-use/board/`](../browser-use/board/) and belongs to the extension and bridge work; this slice is a different -body of work with a different boundary. Creating GitHub issues is an external action and needs its -own authorization. +**The board exists.** It is [`.plan/workspace-browser-tab/board/`](board/) — the specification, one +ticket per issue, and the handoff — and the same work is tracked as GitHub issues #199 (the +specification) and #200 to #212 (one per issue) on `mcode-webui/minimax-code`. The previous +programme's board is [`.plan/browser-use/board/`](../browser-use/board/) and belongs to the +extension and bridge work; this slice is a different body of work with a different boundary. -When a board is created, the same rules as the previous one apply: +The rules the previous programme's board followed apply here: -- The plan is **not** in git, so a ticket may not depend on it. Everything an implementer needs goes - into the ticket body and into ADR 0022. +- A ticket may not depend on this plan alone: everything an implementer needs goes into the ticket + body and into ADR 0022. The plan is tracked now, but the decision is still ADR 0022's. - If implementation evidence contradicts ADR 0022, amend the ADR first, then re-scope the ticket. - The first ticket is the technical validation one: the provider's per-target behaviour and the service's snapshot are what every later ticket is written against. diff --git a/CONTEXT.md b/CONTEXT.md index 39eebe2ec..b8cf0a4d6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -439,6 +439,13 @@ The row of tabs in the workspace panel. It follows a browser's rules: a new tab new tab page, a tab can be closed, and one tab is active at a time. _Avoid_: browser (the driven browser is a surface, not this), toolbar, menu bar +**Viewed page**: +The page the person is looking at. It is the client's cell, and it is not the run's selection: +the person may be on a different page from the one the Agent is working on. One writer each +— the person writes the viewed page, the Agent writes the run's selection — with one bootstrap, +where a person who opens a web tab while the run has no selection gives the run that page. +_Avoid_: selection (the run's selection is the runtime's), active target + **Tool tab**: A tab whose content is a workspace tool view — files, terminal, canvas or changes. It holds no browser page. diff --git a/packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md b/packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md index 8535d2267..c15df3b33 100644 --- a/packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md +++ b/packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md @@ -1,6 +1,6 @@ # The workspace panel is a browser tab strip -**Status:** Accepted. +**Status:** Accepted and implemented. **Date:** 2026-10-21. The right-hand workspace panel becomes a browser. Its strip holds **tabs**; a tab is a stable slot diff --git a/packages/webui/docs/architecture/design.md b/packages/webui/docs/architecture/design.md index ea06df673..50f336d0e 100644 --- a/packages/webui/docs/architecture/design.md +++ b/packages/webui/docs/architecture/design.md @@ -50,16 +50,17 @@ nothing from `packages/tui` (ADR 0003) and does not drive the CLI binary (ADR 00 | --- | --- | --- | | `src/client/` | Browser orchestration, business state, presentation, browser IO | Speaks to the harness, holds a Node resource | | `src/server/` | Network ingress: admission, envelope validation, dispatch, wire projection, socket lifecycle, static assets | Holds browser state, imports a runtime implementation | -| `src/runtime/` | Harness capability adaptation, and every Node-side process resource | Knows which tab a person is looking at, or which target a run has selected (`required` — see below) | +| `src/runtime/` | Harness capability adaptation, and every Node-side process resource | Decides which target a run has selected (`see below` — the runtime owns that cell, and the person cannot write it) | | `src/shared/` | Cross-process wire contracts and shared constants | Imports either execution side | That last cell names two things, and under [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) they are two cells with one writer each: the person writes the tab they are looking at through the strip, and the Agent writes -the target its run has selected. The runtime holds both cells and writes neither: the person and the -Agent are the writers, and the runtime is the place the cells live. That split is `required` — the tree -keeps the run's selection in `runtime/browser/browser-service.ts`, where the panel is its only -writer. +the target its run has selected. The run's selection is the runtime's cell and the Agent writes it +through the runtime; the tab in front is the client's and the person writes it. The runtime holds the +selection — `runtime/browser/browser-service.ts` — and the client only reads it, out of the mark it +publishes on the target list. The one write that crosses is the bootstrap, and it is the runtime's to +make: `openWebTab` is told who opened the page and applies it only when the run has no selection. `src/client/` compiles under `tsconfig.client.json`; `src/server/`, `src/runtime/` and `src/shared/` under `tsconfig.server.json`. `scripts/build-webui.mjs` builds separate client and server outputs, @@ -242,9 +243,9 @@ are the list. Owning a screen is not part of the definition: `terminal` owns the this client knows about, the commands that create, write to, resize and dispose one, and the watch registry, while the workspace surface renders them; `review` owns the turn-changeset and working-tree-snapshot families, which the transcript, workspace and settings surfaces read; `browser` -owns the per-target browser state — a page, its title and URL, and the run's selection — while -`workspace` owns the session's tab strip that renders it (`required`: both businesses exist, but the -selection is the runtime's, not the browser business's); and `turn` has no `ui/` at all, yet is a +owns the per-target browser state — a page, its title and URL, and the page the person is looking at — +while `workspace` owns the session's tab strip that renders it. The run's selection is the runtime's, +and the browser business reads it rather than owning it; and `turn` has no `ui/` at all, yet is a business, because its attach-to-settle lifecycle and generation-fenced identity outlive any surface. The process behind a terminal is the service's, not the business's (§11). Each business directory holds up to five roles (`contracts/`, `application/`, `projection/`, @@ -335,8 +336,9 @@ A change that breaks one of these is a design change, not a refactor: has one owner. 11. **One writer per browser cell, and the two cells are separate.** The run's selection is written by the Agent through the runtime; the tab the person is looking at is written by the person through - the strip; neither party writes the other's cell. There is one bootstrap: a person who opens a web - tab while the run has no selection gives the run that page, because a run with no selection cannot - act at all. `required` — the tree has one selection map, the panel writes it (`selectBrowserTarget`), - and the Agent cannot write it at all: its tools refuse a run that has selected nothing - (`runtime/browser/tool-adapter.ts`). See §8 for the businesses that own each side. + the strip; neither party writes the other's cell. The panel shows, streams and binds input to the + viewed page, and its target list reports the runtime's mark without offering to change it. There is + one bootstrap: a person who opens a web tab while the run has no selection gives the run that page, + because a run with no selection cannot act at all — and the runtime decides it, from the selection + it owns at the moment the page appears (`runtime/browser/browser-service.ts` — `openWebTab`). + See §8 for the businesses that own each side. diff --git a/packages/webui/docs/browser-use/01-capability-matrix.md b/packages/webui/docs/browser-use/01-capability-matrix.md index 0e4e69245..20930a038 100644 --- a/packages/webui/docs/browser-use/01-capability-matrix.md +++ b/packages/webui/docs/browser-use/01-capability-matrix.md @@ -5,7 +5,7 @@ is: | Label | Means | Backed by | | --- | --- | --- | -| `measured` | A probe observed this on the machine named in the section heading. | `.plan/browser-use/complete/cdp-evidence.json` or `.plan/browser-use/complete/credential-store-evidence.json` | +| `measured` | A probe observed this on the machine named in the section heading. | `.plan/browser-use/complete/cdp-evidence.json`, `.plan/browser-use/complete/credential-store-evidence.json` or `.plan/workspace-browser-tab/complete/two-page-spike.json` | | `implemented` | The source does this today. The row names the file. | The cited file in this repository | | `not yet measured` | An open question. Not a denial and not a promise. | Nothing — see the section that collects them | @@ -35,7 +35,7 @@ declaration rather than a probe result. This document is the measurement half. | Authorization switch | `isolatedChromium` | `edge` | `chrome` | | Live image stream | **measured** — `Page.captureScreenshot` and `Page.startScreencast` both succeeded against a real page | **measured** — the extension captured the visible tab and a non-empty image frame reached the host, in a real Edge | **measured** — the same, in a real Chrome | | Pointer / keyboard / text input | **measured** — mouse press + release, `Input.insertText`, and `Input.dispatchKeyEvent` all succeeded against a real page | **measured** — the text the host sent appeared in the page's field, read back through the browser's own automation, in a real Edge | **measured** — the same, in a real Chrome | -| Agent browser tools | **implemented** — the tool adapter forwards to the same browser service the panel uses, and the composition root hands it to the runtime host. Which *surface* a call acts on is resolved from the run's own selection, so a run that has selected nothing is refused rather than being sent somewhere the person is not looking — and the refusal reaches `open_tab` too, so the Agent cannot acquire a selection of its own. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) gives `open_tab` that path (`required`) | **implemented**, same adapter | **implemented**, same adapter | +| Agent browser tools | **implemented** — the tool adapter forwards to the same browser service the panel uses, and the composition root hands it to the runtime host. Which *surface* a call acts on is resolved from the run's own selection, so a run that has selected nothing is refused rather than being sent somewhere the person is not looking — except for `open_tab`, which is the one action that would create the selection the gate wants, and which therefore may. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) gives `open_tab` that path (`implemented` — `runtime/browser/tool-adapter.ts:178`) | **implemented**, same adapter | **implemented**, same adapter | | Full CDP widening | `implemented` — gated in `WebuiBrowserService.sendCommand` | `implemented`, same gate | `implemented`, same gate | | Restricted command set | `implemented` — `WEBUI_BROWSER_RESTRICTED_COMMANDS`, 28 names, `packages/webui/src/shared/contracts/browser.ts:310` | same vocabulary over the bridge | same vocabulary over the bridge | | Extension permission gate | not applicable — no extension is involved | The extension's own browser permission, independent of the WebUI switch | The extension's own browser permission, independent of the WebUI switch | @@ -216,8 +216,11 @@ switch first, so a disabled surface is refused with `browser_surface_not_authori `fullCdp`, then calls `isBrowserCommandAllowed(authorization, command, WEBUI_BROWSER_RESTRICTED_COMMANDS)` (`browser-service.ts:431`). A command outside the restricted set with `fullCdp` off is refused with `browser_capability_denied` and is never forwarded. The path is exposed as the `sendBrowserCommand` -operation. See [`06-runtime-authorization-trace.md`](06-runtime-authorization-trace.md) and finding F5 -in [`07-review-findings.md`](07-review-findings.md). +operation, and the same operation answers three callers: a run's tools (`sendCommandToSelection`), the +panel with no run (`sendCommand`), and a web tab's toolbar, which names the page its tab holds +(`sendCommandToTarget`). All three make the same switch, gate and generation fences — the toolbar is a +new caller, not a new gate. See [`06-runtime-authorization-trace.md`](06-runtime-authorization-trace.md) +and finding F5 in [`07-review-findings.md`](07-review-findings.md). **`not yet measured`** — no run sent a real DevTools command through that gate. This is verified by reading the source. @@ -265,11 +268,11 @@ a pairing credential. See [`04-pairing-credentials.md`](04-pairing-credentials.m Read this section before citing the matrix. Each line is a gap in the evidence, not a known limitation of the product. -- **The isolated surface drives exactly one page, and no probe exercised two.** The provider refuses - every target that is not the page it launched, so `open_tab` creates a page that nothing can address. - Nothing here measures a second page on one surface, and the tab model that needs one - ([ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md)) is `required`, not - `implemented`. +- **The isolated surface drives one page per web tab now.** The provider resolves a target to the + page that carries it, raises the generation per page, and routes a screencast frame by the + envelope's session id. **What the browser itself does with two pages was measured** — see + [the workspace browser tab model](#the-workspace-browser-tab-model) and + `.plan/workspace-browser-tab/complete/two-page-spike.json`. - **The isolated surface's CDP matrix was measured on Chromium only.** The probe ran against the bundled Chromium, not against Edge, so the isolated surface's cells describe that browser. Edge *was* exercised for the **extension** path — see the real-browser acceptance below — but the @@ -323,4 +326,236 @@ an unearned capability. | `chrome` | `true` | `true` | `true` | `true` | `true` | `liveImages` and `input` are `true` for the two bridged surfaces as declarations only. The image stream -and input paths for those surfaces run over the bridge, and nothing measured here confirms either. \ No newline at end of file +and input paths for those surfaces run over the bridge, and nothing measured here confirms either. + +## The workspace browser tab model + +[ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) turns the workspace panel into a +browser tab strip: a tab is a **web tab**, and the panel shows a picture of a live page. That gives the +tab model its own matrix. It is measured against the **isolated Chromium** — the surface the +programme's first slice uses — because that is the surface the repository drives itself. + +**Measured on the validation host.** `/usr/bin/google-chrome`, `Chrome/152.0.7977.82`, revision +`@d04cdb24d67b081f6cf80200cfc5233f44b61109` (`two-page-spike.json` → `host.chrome`). The record is +`.plan/workspace-browser-tab/complete/two-page-spike.json`. + +**Probe method.** The spike launches a real Chrome in the provider's own launch shape and speaks CDP +through the repository's own `WebuiBrowserCdpConnection` +(`packages/webui/src/runtime/browser/isolated-chromium/cdp-connection.ts`). It attaches to **two** +pages and exercises them at the same time. It also launches the repository's own +`WebuiIsolatedChromiumProvider` to record what the provider does today. The two pages are real +documents served from `127.0.0.1`; no network and no real user content were involved. The spike +asserts nothing — the record is the evidence. + +### What a web tab can do — measured + +Every cell in this table is `measured`, and the evidence field is named. + +| Capability | Mark | Evidence | +| --- | --- | --- | +| Two pages of one isolated Chromium, live at the same time | **measured** | `substrate.pages` — two pages, two distinct target ids and two distinct target sessions | +| Each page is driven independently | **measured** | `substrate.afterNavigation` — page A answers `Page A`, page B answers `Page B2` | +| Each page is streamed independently | **measured** | `substrate.framesAtTheEnd.byEnvelopeSession` — A: 1 frame, B: 3 frames, from two different target sessions; `substrate.frameIdentityIsSufficient.bothPagesStreamedAtTheSameTime` is `true` | +| A page that is **not** in front produces no new frames | **measured** | typing into the background page added no frame to its stream; `Page.bringToFront` on that page made it produce one. Chrome renders the foregrounded tab and throttles the others | +| A newly opened page's generation may advance once | **measured** | a page opened with a `data:` URL finished loading before the provider attached to it and stayed at generation `1`; a page opened with a real URL was still loading, and the provider saw the page behind the tab replaced — the generation went to `2` and a target change was announced | +| Each page is input into independently | **measured** | `substrate.input` — page A's field reads `alpha`, page B's reads `bravo`; `independent` is `true` | +| A navigation on the second page advances **that** page's generation and leaves the first alone | **measured** | `substrate.generation` — A stays `1`, B goes `1` → `2` | +| A reference recorded against a page's previous generation is refused | **measured** | `substrate.fence.staleReferenceOnNavigatedPage` — refused with `browser_target_stale` | +| A reference against the untouched page's current generation is still accepted | **measured** | `substrate.fence.currentReferenceOnUntouchedPage` — `accepted: true` | +| The frame's page is resolved from the **envelope's** session id | **measured** | `substrate.frameRouting` — the envelope's `event.sessionId` resolves to a page for every frame; `event.params.sessionId` resolves to none | + +**The frame's identity, in one line.** The envelope's `event.sessionId` is the target session — a +string, put on the event by the CDP connection (`cdp-connection.ts:234`, `:237`) — and it identifies the +page. `event.params.sessionId` is the **number** `1` for frames from *both* pages +(`substrate.frameRouting.paramsSessionIdValues`), and identifies nothing. A screencast frame is +routed by the envelope. + +### What this repository does today + +`not yet measured` in this table means the source does not do this yet, and the row names the ticket +that closes it. It is a gap, not a denial. + +| Capability | Mark | Where it stands | +| --- | --- | --- | +| The provider drives one page per web tab | **implemented** | the provider holds a map of target id → session and generation, and resolves the session by target in `sendCommand`, `sendInput`, `startImageStream` and `stopImageStream`. `packages/webui/src/runtime/browser/isolated-chromium/provider.ts` | +| The generation advances per page | **implemented** | the generation lives on the page, not on the browser. `Page.enable` goes to every page, and the raise is keyed by the event's own session. `provider.ts` — `#attachPage` and `#onEvent` | +| A frame is routed to the page that asked for it | **implemented** | a screencast frame is matched to its page by the **envelope's** session id, and the subscriber that receives it is that page's own. `provider.ts` — `#onEvent` | +| A page the provider never attached to is still refused | **implemented** | only the pages the provider attached to are in the map, and a target that is not in the map is refused with `browser_target_unknown` rather than being sent to the wrong page. `provider.ts` — `sendCommand` | +| The identity `open_tab` returns names the page it created | **implemented** | the answer carries the surface, the browser id, the target id and the generation the provider's own accounting gives the page. `packages/webui/src/runtime/browser/isolated-chromium/provider.ts` — the `open_tab` case of `sendCommand` | +| The service observes a new page from the identity `open_tab` returns | **implemented** | the service reads the answer and observes that target in the registry, so the identity is usable at once — it does not have to wait for the provider to be asked for its list again. `packages/webui/src/runtime/browser/browser-service.ts` — `#observeOpenedTarget` | +| The image stream is keyed by target | **implemented** | the client asks for the target it is showing; the service's frame subscribers are keyed by target, one screencast per page, and a frame that names another page is dropped and counted. `packages/webui/src/runtime/browser/browser-service.ts` — `watchFrames` | +| A session's pages are released when the session ends | **implemented** | the release is keyed on the **session**, not on one operation. The three ways a session goes away — deletion, archiving, and a transfer away — all reach it: the first two through the harness port's own `deleteSession` and `archiveSession`, the third through the transfer's rollback, which calls the same `deleteSession`. `packages/webui/src/runtime/harness/sessions.ts` — `releaseSessionIfItEnded`; `packages/webui/src/runtime/browser/browser-service.ts` — `releaseSession` | +| The runtime publishes the session's web tabs as a snapshot | **implemented** | the service keeps the session's web tabs beside the run's selection and publishes them on the `watchBrowserWebTabs` stream — each tab's id, its target identity, its title, its URL, and whether it is the run's selection. `packages/webui/src/runtime/browser/browser-service.ts` — `watchWebTabs` | +| The client subscribes to the published snapshot | **implemented** | the browser port carries `watchBrowserWebTabs`, the transport's request table has it, and the wire opens the stream, and the strip subscribes **under the session's run id**: the pages of a session are recorded in that cell whether the Agent opened them or the person did, so one subscription carries both. `packages/webui/src/client/features/browser/contracts/browser-port.ts`; `packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx` | +| The Agent opens tabs and gives a run its first selection | **implemented** | `open_tab` is the one action a run with no selection may perform, because it is the action that would create the selection the gate wants. Every other action of a cold run is still refused with `browser_target_unknown`. `packages/webui/src/runtime/browser/tool-adapter.ts` — the `open_tab` branch; `packages/webui/src/runtime/browser/browser-service.ts` — `openWebTab` | `tool-adapter.ts:163` refuses a run that has selected nothing, so the Agent cannot acquire a selection of its own and `open_tab` is refused with it. The provider's side is done; the tool's side is not. #206 | +| The client's tab is a stable id with a mutable content | **implemented** | 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. The rules that used to fall out of the id — the same file opens one tab, the workspace review and the turn review are two tabs — are content checks now. `packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts` | +| The frame operation's request names a target | **implemented** | `watchBrowserFrames` takes `{ surface, target }`. A request that still carries a run id is refused with an explicit error rather than answered with the run's selection. `packages/webui/src/server/operation/browser.ts` — `validateFrameStream` | +| The provider's attachment retry | **implemented** | `provider.ts` — the mechanism is cited in full above | +| The files tab is a singleton | **implemented** | an explicit content check now: one files tab per session and directory. The id no longer implies it, because the id says nothing about what the tab is showing | +| The two review tabs are two tabs | **implemented** | the workspace's change set and a turn's review are told apart by the content's `source`, so opening one no longer replaces the other | +| A tab's label follows its content | **implemented** | the content carries the kind, and a web tab is titled by the page the runtime publishes | +| The strip renders tool tabs and web tabs in one row | **implemented** | a published `WebuiWorkspaceTabStrip` renders every tab the workspace's reducer holds — tool tabs (files / review / canvas / terminal) and web tabs side by side — and owns the close button, the active tab and the "+" new-tab affordance. The strip does not keep its own list of web tabs: each `watchBrowserWebTabs` snapshot becomes an `open-web-tab` command, and the reducer's existing-tab rule keeps duplicates out. `packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx` | +| The surface switcher is gone | **implemented** | Edge and Chrome are reached in the person's own windows now; the only surface the panel ever shows is the isolated Chromium. `BrowserPanel.public.tsx` no longer renders the three-button radio row, and `BrowserPanelHost.public.tsx`'s `surface` prop becomes a `target` prop, the target the active web tab is bound to. `packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx`; `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx` | +| A newly opened tab opens as the new tab page | **implemented** | the strip's "+" dispatches `open-new-tab`, the workspace's reducer opens a tab with the `launcher` content kind and makes it active. It is a fresh tab every time — the launcher is a tab's content, so a person who typed a URL into one presses "+" again for another. `packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts`; `packages/webui/src/client/features/workspace/contracts/panel-command.ts` | +| The new tab page is a launcher, not a menu | **implemented** | four tiles — 变更 / 终端 / 画布 / 查看文件 — and a URL box, rendered by `WebuiWorkspaceLauncher` next to the strip that owns the row. The tiles are the registry's rows that carry a tile, in one order list, so the launcher's list is not a second enumeration; the browser tool view is not among them. `packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx` — `webuiWorkspaceLauncherTiles` | +| Choosing a tile replaces the tab's content | **implemented** | `open-tab` and `open-workspace-review` carry an optional `tabId`, and with it the reducer replaces that tab's content in place instead of the singleton lookup. The tab keeps its id and is neither closed nor replaced. The singleton rule still holds for every caller that does not name a tab | +| A URL typed in the launcher opens a page through the same switch | **implemented** | the launcher calls the browser business's port — `openBrowserWebTab`, a new unary operation whose handler calls `WebuiBrowserPort.openWebTab`, the same member the Agent's `open_tab` reaches. The switch, the provider gate and the refusal codes are shared; a surface that is switched off refuses both paths with `browser_surface_not_authorized`, before a provider is asked for a page. `packages/webui/src/server/operation/handlers/browser.ts`; `packages/webui/src/runtime/port.ts` | +| The page a person opened lands in the tab that asked for it | **implemented** | the launcher dispatches `request-web-page` before the call, so a `watchBrowserWebTabs` snapshot that lands mid-call finds the tab that asked; `open-web-tab` binds the page to the named tab, and to a launcher that is waiting when the command names none. Either order, one page, one tab — and the tab is not activated, so a person who looked elsewhere is not moved. `packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts` | +| A refusal keeps the launcher, and says why | **implemented** | the runtime's own message is carried on the launcher's `request.error` and rendered under the URL box, so the box stays and the person can type something else. Nothing about the launcher's refusal is worded differently from the Agent's | +| The URL box exists only on the new tab page | **implemented** | the launcher renders exactly one input, and a web tab renders none: the browser business's host shows the page's URL in the tab's toolbar as text, not as a field. `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx` | +| A web tab carries back, forward, reload and the URL | **implemented** | the toolbar is rendered by the browser business's **published host** — ADR 0022's component table already gave it that home ("binds a tab to a target, shows the picture and the toolbar") — and it is drawn over the one thing the strip hands it: the tab's target, and the url the runtime published with it. The strip owns the row of tabs; a workspace component drawing a foreign business's page chrome would invert the dependency rule. `BrowserPanelHost.public.tsx` | +| The toolbar's three presses go through the existing command path, and name the page | **implemented** | one request per press on the browser family's own `sendBrowserCommand` operation — no second operation, no second route. `WebuiBrowserCommandRequest` gained an optional `target`, and the handler routes a named target to `WebuiBrowserPort.sendCommandToTarget`. The target is what makes the press mean the page in front of the person: an absent target still resolves the surface's selection (a run's, or the panel's own cell), which is a page the tab may not be showing. `packages/webui/src/server/operation/browser.ts`, `packages/webui/src/server/operation/handlers/browser.ts`, `packages/webui/src/runtime/browser/browser-service.ts` | +| A toolbar press that cannot be honoured says so, and leaves the page alone | **implemented** | the refusal travels out with the runtime's own code and message: `browser_target_stale` for a reference recorded before a move, `browser_capability_denied` when the page's history has nothing to go to. The service checks the surface switch and the command gate first, so a refusal names the switch rather than the page. The client helper does not catch: `browser-web-tab-toolbar.test.tsx` | +| Back, forward and reload move a real page | **implemented** | `packages/webui/test/integration/isolated-chromium-two-pages.test.ts`, "the web tab's toolbar, on a real page": three presses through the client's own helper, the operation's validator and handler, the service and a real isolated Chromium. Observed: `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 with `browser_target_stale` while the tab's current reference still works, and a `back` with nothing behind it is refused with `browser_capability_denied` with the page untouched | + +### The two cells, one writer each + +The tab model has **two** answers to "which page?", and they are not the same answer. ADR 0022 names +them, the code names them, and keeping them apart is what the input path exists for. + +| Cell | Whose | Written by | Read by | Code | +| --- | --- | --- | --- | --- | +| The **run's selection** | the runtime's | the Agent, through its own `open_tab` and its own selection operation | the client, read-only, out of the mark the runtime puts on the target list | `WebuiBrowserRunSelectionTarget` — `WebuiBrowserPanelState.runSelection`, `packages/webui/src/runtime/browser/browser-service.ts` `#runSelections` | +| The **tab the person is looking at** | the client's | the person, by choosing a tab | the client, for the picture, the alt text, the input binding and the frame stream | `WebuiBrowserViewedPage` — `WebuiBrowserPanelState.viewedPage`, `packages/webui/src/client/features/browser/contracts/browser-panel.ts` | + +| Capability | Mark | Where it stands | +| --- | --- | --- | +| The panel's input is bound to the page the person is looking at, never to the run's selection | **implemented** | `prepareWebuiBrowserInputCommand` takes the viewed page as its own argument — there is no field of the run's selection left in its signature for a gesture to be aimed at — and `checkWebuiBrowserInputTarget` fences it against that page's generation. `packages/webui/src/client/features/browser/application/browser-commands.ts`; `packages/webui/test/unit/browser-input-command.test.ts`, "binds to the tab the person is looking at while the run's selection is elsewhere" | +| The panel shows and streams the page in front, not the Agent's page | **implemented** | `selectWebuiBrowserStreamTarget` and `selectWebuiBrowserFrame` read `viewedPage`; the host dispatches `page-viewed` when the tab it was handed changes. `packages/webui/src/client/features/browser/projection/browser-state.ts`; `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx` | +| The person cannot write the run's selection from the panel | **implemented** | the panel's target list is a report of the runtime's mark, not a control: the rows are spans, `BrowserPanelProps` has no `onSelectTarget`, and the client port member `selectBrowserTarget` is no longer called from the panel. The `selectBrowserTarget` operation and port member still exist — the runtime still owns that write — the panel simply is not one of its callers | +| Neither cell writes the other when either one moves | **implemented** | `page-viewed` moves `viewedPage` alone; `targets-loaded` and `target-changed` move `runSelection` alone; `run-changed` clears `runSelection` and leaves `viewedPage`. `packages/webui/test/unit/browser-panel-state.test.ts`, "the two cells" | +| The bootstrap: a page a person opened while the run has no selection becomes the run's selection | **implemented** | the request says who opened it — `WebuiBrowserTabOpener` is `"agent"` or `"person"` — and the **runtime** decides: a person-opened page joins the run's selection only when `#selectionOf(surface, runId)` was undefined at the moment the page appeared. `packages/webui/src/runtime/browser/browser-service.ts` — `openWebTab`; `packages/webui/test/unit/browser-service.test.ts`, "run-scoped selection" | +| The bootstrap is not a standing co-write rule | **implemented** | an `agent` opener always sets the selection — a second `open_tab` moves the run and loses nothing — while a `person` opener with a run that already has a page records the web tab and leaves the selection where the Agent put it. Both are asserted in `browser-service.test.ts` and both were observed against a real browser | +| The two cells are separated on a real browser, with both in use | **implemented** | `packages/webui/test/integration/isolated-chromium-two-pages.test.ts`, "the run's selection and the tab in front, on a real browser". Observed: the Agent opened a page and drove it while the person opened a second page and typed into it through the client's binding function and the `sendBrowserInput` handler; the Agent's page read `agent acted` with an empty field, the person's page read `text:person typed` in its field, and the run's selection was still the Agent's page at the end. On one page — the one the bootstrap gave the cold run — the person's text and the Agent's action were both delivered, and an input recorded before that page moved was refused with `browser_target_stale` rather than dropped | + +**What the bootstrap is not.** It is not a rule that says "the person's page is the run's page", and it +is not decided by the client. The workspace names its session in the request and the runtime compares it +against the selection it owns at that moment; the client cannot see a run's selection at all. That is why +the launcher passes the run id straight through (`webuiWorkspaceLauncherOpenPage`) and applies nothing +itself. + +**What the provider does today, measured again.** The provider's `open_tab` creates a page, attaches +to it and registers it, and the page is listed with its own identity +(`phases.provider.newPageIsListedByTheProvider`). Driving it, inputing into it and streaming it all +succeed (`phases.provider.driveNewPageNavigate`, `.driveNewPageInput` and `.streamNewPage` are all +`ok`). The page count still goes from 2 to 3 (`phases.provider.pageCount`) — the third page is the +one Chrome opened for itself, which the provider has never attached to and therefore still refuses. + +### The fence, on every page + +The fence is what makes a reference to a page that has been replaced detectable. The spike shows it +working on a page that is **not** the one the browser was launched with: + +| Page | What happened | Result | +| --- | --- | --- | +| B (navigated) | its generation advanced `1` → `2`; a reference recorded against generation `1` was offered | refused with `browser_target_stale` | +| A (untouched) | its generation stayed `1`; a reference recorded against generation `1` was offered | accepted | + +A fence that only works on the launch page is a fence that does not work. The provider's fence now +covers every page: the generation it compares against is the page's own, so a reference recorded +against a page that navigated is refused on that page, and the same reference against a page that +did **not** navigate is still accepted. + +### The registration cost of the new operation + +The programme adds one operation that publishes the session's web tabs. Registering it costs the +**eight** sites below, and every one of them is a place where a change that is forgotten shows up as a +missing capability rather than as an error. **The `watchBrowserWebTabs` operation is registered at +every one of them**; `packages/webui/test/unit/browser-web-tabs-operation.test.ts` is the executable +form of this list, and a site that loses the operation fails there. + +| Site | Where | What the new operation needs | +| --- | --- | --- | +| The capability manifest's `browser.ready` list | `packages/webui/src/shared/capability-manifest.ts:143` | the operation's name joins the list. A `ready` member that is not wired makes the whole browser family **not-ready** (`capability-manifest.ts:202`), not absent — a quiet degradation. **Done.** | +| The operation-name constants | `packages/webui/src/shared/operation-names.ts:51` | one new `as const` constant beside the eight browser names. **Done.** | +| The operation spec | `packages/webui/src/shared/operation-names.ts:458` | one row naming the request and the response type, beside `watchBrowserFrames`. **Done.** | +| The contract type | `packages/webui/src/shared/contracts/browser.ts` | the request and response types live here; `watchBrowserFrames` has none of its own, so the new operation introduces one. **Done.** | +| The operation registry | `packages/webui/src/server/operation/operations.ts:162` | one `registerOperation(registry, entry(""))` line inside the `if (browser)` block. **The order is a wire contract** (`operations.ts:69`). **Done.** | +| The server handler | `packages/webui/src/server/operation/handlers/browser.ts:127` | a dedicated handler. A stream handler returns `{ stream: { ok: true, source } }` and lets the service's generator raise the errors — the handler does not catch them. **Done.** | +| The port member and its implementation | `packages/webui/src/runtime/port.ts:637` and `packages/webui/src/runtime/harness/browser.ts:206` | the member on `WebuiBrowserPort` and its body in `createBrowserPort`. **Done.** | +| The transport's request table | `packages/webui/src/client/platform/transport.ts:232` | the client's request builder; a stream also needs the wire session in `packages/webui/src/client/platform/wire.ts:144` and `:373`. **Done.** | + +**There is no `bind()` row to add.** The browser operations are dedicated handlers, not bindings: the +`WEBUI_OPERATION_BINDINGS` table (`packages/webui/src/server/operation/bind-handlers.ts:149`) has no +browser entry, and there is no `RuntimeGroups.browser` group to extend. + +**The second operation costs the same eight sites and nothing else.** `openBrowserWebTab` — the call the +new tab page's URL box makes, so a person and the Agent reach the same `open_tab` — is registered at +every site above, and `packages/webui/test/unit/browser-web-tabs-operation.test.ts` asserts both +operations there. Two things about it are worth naming, because both are decisions rather than +mechanics: + +- **Its request carries a URL and a run, not a surface.** The surface is read off the run, the same way + the Agent's path reads it, so the two cannot disagree about where a page is opened. A request with no + run is the panel's own cell — the panel is not a run — and the bootstrap applies to it as it applies to + a run. The workspace names its session, which is the same run id the rest of the browser port already + uses; **what the request does not carry is the answer.** The bootstrap is decided by the runtime, which + is the only place that can read the run's selection at the moment the page appears, so the launcher + passes the run through and applies nothing itself. The handler is what declares the request the + person's: it passes `WebuiBrowserTabOpener`'s `"person"`, and `WebuiBrowserPort.openWebTab` carries it + to the service. **Done in #211.** +- **Its validator refuses an empty URL** with `invalid_body` rather than defaulting it. A blank page + opened by a request that claimed to be honoured is worse than a request that was refused. + +### The plumbing cost of a new source file and a new test file + +| What | Where | What it costs | +| --- | --- | --- | +| The frozen path metadata fixture | `packages/webui/test/gates/fixtures/path-metadata-f00.json` | a new source path needs an entry, classified by the **frozen** baseline classifier. The fixture's own README says not to regenerate expectations from the current classifier: strip only `.public` before the TypeScript suffix, then run the frozen classifier against the path and review any mismatch separately | +| The declared Vitest suite groups | `packages/webui/test/vitest-suites.json` | a new Vitest file is appended to `suites.webui`. This JSON is the single source — no `package.json` script and no Vitest config change | +| The source inventory | `release/public-source.json` | regenerate with `node scripts/source-inventory.mjs --write`; `pnpm check:source` reads it. The inventory scans the working tree, including untracked files, so a scratch file left in the repository becomes a publication problem | +| The Playwright spec directory | `packages/webui/test/browser/` | a real-browser spec lands here. A test that drives the **isolated Chromium runtime** is not a Playwright spec: it belongs in `packages/webui/test/integration/`, beside `browser-use-real-input.test.ts`, and is a Vitest suite | + +**The launcher cost none of them.** The new tab page is `WebuiWorkspaceLauncher` inside the strip's own +published file, and its interaction coverage is inside the strip's existing suite: a launcher that was a +new component and a new test file would have added a frozen-fixture entry, a suite-group entry and an +inventory entry for a component the ADR already gives a home. The next programme step should follow the +same shape — a view that belongs to an existing component belongs in that component's file. + +### The tab-model inventory, checked against the tree + +The inventory the research notes name, checked against the code as it stands. Every row is real. + +| Site | Where | What it is | +| --- | --- | --- | +| The files-tab singleton | `workspace-panel-state.ts:289` | `open-primary-view` drops **every** `files` tab before adding one — the singleton is enforced by deletion, not by a lookup | +| The two review tabs that share one id | `workspace-panel-state.ts:279` and `:283` | the workspace's change set and a turn's review derive the **same** id, `tabId("review", sessionId, workspaceDir)`. One of them replaces the other | +| The label derived from the kind | `workspace-panel-state.ts:154` | `webuiWorkspaceTabLabel` switches on `tab.kind` | +| The `kind === "review"` narrowing | `workspace-panel-state.ts:156`, `:299`, `:302`, `:303`; `WorkspacePanels.tsx:332`, `:337`, `:338`, `:340`, `:341` | the two review sources are told apart by narrowing on the kind and then on the source | +| The `activeTab?.kind` guards | `WorkspacePanels.tsx` | **28** of them | +| The caches keyed by a tab id whose effects depend on `[activeTab?.id]` | `WorkspacePanels.tsx:291`, `:306`, `:309`, `:325`, `:327`, `:330`, `:337`, `:347`, `:353`, `:417` | the file-results cache, the review summary, the review search and the review diffs are all keyed by the tab id, and their effects re-run when that id changes | + +**Why that breaks under a mutable content.** A tab's id is derived from its kind and its parts +(`workspace-panel-state.ts:251`), and a web tab's page changes without the kind changing. The +caches above are keyed by the id and their effects are keyed by the id, so a content that changes +under a stable id leaves a stale picture on screen: the cache says the tab is showing generation 1 +while the page behind it is generation 2. The id has to become stable and the content has to become +the thing that changes — which is what #207 does. + +### Not yet measured — the tab model's open questions + +- **The two-page case was first measured on the substrate and then through the provider.** The + provider could not hold two pages when the spike first ran, so the spike drove the CDP connection + directly to find out what the browser can do. The provider's per-target map is now implemented and + the same spike observes it driving, inputing into and streaming the second page. +- **The frame mis-stamping count is a modelled consequence, not a provider observation.** + `substrate.currentProviderFrameRule` applies the provider's current rule to the frames the spike + observed, because the provider cannot stream a second page at all. It is arithmetic over a + measurement, and it is labelled as such in the record. +- **The toolbar and the two cells are measured against a real browser; the rest of the tab model is + not.** The three commands a web tab carries — back, forward, reload — are pressed against a real + isolated Chromium in `packages/webui/test/integration/isolated-chromium-two-pages.test.ts`, through + the client's own helper, the operation's validator and handler, and the service. So is the separation + of the two cells: the Agent working in one page while the person types in another, both delivered on + the one page the bootstrap gave a cold run, and a stale input refused rather than dropped — in + "the run's selection and the tab in front, on a real browser", in the same file. The tab model, the + strip and the new tab page are still exercised only by unit coverage: #208 and #209 covered the tab + model, the strip and the new tab page, #210 the toolbar, #211 the two cells, and #212 is where the + acceptance record and the documentation sweep happen. +- **Only the foregrounded page renders.** Chrome throttles the pages it is not showing, so a tab in + the background produces no new frames. A strip shows one tab at a time, so the page in front is the + one that produces the picture — but whether a background tab should show a *still* picture, and + when it is refreshed, is not decided. +- **The bridged surfaces have no tab model at all.** Everything measured here is the isolated + Chromium. The extension bridge exposes one visible tab per browser, so a tab strip over it is a + different question and was not asked. \ No newline at end of file diff --git a/packages/webui/docs/browser-use/05-target-identity.md b/packages/webui/docs/browser-use/05-target-identity.md index b29d991b8..3ca531ffb 100644 --- a/packages/webui/docs/browser-use/05-target-identity.md +++ b/packages/webui/docs/browser-use/05-target-identity.md @@ -135,10 +135,10 @@ identity the message arrives on. See [`02-bridge-protocol.md`](02-bridge-protoco | --- | --- | --- | | Each Agent run / TUN owns its own target selection | Two concurrent runs select independently | `implemented` — the service keys its selections by surface and then by run (`WebuiBrowserService.#selected`) | | One run's selection must not overwrite another's | Selecting a tab in run A does not move run B's target | `implemented` — the selection map is per run | -| The Agent may choose a different authorized tab each turn | Selection is not pinned for the life of a run | `required` — the contracts pin it and the tree has no path: the Agent's tools can only act on a selection, and nothing the Agent does creates one. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides how that path is built | +| The Agent may choose a different authorized tab each turn | Selection is not pinned for the life of a run | `implemented` — the Agent's `open_tab` is the path: it creates a page and makes it the run's selection (`runtime/browser/tool-adapter.ts:178`, `runtime/browser/browser-service.ts:1082`), so a run's selection is not pinned for the life of the run | | **After switching tabs, the Agent must reacquire page elements** | References from the previous tab are dead. This is a caller obligation, enforced by the generation counter making it visible | `required` — a rule on the caller; the generation counter can only report the mismatch | | Operations against the same tab are serialized | One writer per tab | `required` — the service and the CDP connection both answer commands without serializing them per target | -| Operations against different tabs may proceed independently | Concurrency is per tab, not global | `required` — the tree has one provider per surface, so a second tab has no session to address | +| Operations against different tabs may proceed independently | Concurrency is per tab, not global | `implemented` — each page of the isolated Chromium has its own CDP session (`runtime/browser/isolated-chromium/provider.ts:199`), so a command against one tab does not wait for another | The reacquire rule is stated as a requirement on the caller rather than as a mechanism, because the generation counter can only report the mismatch — it cannot re-resolve an element reference. A reference @@ -154,15 +154,16 @@ The rules that decision pins are in [The tab model](#the-tab-model-adr-0022). | Rule | Reason | State | | --- | --- | --- | -| The panel projects the active run's selection | The user is watching the run they are looking at, not a different run's tab | `implemented` — the panel reads the run's selection from the target list (`BrowserPanelHost` → `listBrowserTargets`) | -| Changing runs updates the panel | The panel re-reads the newly active run's selection | `implemented` — the panel re-reads the list when the run changes | +| The panel reports the active run's selection | The person can see which page the Agent is working on, and it is a different page from the one in front | `implemented` — the panel reads the run's selection from the target list (`BrowserPanelHost` → `listBrowserTargets`) and mirrors it into `WebuiBrowserPanelState.runSelection`. It **reports** rather than projects: the panel shows the page in front (below) | +| Changing runs updates what the panel reports | The panel re-reads the newly active run's selection, and the page in front does not move with it | `implemented` — the panel re-reads the list when the run changes; `run-changed` clears `runSelection` and leaves `viewedPage` alone | | A frame for a target the panel is not showing is dropped | The frame carries its identity, so a stale or out-of-band frame is detectable and is not displayed as if it were the chosen page. | `implemented` — `WebuiBrowserService.#frameMatchesSelection` (`browser-service.ts:733`) compares all four identity fields and the stream drops non-matching frames (`browser-service.ts:664`); see finding F6 | -| The panel never switches the target itself | Selection belongs to the run; the panel reflects it | `implemented` — the panel writes a selection only when the person clicks a target, and it never moves one on its own | +| The panel never writes the run's selection | Selection belongs to the Agent; the panel reflects it | `implemented` — the panel's target list is a report of the runtime's mark, with no write behind it: `BrowserPanelProps` has no `onSelectTarget` and nothing in the panel calls `selectBrowserTarget`. The person writes the cell they own — the tab in front — by choosing a tab in the strip | -That last row is the distinction that keeps the panel honest. The panel reflects the run's selection, -and it writes that selection only when the person asks it to. If the panel could move a selection on -its own, it would be a second writer on the same per-run selection, and the run's choice would stop -being authoritative. +That last row is the distinction that keeps the panel honest. The run's selection is the Agent's cell, +and a person cannot move it from the panel at all: a click that used to hand the Agent a page is gone, +because the strip — which the ADR 0022 gives the row of tabs — is what the person writes now. If the +panel could move a selection on its own, it would be a second writer on the same per-run selection, and +the run's choice would stop being authoritative. The frame's `sequence` is a monotonic per-stream counter, so a client can also detect a dropped frame — a liveness signal that is independent of whether the frame was the right one. @@ -170,26 +171,33 @@ a liveness signal that is independent of whether the frame was the right one. ## The tab model (ADR 0022) [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) replaces the single browser -panel with a per-session browser tab strip. It is accepted and not yet in the tree, so every rule below -is `required` — the contracts pin it and the source does not enforce it yet. +panel with a per-session browser tab strip. It is in the tree: every rule below is `implemented`, and +the row says what carries it. The rules that are *not* the tab model's — a caller obligation, or a +concurrency rule the tree does not enforce — are still `required`, and they say so. | Rule | Consequence | State | | --- | --- | --- | -| A tab is a slot whose content can be replaced in place | Choosing a launcher tile or typing a URL changes what the tab shows; the tab is not closed and the strip does not flicker | `required` | -| The tab's id belongs to the client | Two web tabs in one session are two tabs, and the id does not change when the content does; the target identity is a binding the tab holds | `required` | -| The URL box belongs to the new tab page | There is no persistent address bar; a web tab carries a toolbar (back, forward, reload, read-only URL) instead | `required` | -| A web tab holds exactly one browser page | The isolated Chromium holds one page per web tab, so a page is addressable by the tab that shows it | `required` | -| A web tab is titled by the page and glyphed by default | The title is the one the provider already reports; the icon is a default glyph, and fetching a site's icon is not part of this | `required` | -| The Agent opens web tabs | `open_tab` creates a page, makes it the run's selection and tells the client, so a tab appears in the strip and becomes the active one | `required` — the tree refuses every action of a run with no selection, including `open_tab` | -| The run's selection and the person's active tab are separate cells | A person opening their own tab does not move the Agent off its page, and the Agent opening a tab does not yank the person away from theirs. The person's input follows their tab; the Agent's actions follow the run's selection | `required` | -| One bootstrap: a person opening a web tab gives the run that page **only when the run has no selection** | A run with no selection cannot act at all, so the person's page becomes the run's selection; after that the two cells are independent | `required` | -| Closing a web tab closes its page | The runtime's page set follows the strip, so a closed tab leaves no page behind in the Chromium | `required` | -| The browser tool tab is deleted | The strip's kinds lose `browser` and with it the surface switcher: the panel is the isolated browser's view, and the external browsers are driven in the user's own windows | `required` | -| Frames are per target | Each tab's picture is its own; a frame reaches only the tab that asked for it | `required` — the stream is per run today | +| A tab is a slot whose content can be replaced in place | Choosing a launcher tile or typing a URL changes what the tab shows; the tab is not closed and the strip does not flicker | `implemented` — `replaceTabContent` (`client/features/workspace/projection/workspace-panel-state.ts:420`) replaces the content of the tab that is open, and the strip's own launcher does it (`client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx:315`) | +| The tab's id belongs to the client | Two web tabs in one session are two tabs, and the id does not change when the content does; the target identity is a binding the tab holds | `implemented` — `openNewTab` assigns the id from the client's own counter (`client/features/workspace/projection/workspace-panel-state.ts:353`, `:259`), and the content carries the binding (`WorkspacePanelTab`, `:120`) | +| The URL box belongs to the new tab page | There is no persistent address bar; a web tab carries a toolbar (back, forward, reload, read-only URL) instead | `implemented` — the only field for a URL is the launcher's (`WebuiWorkspaceLauncher`, `client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx:315`); a web tab's URL is a `span`, not an input (`client/features/browser/ui/BrowserPanelHost.public.tsx:442`) | +| A web tab holds exactly one browser page | The isolated Chromium holds one page per web tab, so a page is addressable by the tab that shows it | `implemented` — the provider's per-target map holds one page per web tab (`runtime/browser/isolated-chromium/provider.ts:199`), and the command path resolves the session by target | +| A web tab is titled by the page and glyphed by default | The title is the one the provider already reports; the icon is a default glyph, and fetching a site's icon is not part of this | `implemented` — the title is the one the runtime's snapshot publishes, and the strip renders it with a default glyph (`client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx:183`); no icon is fetched | +| The Agent opens web tabs | `open_tab` creates a page, makes it the run's selection and tells the client, so a tab appears in the strip and becomes the active one | `implemented` — the tool adapter lets `open_tab` through for a run with no selection (`runtime/browser/tool-adapter.ts:178`), the service opens the page and makes it the run's selection (`runtime/browser/browser-service.ts:1082`), and the strip turns the published tab into a panel tab (`client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx:106`) | +| The run's selection and the person's active tab are separate cells | A person opening their own tab does not move the Agent off its page, and the Agent opening a tab does not yank the person away from theirs. The person's input follows their tab; the Agent's actions follow the run's selection | `implemented` — `WebuiBrowserPanelState.viewedPage` (the client's cell, written by `page-viewed`) and `runSelection` (the runtime's cell, written by `targets-loaded`). The picture, the alt text, the frame stream and the input binding all read `viewedPage`; `prepareWebuiBrowserInputCommand` takes it as its own argument, so no gesture can be aimed at the run's page | +| One bootstrap: a person opening a web tab gives the run that page **only when the run has no selection** | A run with no selection cannot act at all, so the person's page becomes the run's selection; after that the two cells are independent | `implemented` — `openWebTab` takes a `WebuiBrowserTabOpener` (`"agent"` or `"person"`), and a person-opened page joins the run's selection only when `#selectionOf(surface, runId)` was undefined at the moment the page appeared. The runtime decides it, because the runtime is the only place that can read that selection; the workspace names its session and applies nothing itself | +| Closing a web tab closes its page | The runtime's page set follows the strip, so a closed tab leaves no page behind in the Chromium | `implemented` — `closeWebTab` closes the page and forgets the target (`runtime/browser/browser-service.ts:376`) | +| The browser tool tab is deleted | The strip's kinds lose `browser` and with it the surface switcher: the panel is the isolated browser's view, and the external browsers are driven in the user's own windows | `implemented` — the registry has no `browser` kind (`client/features/workspace/projection/workspace-panel-state.ts:29`), and the published host is keyed by the web tab's target rather than by a switchable surface (`client/features/browser/ui/BrowserPanelHost.public.tsx`) | +| Frames are per target | Each tab's picture is its own; a frame reaches only the tab that asked for it | `implemented` — the stream is keyed by the target (`runtime/browser/browser-service.ts`), and the provider routes a frame to its page by the envelope's session id (`runtime/browser/isolated-chromium/provider.ts`) | The last two rows are the ones that change the previous section. The panel is no longer a projection of the run's selection: it shows the tab the person is looking at, and the run's selection is a -separate cell with a separate writer. +separate cell with a separate writer. Both are named in the code as they are named here — +`WebuiBrowserRunSelectionTarget` and `WebuiBrowserViewedPage` in +`client/features/browser/contracts/browser-panel.ts` — and both were observed on a real isolated +Chromium in `packages/webui/test/integration/isolated-chromium-two-pages.test.ts`, "the run's selection +and the tab in front, on a real browser": the Agent drove one page while the person typed in another, +each page ended up holding only its own input, and an input recorded before a page moved was refused +with `browser_target_stale` rather than dropped. ## Related diff --git a/packages/webui/docs/browser-use/06-runtime-authorization-trace.md b/packages/webui/docs/browser-use/06-runtime-authorization-trace.md index 46778d011..b0469d75b 100644 --- a/packages/webui/docs/browser-use/06-runtime-authorization-trace.md +++ b/packages/webui/docs/browser-use/06-runtime-authorization-trace.md @@ -83,23 +83,23 @@ Five enforcement points. "Required check" is the rule; "Code today" is what exis | --- | --- | | Required check | The surface switch is on, and the target identity — including generation — matches what the tab is showing | | Refusal | `browser_surface_not_authorized`, `browser_target_stale` | -| Code today | **`implemented`.** `WebuiBrowserService.watchFrames` refuses with `browser_surface_not_authorized` before doing anything, and the stream is registered as the `watchBrowserFrames` operation (`shared/operation-names.ts`, `server/operation/browser.ts`). Frames are fenced: `#frameMatchesSelection` (`browser-service.ts:733`) compares surface, browserId, targetId **and generation**, and the stream loop drops anything that does not match (`browser-service.ts:664`) | -| Not measured | No probe or integration run observed a frame being dropped for a superseded generation — verified by reading the source | +| Code today | **`implemented`.** `WebuiBrowserService.watchFrames` refuses with `browser_surface_not_authorized` before doing anything, and the stream is registered as the `watchBrowserFrames` operation (`shared/operation-names.ts`, `server/operation/browser.ts`). Frames are fenced: `#frameMatchesTarget` (`browser-service.ts:1035`) compares surface, browserId, targetId **and generation**, and the stream loop drops anything that does not match (`browser-service.ts:886`) | +| Not measured | No probe or integration run observed a frame being dropped for a superseded generation — verified by reading the source, and by a real browser observing *delivery* rather than a drop (`isolated-chromium-two-pages.test.ts` gives two pages their own pictures and asserts neither receives the other's) | | What does exist | `WebuiBrowserImageFrame` and the bridge's `WebuiBrowserBridgeImageFrame`, which carry the target identity including generation so a client *can* drop a frame for a page it is no longer showing ([`05-target-identity.md`](05-target-identity.md)) | | Provider-side prerequisite | `implemented` — `#withAttachmentRetry` (`provider.ts:588`) re-issues an attachment failure for up to 3 s across eleven sends. The failure itself is `measured`, see [`01-capability-matrix.md`](01-capability-matrix.md) | -| What ADR 0022 changes | The stream is keyed by the run, so it carries the run's selection. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) makes it per target, so a person looking at one tab does not receive another tab's frames. `required` — not in the tree yet | +| What ADR 0022 changes | The stream is keyed by the run, so it carries the run's selection. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) makes it per target, so a person looking at one tab does not receive another tab's frames. **`implemented`.** `watchFrames(surface, target, signal)` is keyed by the target, the request carries `{ surface, target }`, and the client asks for `viewedPage` — the page in front, which is the *client's* cell, not the run's selection. `browser-service.ts` — `watchFrames`; `client/features/browser/projection/browser-state.ts` — `selectWebuiBrowserStreamTarget` | ### 2. A tab's input forwarding | | | | --- | --- | -| Required check | The surface switch is on; the command satisfies `WebuiBrowserInputCommand`; the named target matches the registry including generation | +| Required check | The surface switch is on; the command satisfies `WebuiBrowserInputCommand`; the named target is **the page the person is looking at** and matches the registry including generation. The named target is the *client's* cell: a gesture is never bound to the run's selection (`client/features/browser/application/browser-commands.ts` — `prepareWebuiBrowserInputCommand`) | | Refusal | `browser_surface_not_authorized`, `browser_command_invalid`, `browser_target_stale` | -| Code today | **`implemented`.** `WebuiBrowserService.sendInput` (`browser-service.ts:289`) calls `validateBrowserInputCommand` first (`input.ts`), then refuses with `browser_surface_not_authorized`, then calls `this.#registry.isCurrent(validated.target)` and refuses with `browser_target_stale`. It is registered as the `sendBrowserInput` operation | -| What made the generation check effective | The registry's counter is fed: `#ensureProvider` subscribes to `provider.onTargetChanged` and calls `registry.raiseGeneration` (`browser-service.ts:176`), so the registry's generation advances when the provider reports a page replacement. Finding F2 in [`07-review-findings.md`](07-review-findings.md) | +| Code today | **`implemented`.** `WebuiBrowserService.sendInput` (`browser-service.ts:788`) calls `validateBrowserInputCommand` first (`input.ts`), then refuses with `browser_surface_not_authorized`, then calls `this.#registry.isCurrent(validated.target)` and refuses with `browser_target_stale`. It is registered as the `sendBrowserInput` operation | +| What made the generation check effective | The registry's counter is fed: `#ensureProvider` subscribes to `provider.onTargetChanged` and calls `registry.raiseGeneration` (`browser-service.ts:597`), so the registry's generation advances when the provider reports a page replacement. Finding F2 in [`07-review-findings.md`](07-review-findings.md) | | Not measured | No run drove a navigation and observed a refused input — verified by reading the source | | What does exist | `WebuiBrowserInputCommand` (pointer / key / text), `WebuiBrowserBridgeInputCommand`, and `browser_command_invalid` in the pinned error codes | -| Why validation is a gate and not hygiene | A tab showing a stale page must not deliver input to whatever replaced it. The generation in the command is what makes that refusal possible | +| Why validation is a gate and not hygiene | A tab showing a stale page must not deliver input to whatever replaced it. The generation in the command is what makes that refusal possible. A real browser showed both halves of it: on one page the person's text and the Agent's action were both delivered, and an input recorded before that page moved was refused with `browser_target_stale` rather than dropped (`isolated-chromium-two-pages.test.ts`, "the run's selection and the tab in front, on a real browser") | ### 3. The Agent's browser tools @@ -108,7 +108,7 @@ Five enforcement points. "Required check" is the rule; "Code today" is what exis | Required check | The surface switch for the selected surface is on, and the requested action is in the restricted command set (or the Full CDP gate is on, for anything outside it). `open_tab` is the exception the tab model introduces: it reads the surface switch and creates the page itself, so it needs no prior selection | | Refusal | `browser_surface_not_authorized`, `browser_capability_denied` | | Code today | **Implemented.** The restricted vocabulary is declared in `packages/agent-tools/src/desktop/builtin-browser-defs.ts` — `LOCAL_BROWSER_ACTION_NAMES` at line 744 (24 names) and `LOCAL_BROWSER_ACTION_INPUT_CONTRACTS` at line 714. The WebUI's tool adapter (`packages/webui/src/runtime/browser/tool-adapter.ts`) implements `LocalBrowserAdapter` over the browser service: `getCapabilities()` declares exactly the restricted set, and `execute` forwards the action to the service, which reads the surface switch and the Full CDP gate. An action outside the restricted set is refused with `browser_capability_denied` even when Full CDP is on, because the tool path is the restricted path. | -| Reachability from this client | **Reachable.** The WebUI has a browser port (`WebuiBrowserPort`, `runtime/port.ts`), a browser adapter (`runtime/harness/browser.ts`) and the registered browser operations. The adapter is handed to the runtime host as the `browserProvider` by both composition roots, and the service is the one place the switch is read — so the Agent's tools and the panel address the same session by construction rather than by convention. The service's `sendCommandToSelection` runs the action against the **selected** target of the surface, and refuses when there is no selection rather than defaulting to one. A run that has never selected anything therefore cannot act at all, `open_tab` included: the tree has no path by which the Agent creates the selection it needs. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides that `open_tab` is that path; `required`, not in the tree yet | +| Reachability from this client | **Reachable.** The WebUI has a browser port (`WebuiBrowserPort`, `runtime/port.ts`), a browser adapter (`runtime/harness/browser.ts`) and the registered browser operations. The adapter is handed to the runtime host as the `browserProvider` by both composition roots, and the service is the one place the switch is read — so the Agent's tools and the panel address the same session by construction rather than by convention. The service's `sendCommandToSelection` runs the action against the **selected** target of the surface, and refuses when there is no selection rather than defaulting to one. A run that has never selected anything therefore cannot act at all, `open_tab` included. [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides that `open_tab` is that path, and there is now a second one: **the bootstrap** — a page a person opened while the run had no selection becomes the run's selection, decided by the runtime at the moment the page appears. `implemented`: `openWebTab`'s `WebuiBrowserTabOpener` argument, `browser-service.ts` | | Isolated-surface provider | The WebUI's own: `packages/webui/src/runtime/browser/isolated-chromium/provider.ts` with `cdp-connection.ts`. The repository also has a TUI-side provider (`packages/tui/src/runtime/browser/headless-chrome-provider.ts`); the WebUI does not adopt it, because the WebUI's provider is the one the panel's live image stream and the tool adapter share | ### 4. Bridge admission @@ -156,9 +156,9 @@ impossible to allow one surface without allowing the rest. | Enforcement point | Surface switch checked | Full CDP checked | Code today | | --- | --- | --- | --- | -| A tab's image stream | required | not applicable | **implemented** — surface check enforced, frames fenced to the selected generation (F6). The stream is keyed by the run; [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) makes it per target (`required`) | +| A tab's image stream | required | not applicable | **implemented** — surface check enforced, frames fenced to the selected generation (F6). The stream is keyed by the **target** (`runtime/browser/browser-service.ts:795`), and the provider routes a frame to its page by the envelope's session id | | A tab's input forwarding | required | not applicable | **implemented** — validation, surface check and `isCurrent`; the registry's generation is fed from `onTargetChanged` (F2) | -| Agent browser tools | required | required, for non-restricted commands | **implemented** — the tool adapter (`packages/webui/src/runtime/browser/tool-adapter.ts`) forwards to the same browser service the panel uses, and the composition root hands it to the runtime host. A run with no selection is refused every action, `open_tab` included; ADR 0022 gives `open_tab` the bootstrap path (`required`) | +| Agent browser tools | required | required, for non-restricted commands | **implemented** — the tool adapter (`packages/webui/src/runtime/browser/tool-adapter.ts`) forwards to the same browser service the panel uses, and the composition root hands it to the runtime host. A run with no selection is refused every action except `open_tab`, which is the action that would create the selection the gate wants (`runtime/browser/tool-adapter.ts:178`) | | Bridge admission | required | not applicable | **implemented** — the bridge has its own upgrade path, branched on before the generic one (F3), and the command routing is fixed (F4) | | Full CDP gate | required first | required | **implemented** — `sendCommand` checks the surface first, then the switch (F5) | diff --git a/packages/webui/docs/browser-use/07-review-findings.md b/packages/webui/docs/browser-use/07-review-findings.md index a8a7c2db1..67b3fca92 100644 --- a/packages/webui/docs/browser-use/07-review-findings.md +++ b/packages/webui/docs/browser-use/07-review-findings.md @@ -651,22 +651,24 @@ are closed. This round was not run by an external reviewer. It was raised by a person asking the Agent to open a browser and visit a page, and reading the refusal it produced. Four findings came out of that, and -none of them is closed. +all four are closed by the tab model ([ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md)), +which is now in the tree. | # | Severity | Finding | Status | | --- | --- | --- | --- | -| T1 | BLOCKER | **The Agent cannot create a selection, so it cannot act at all.** The tool adapter resolves the surface from the run's own selection and refuses with `browser_target_unknown` when the run has none, and the only writer of a selection is the panel. A person asking for a browser therefore got the same refusal for every action — `inspect`, `navigate` and `open_tab` alike — and `open_tab` was the one action that should have produced a selection. ADR 0019 required that both the user and the Agent can select a target; the tree carried half of it. | `open` — [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides that `open_tab` creates the page and the selection. Not in the tree. | -| T2 | MAJOR | **`open_tab` created a page that nothing could address.** The isolated provider drives exactly one page and refuses every other target, so `Target.createTarget` produced a second page that the provider would not drive, would not stream, and would not accept input for. It appeared in the target list as a tab that did nothing when chosen. | `open` — ADR 0022 makes the provider hold one page per web tab. Not in the tree. | -| T3 | MAJOR | **The image stream was per run, so the person and the Agent could not be on different pages.** `watchFrames` resolved the run's selection and streamed that target, and the client dropped any frame that did not match it. A person opening another tab had no way to see it without moving the run's selection, which would have moved the Agent's work with it. | `open` — ADR 0022 makes the stream per target, and separates the run's selection from the tab the person is looking at. Not in the tree. | -| T4 | MINOR | **A page had no identity in the interface.** The panel was a view of a run's selected target, and that selection was invisible: a person moved it by clicking a chip inside the browser panel, and the Agent could not move it at all. | `open` — ADR 0022 gives a page a name, a tab strip and an opener on both sides. Not in the tree. | +| T1 | BLOCKER | **The Agent cannot create a selection, so it cannot act at all.** The tool adapter resolves the surface from the run's own selection and refuses with `browser_target_unknown` when the run has none, and the only writer of a selection is the panel. A person asking for a browser therefore got the same refusal for every action — `inspect`, `navigate` and `open_tab` alike — and `open_tab` was the one action that should have produced a selection. ADR 0019 required that both the user and the Agent can select a target; the tree carried half of it. | `closed` — `open_tab` is the one action a run with no selection may perform, because it is the action that would create the selection the gate wants (`packages/webui/src/runtime/browser/tool-adapter.ts:178`). The service opens the page and makes it the run's selection (`packages/webui/src/runtime/browser/browser-service.ts:1082`), and a person who opens a tab while the run has none gives the run that page as a documented bootstrap. Measured in a real Chrome — see [`09-acceptance-record.md`](09-acceptance-record.md), the rows "the Agent opens a tab" and "a person opens a tab while the run has no selection". | +| T2 | MAJOR | **`open_tab` created a page that nothing could address.** The isolated provider drives exactly one page and refuses every other target, so `Target.createTarget` produced a second page that the provider would not drive, would not stream, and would not accept input for. It appeared in the target list as a tab that did nothing when chosen. | `closed` — the provider holds one page per web tab, each with its own CDP session (`packages/webui/src/runtime/browser/isolated-chromium/provider.ts:199`), and `open_tab` attaches the page it creates and registers it (`packages/webui/src/runtime/browser/isolated-chromium/provider.ts:349`). Measured in a real Chrome — the rows "the Agent opens a tab" and "a second web tab in one session". | +| T3 | MAJOR | **The image stream was per run, so the person and the Agent could not be on different pages.** `watchFrames` resolved the run's selection and streamed that target, and the client dropped any frame that did not match it. A person opening another tab had no way to see it without moving the run's selection, which would have moved the Agent's work with it. | `closed` — the stream is keyed by the target (`packages/webui/src/runtime/browser/browser-service.ts:795`), and the run's selection and the tab the person is looking at are two cells with one writer each (`packages/webui/src/client/features/browser/contracts/browser-panel.ts`). Measured in a real Chrome — the rows "a person opens a tab while the Agent works" and "a person types into their own tab while the Agent works". | +| T4 | MINOR | **A page had no identity in the interface.** The panel was a view of a run's selected target, and that selection was invisible: a person moved it by clicking a chip inside the browser panel, and the Agent could not move it at all. | `closed` — a page has a name: the tab strip (`packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx:183`) shows a row of tabs, and the runtime publishes the session's web tabs as a snapshot (`packages/webui/src/runtime/browser/browser-service.ts`). The strip opens a tab and the Agent opens a tab, each on its own side. Measured in a real Chrome — the row "the Agent opens a tab, and the strip gains it as the active tab". | ### What was done -Nothing is fixed yet; this round is the reason [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) -was written, and [ADR 0019 amendment A8](../adr/0019-webui-browser-use-surfaces-and-external-browser-bridge.md) -records the change to the panel's role. The decisions are settled and the tree is not; the rules they -pin are listed as `required` in [`05-target-identity.md`](05-target-identity.md), and the work is -carried by the plan rather than by this document. +This round is the reason [ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) was +written, and [ADR 0019 amendment A8](../adr/0019-webui-browser-use-surfaces-and-external-browser-bridge.md) +records the change to the panel's role. The decisions are settled **and** the tree carries them now: +the rules ADR 0022 pins are `implemented` in [`05-target-identity.md`](05-target-identity.md), each with +the file and the line that carries it, and the behaviour is measured in a real Chrome in +[`09-acceptance-record.md`](09-acceptance-record.md). ## What this document does not establish @@ -674,10 +676,12 @@ carried by the plan rather than by this document. **every** behaviour. The handshake, the refusal, the input, the image, the DevTools command and the worker's termination are established, each on its own surface — the `chrome` surface in Chrome and the `edge` surface in Edge. - What is not established is the **Agent's** path against a real browser, and - the Full CDP switch through the runtime gate against a real browser: the - bridge carries the command and the extension's allowlist refuses what is not - in it, which is a different thing from the runtime switch. + What was not established by **this** document's run is the **Agent's** path + against a real browser, and the Full CDP switch through the runtime gate + against a real browser: the bridge carries the command and the extension's + allowlist refuses what is not in it, which is a different thing from the + runtime switch. The Agent's path is established by the tab model's own run — + see [the workspace browser tab model's acceptance](09-acceptance-record.md#the-workspace-browser-tab-models-acceptance). - That the macOS, Windows or Linux credential-store forms behave as intended. The Linux form was probed on one host and was **unavailable** there; the macOS and Windows forms are asserted through an injected command runner, not @@ -688,8 +692,14 @@ carried by the plan rather than by this document. real browser**. The tool adapter exists and forwards to the same service, and which *surface* a tool call acts on is resolved from the run's own selection rather than from a default, so a run that has selected nothing is refused - rather than being sent somewhere the person is not looking. What is not - established is the whole path exercised end to end against a real browser in - one run — and 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. + rather than being sent somewhere the person is not looking. That path is + reachable now — round 11's T1, T2 and T3 closed the three things that made it + unreachable — and the tab model's own run exercises it end to end against a + real Chrome: the Agent's `open_tab` opens a page, the run's selection points + at it, the strip gains it, and the Agent's next action lands on the page it + opened + ([`09-acceptance-record.md`](09-acceptance-record.md#the-workspace-browser-tab-models-acceptance)). + What that run does **not** establish is the same path on the **external** + bridge's surfaces: the rounds above drove the chrome and edge surfaces + through the bridge, and the tab model's run drove the isolated surface + through the runtime. diff --git a/packages/webui/docs/browser-use/09-acceptance-record.md b/packages/webui/docs/browser-use/09-acceptance-record.md index 07b1d118f..331b7513f 100644 --- a/packages/webui/docs/browser-use/09-acceptance-record.md +++ b/packages/webui/docs/browser-use/09-acceptance-record.md @@ -177,13 +177,59 @@ reason. carries the DevTools command and the extension's allowlist refuses what is not in it — that is what the `real-external` rows measured. The switch that gates it is enforced in the runtime browser service and is covered by the unit suite. -- **The Agent's browser tools were not driven against a real browser in this pass.** The tool adapter - forwards to the same browser service as the panel, and a run that has selected nothing is refused - rather than being sent somewhere the person is not looking. Since round 11 of the review findings - that refusal reaches `open_tab` as well, so the Agent could not drive a browser at all: it could not - acquire a selection, the isolated provider drove one page, and the image stream was per run. The tab - model that fixes all three is decided ([ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md)) - and is not in this tree, so nothing in this record speaks for it. +- **The Agent's browser tools were not driven against a real browser in *this* pass.** The run above + is the previous programme's, and it does not speak for the tab model. The tab model's own run + does: see [the workspace browser tab model's acceptance](#the-workspace-browser-tab-models-acceptance) + below, where the Agent's `open_tab` opens a page in a real Chrome and the run's selection points at + it. + +## The workspace browser tab model's acceptance + +The tab model ([ADR 0022](../adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md)) is a second +programme, and its acceptance is a second run: the same machine, the same installed Google Chrome, a +different suite. Its records are written to **its own** completion directory — +`.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json` — so a case from one +programme can never be read as a case from the other. + +**The run.** `linux 7.0.0-38-generic`, Google Chrome 152.0.7977.82 (installed, +`/usr/bin/google-chrome`), the `isolatedChromium` switch on, the Full CDP switch on, 2026-10-21. The +suite is `packages/webui/test/integration/workspace-browser-tab-acceptance.test.ts`, and it drives 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 isolated +provider itself. + +| Surface | Entry | Browser | Case | Settings | Result | +| --- | --- | --- | --- | --- | --- | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | the Agent opens a tab | `isolatedChromium` on; the run's selection points at the page the Agent opened, and the client's strip shows it and makes it the active tab | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | the Agent navigates after opening | `isolatedChromium` on; the page is at the requested url and the frame stream follows the page it opened | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | the Agent acts on a page it did not open | `isolatedChromium` on; the action runs on the run's selection, and a reference recorded before the page moved is refused | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a person opens a tab while the Agent works | `isolatedChromium` on; the Agent's page and the person's page are different, and each shows its own picture | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a person opens a tab while the run has no selection | `isolatedChromium` on; the run's selection becomes the person's page, so the Agent can act on it | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a person types into their own tab while the Agent works | `isolatedChromium` on; the input reaches the person's page, not the Agent's | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a person types a url in the launcher | `isolatedChromium` on; the tab becomes that page, and the url is shown read-only in the toolbar | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a person closes a web tab | `isolatedChromium` on; the page is released, and the runtime's page set follows the strip | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | back and forward | `isolatedChromium` on; the page moves through its own history, and the generation follows | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | the surface's switch is off | `isolatedChromium` on; the Agent's open_tab and the person's launcher path are both refused with browser_surface_not_authorized | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a run with no selection acts | `isolatedChromium` on; every action except open_tab is refused with browser_target_unknown | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a session ends | `isolatedChromium` on; its pages are released and no page is left behind | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a second web tab in one session | `isolatedChromium` on; both are addressable, and the strip shows both | `measured` — pass | +| `isolated-chromium` | development | Google Chrome 152.0.7977.82 (installed) | a page navigates | `isolatedChromium` on; its generation advances, and a reference recorded against the old generation is refused — on every tab, not only the first | `measured` — pass | + +**14 cases, 14 passes.** The three rows this +programme exists for are here: the Agent opening a tab, the person browsing alongside it, and the +generation fence on a page that is **not** the launch page. + +### What this run closes + +- **The Agent's browser tools were driven against a real browser.** The `open_tab` row is the tool + adapter's own path — the same adapter the Agent's tools use — opening a page in a real Chrome, and + the run's selection pointing at it. The "two cells" rows are the person typing in one page while the + Agent acts in another, in the same browser. +- **The isolated provider drives more than one page.** The "second web tab" row opens two pages in + one session and drives each on its own. +- **The image stream is per target.** The "navigates" and "back and forward" rows are the page's own + generation moving, and a reference recorded before the move being refused — on a page that is not + the launch page. ## Reproducing this diff --git a/packages/webui/docs/browser-use/README.md b/packages/webui/docs/browser-use/README.md index 0e8a8fc76..30d63113f 100644 --- a/packages/webui/docs/browser-use/README.md +++ b/packages/webui/docs/browser-use/README.md @@ -31,9 +31,9 @@ strip, the Agent's browser tools and the extension must agree on. Nothing here describes shipped, released behavior. The isolated-Chromium surface has a provider, a service and a panel path in this branch, and the extension bridge is wired into the server's upgrade path; the external handshake completes against a real Chrome and a real Edge, and the acceptance -matrix records what each run observed. The tab model that ADR 0022 decides is **not in this tree**: -its rules appear below as `required`. Where a document states a rule that has no implementing code, -it says so in place rather than in a footnote. +matrix records what each run observed. The tab model that ADR 0022 decides is **in this tree** except for +its acceptance record: the rows that have code behind them say `implemented` and name the file. Where a +document states a rule that has no implementing code, it says so in place rather than in a footnote. ## Implementation status was re-checked against the tree @@ -52,7 +52,7 @@ still says something is not implemented, that is because it is not — the per-r | `packages/webui/src/server/browser-bridge/admission.ts`, `pairing.ts`, `session.ts`, `registry.ts`, `reconnect.ts` | `implemented` and **wired** into the server's upgrade path and the browser service; target-ownership routing fixed (F4) | `02`, `03` | | `packages/webui/src/shared/operation-names.ts` | `implemented` — eight browser operations registered, including `sendBrowserCommand` (F5) | `06` | | `packages/browser-extension/` | `implemented` — MV3 service worker, `chrome.storage.local` credential, Web Crypto signing | `02`, `04` | -| The tab model of ADR 0022 | `required` — decided, not in the tree: the Agent cannot acquire a selection, the provider drives one page, and the frame stream is per run | `05`, `07` | +| The tab model of ADR 0022 | `implemented` for the strip, the surface-switcher deletion, the launcher content kind, the tab's toolbar, the tile launcher and the run-vs-tab split — the workspace's reducer carries a `launcher` content, the strip renders tool tabs and web tabs in one row, `BrowserPanelHost` is keyed by a target rather than a switchable surface, and the run's selection and the tab the person is looking at are two cells with one writer each plus one runtime-decided bootstrap; what remains is the acceptance record | `01`, `05`, `07` | The *measurements* in these documents were never affected by the tree moving. They come from the probe artifacts, not from the source. What moved was the implementation status, and that is what this pass @@ -149,7 +149,9 @@ directory: | **Full CDP** | The separate authorization to send arbitrary DevTools commands to an already-authorized surface. It never authorizes a surface on its own. | | **Generation** | The monotonic counter that makes a recorded page reference detectably stale rather than silently mis-aimed. See [`05-target-identity.md`](05-target-identity.md). | | **Run** | The Agent's browser working context, keyed by the WebUI Agent session. One session drives one browser selection. | -| **Run selection** | The page the Agent acts on. The tree has no path by which the Agent creates one, which is what the eleventh review round found and what ADR 0022 decides. | +| **Run selection** | The page the Agent acts on. The runtime's cell: the Agent writes it, the client reads it. There are two paths into it — the run's own `open_tab`, and the bootstrap below. | +| **Tab in front** | The page the person is looking at. The client's cell: the person writes it by choosing a tab, and the picture and the input binding follow it. | +| **Bootstrap** | The one write that crosses the two cells: a page a person opened while the run had no selection becomes that run's selection, decided by the runtime at the moment the page appears. | | **Tab** | A slot in the workspace panel's strip. It holds one piece of content at a time, and its content can be replaced without the tab being closed. | | **New tab page** | The launcher a newly opened tab shows: the tool views and a URL box. Choosing either replaces that tab's content. The URL box exists nowhere else. | diff --git a/packages/webui/docs/client/ownership.md b/packages/webui/docs/client/ownership.md index 4b00c8d9d..af3a6969e 100644 --- a/packages/webui/docs/client/ownership.md +++ b/packages/webui/docs/client/ownership.md @@ -17,9 +17,9 @@ owns the per-target browser state — a page, its title and URL, and the run's s `workspace` owns the session's tab strip that renders it. The process behind a terminal belongs to the service, not to the business — see *Surface facts worth knowing* below. `turn` is a business even without a screen, because its attach-to-settle lifecycle and generation-fenced identity outlive any -surface. The browser clause is `required` in one part: both businesses and the strip exist, but the -run's selection is the runtime's (`runtime/browser/browser-service.ts`), and the panel's target list -is its only writer. +surface. Both cells exist: the run's selection is the runtime's +(`runtime/browser/browser-service.ts`), and the client reads it rather than writing it — the panel's +target list reports the runtime's mark and offers no way to change it. Each business directory holds up to five roles, and the role decides what the file may do — not the directory it happens to sit in: @@ -70,10 +70,11 @@ Four rules, each with a mechanism in `client/conversation/`: fields cannot overwrite each other. A selected destination is a separate cell with its own identity and generation, so a slow answer for an earlier selection is refused at the new one. A web tab's page is the browser's instance of that split: the page's title, URL and generation - are answers keyed by the target, while the tab the person is looking at is the selected cell, so - two tabs cannot overwrite each other's page state and a frame for a tab the strip has left is - refused rather than drawn as the tab on screen (`required` — the frame stream is keyed by run - today: `runtime/browser/browser-service.ts` follows one run's selection, not a target's). + are answers keyed by the target, while the tab the person is looking at is the viewed cell + (`WebuiBrowserPanelState.viewedPage`), so two tabs cannot overwrite each other's page state and a + frame for a tab the strip has left is refused rather than drawn as the tab on screen. The frame + stream is keyed by the target (`runtime/browser/browser-service.ts` — `watchFrames`), and the panel + opens it for the viewed page. Resource release retires cached detail, revisions, in-flight records and epoch subscriptions. A monotonic revision floor prevents a released and recreated key from accepting old tokens. @@ -134,9 +135,10 @@ proposing to move this state to the server. the URL box exists only inside the new tab page — a web tab carries a toolbar (back, forward, reload, the URL read-only) instead. The run's selection and the tab the person is looking at are two cells with one writer each, with one bootstrap: a person who opens a web tab while the run has no - selection gives the run that page. The slot, the launcher and the two cells are `required`: the - tree's tabs are keyed by kind and opened from the add menu - (`client/features/workspace/projection/workspace-panel-state.ts`), so one browser tab sits among - six rather than a strip of tabs the Agent can open. The browser tab is the isolated browser's - view and the surface switcher it carries goes with it — the external browsers are driven in the - user's own windows, so the panel is not where a surface is chosen. + selection gives the run that page, decided by the runtime at the moment the page appears + (`WebuiBrowserTabOpener` — `runtime/browser/browser-service.ts` — `openWebTab`). A tab is a stable + id and its content is what changes + (`client/features/workspace/projection/workspace-panel-state.ts`), and the strip holds tool tabs and + web tabs in one row. The browser tab is the isolated browser's view and the surface switcher it + carries goes with it — the external browsers are driven in the user's own windows, so the panel is + not where a surface is chosen. diff --git a/packages/webui/docs/known-gaps.md b/packages/webui/docs/known-gaps.md index 3f1a14887..a92811591 100644 --- a/packages/webui/docs/known-gaps.md +++ b/packages/webui/docs/known-gaps.md @@ -89,33 +89,22 @@ in-process tick loop and its `/webui/scheduled-tasks.sqlite` store are `cronService` for this client, but nothing in the browser surfaces them, and no plan exists to restore the removed surface (the retirement decision is in the branch history). -## 8. Browser Use reaches one page, and the Agent cannot open it - -More of Browser Use exists than this entry used to claim. The client has a browser business -(`client/features/browser/`), mounted as the workspace's browser tab -(`client/features/workspace/ui/WorkspacePanels.tsx` mounts `BrowserPanelHost`), and Settings carries -an enabled `browser` page with the authorization switches (`BrowserSettingsPage.public.tsx`). The -runtime has the isolated Chromium provider -(`src/runtime/browser/isolated-chromium/provider.ts`), the target registry and its generation, and a -browser service composed in `runtime/harness/browser.ts` that launches that provider itself. What is -missing is what -[ADR 0022](adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides on top of them: - -- **The Agent cannot acquire a selection.** `runtime/browser/tool-adapter.ts` refuses every action of - a run whose `selectionSurfaceOf(runId)` is empty, with `browser_target_unknown` — including - `open_tab`, the action that would have given it one. The panel's target list is the only writer - (`BrowserPanelHost.public.tsx` calls `selectBrowserTarget`). -- **The isolated provider drives one page.** `#pageTargetId` names a single target and every command - refuses another; the provider's `open_tab` calls `Target.createTarget`, so it creates a page - nothing can then drive. -- **The frame stream is per run.** Subscribers are keyed by `runId` and `#frameMatchesSelection` - drops every frame that is not that run's target, so two tabs cannot each have their own picture. -- **The tab model is decided and not built.** A tab is still keyed by kind and opened from the add - menu (`workspace-panel-state.ts`), so there is no new tab page, no URL box, no in-place content - change and no per-target stream. - -A web tab can name only the isolated Chromium: the extension bridge implements no `open_tab`, so Edge -and Chrome keep the panel's target list as their entry point. +## 8. A web tab can name only the isolated Chromium + +The tab model ([ADR 0022](adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md)) is in the tree. +The workspace panel's tab strip holds tool tabs and web tabs, the isolated Chromium holds one page per +web tab, and the Agent's `open_tab` opens a page and makes it the run's selection. The four gaps this +entry used to list are closed: the Agent can acquire a selection, the provider drives a page per web +tab, the frame stream is per target, and a tab is a stable id with a mutable content. + +What remains is a boundary rather than a gap: + +- **The extension bridge implements no `open_tab`.** Edge and Chrome are reached through the + extension, which drives the person's own browser through `chrome.debugger` and cannot open a page + in it, so a web tab can name only the isolated Chromium. A person who wants a page in their own + browser uses the browser itself; the panel's web tabs are the embedded browser's. +- **The bridged surfaces have no tab model.** The strip's web tabs come from the isolated + Chromium's snapshot, and the bridge's pages are not published as tabs. ## 9. The Mini App capability is not reachable from this client diff --git a/packages/webui/docs/open-questions.md b/packages/webui/docs/open-questions.md index 32731e878..6bd3ec74f 100644 --- a/packages/webui/docs/open-questions.md +++ b/packages/webui/docs/open-questions.md @@ -68,8 +68,10 @@ not silently change title generation (`packages/local-runtime/src/runtime/runtim refuses it as stale); the stage behind it falls back to its empty-picture notice, because a closed page pushes no frames. There is no tab to lose either way: the browser tab is the panel's own `browser` kind, not a slot holding a page. -- **Alternative:** the tab falls back to the new tab page, so the slot stays usable and the URL box - comes back with it; or the tab closes with its page, so a tab always names a live page. +- **Answered.** [ADR 0022](adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md) decides it and + the tree carries it: **a closed web tab closes its page**, and the runtime's page set follows the + strip (`runtime/browser/browser-service.ts` — `closeWebTab` closes the page and forgets the + target). A tab always names a live page, and closing a page and closing a tab are one command. - **What a decision would change:** whether the strip may hold a tab whose content is absent, and whether closing the last page closes the tab — which decides whether closing a page and closing a tab are one command or two. diff --git a/packages/webui/src/client/application/port-slices.ts b/packages/webui/src/client/application/port-slices.ts index 61647c0ae..54e5136cd 100644 --- a/packages/webui/src/client/application/port-slices.ts +++ b/packages/webui/src/client/application/port-slices.ts @@ -141,6 +141,9 @@ const BROWSER_KEYS = [ "sendBrowserInput", "sendBrowserCommand", "watchBrowserFrames", + "watchBrowserWebTabs", + "openBrowserWebTab", + "closeBrowserWebTab", "listBrowserCapabilities", "getBrowserInstallationKey", ] as const satisfies readonly (keyof BrowserPort)[]; diff --git a/packages/webui/src/client/features/browser/application/browser-commands.ts b/packages/webui/src/client/features/browser/application/browser-commands.ts index 15209ec14..c1ec3953b 100644 --- a/packages/webui/src/client/features/browser/application/browser-commands.ts +++ b/packages/webui/src/client/features/browser/application/browser-commands.ts @@ -17,11 +17,12 @@ // identity it was aimed at, so the receiving side can tell a command meant // for the current page from one meant for the page that was there a moment // ago. -// 3. **The command is refused unless it names the page the panel is showing.** -// A pointer press queued while the tab navigated would otherwise land on -// whatever replaced it. Binding is checked here, at send time, against the -// state rather than at construction time, because that is the only moment -// the two can be compared. +// 3. **The command is refused unless it names the page the person is looking +// at.** A pointer press queued while the tab navigated would otherwise land on +// whatever replaced it. The page in front is the *client's* cell — the tab the +// person chose, not the run's selection — so a gesture can never be aimed at +// the Agent's page by accident, and binding is checked here, at send time, +// because that is the only moment the command and the cell can be compared. import type { WebuiBrowserAuthorization, @@ -36,7 +37,7 @@ import { type WebuiBrowserCommandRejectionReason, type WebuiBrowserCommandResult, type WebuiBrowserInputIntent, - type WebuiBrowserPanelState, + type WebuiBrowserViewedPage, } from "../contracts/browser-panel.js"; /** @@ -230,66 +231,72 @@ export function buildWebuiBrowserInputCommand( } /** - * Whether `target` still names the page the panel is showing. + * Whether `target` still names the page the person is looking at. * * Both directions of a generation mismatch are refused. A command bound to an * older generation was aimed at a page that has been replaced; one bound to a * newer generation was aimed at a page the panel has not adopted yet, so sending - * it would act on something the user cannot see. + * it would act on something the person cannot see. */ export function checkWebuiBrowserInputTarget( - selected: WebuiBrowserPanelState["selectedTarget"], + viewed: WebuiBrowserViewedPage | undefined, target: WebuiBrowserInputCommand["target"], ): WebuiBrowserCommandResult | undefined { if (!isUsableWebuiBrowserTargetId(target)) { return rejection("target_identity", "browser_command_invalid", "命令缺少可用的目标标识。"); } - if (!selected) { - return rejection("target_unknown", "browser_target_unknown", "面板当前没有选中的浏览器目标。"); + if (!viewed) { + return rejection("target_unknown", "browser_target_unknown", "面板当前没有正在查看的浏览器页面。"); } if ( - selected.surface !== target.surface || - selected.browserId !== target.browserId || - selected.targetId !== target.targetId + viewed.surface !== target.surface || + viewed.browserId !== target.browserId || + viewed.targetId !== target.targetId ) { - return rejection("target_generation_ahead", "browser_target_stale", "命令指向的目标不是面板当前显示的目标。"); + return rejection("target_generation_ahead", "browser_target_stale", "命令指向的页面不是面板当前显示的页面。"); } - if (target.generation < selected.generation) { + if (target.generation < viewed.generation) { return rejection( "target_generation_behind", "browser_target_stale", - `目标已更新到第 ${selected.generation} 代,命令仍绑定第 ${target.generation} 代。`, + `目标已更新到第 ${viewed.generation} 代,命令仍绑定第 ${target.generation} 代。`, ); } - if (target.generation > selected.generation) { + if (target.generation > viewed.generation) { return rejection( "target_generation_ahead", "browser_target_stale", - `命令绑定第 ${target.generation} 代,面板显示的是第 ${selected.generation} 代。`, + `命令绑定第 ${target.generation} 代,面板显示的是第 ${viewed.generation} 代。`, ); } return undefined; } /** - * The one call the panel makes: build the command, then refuse it unless it names - * the page currently on screen. + * The one call the panel makes: build the command against the page the person is + * looking at, then refuse it unless it names that same page. + * + * The cell is the client's — the tab the person chose — and passing it as its own + * argument rather than reading it out of the whole state is what keeps the two + * cells from merging here again: there is no field of the run's selection left in + * this signature for a gesture to be aimed at. The run's selection decides what the + * *Agent's* commands act on, which happens in the runtime and nowhere near this + * path. * * Both halves are here rather than left to the caller so the order cannot be - * wrong — a command validated against a target and then sent to a different one - * would pass both checks and still mis-aim. + * wrong — a command validated against one page and then sent to another would pass + * both checks and still mis-aim. */ export function prepareWebuiBrowserInputCommand( - state: WebuiBrowserPanelState, + viewed: WebuiBrowserViewedPage | undefined, intent: WebuiBrowserInputIntent, ): WebuiBrowserCommandResult { - const selected = state.selectedTarget; - if (!selected) { - return rejection("target_unknown", "browser_target_unknown", "面板当前没有选中的浏览器目标。"); + if (!viewed) { + return rejection("target_unknown", "browser_target_unknown", "面板当前没有正在查看的浏览器页面。"); } - const built = buildWebuiBrowserInputCommand(intent, selected); + const built = buildWebuiBrowserInputCommand(intent, viewed); if (!built.ok) return built; - const unbound = checkWebuiBrowserInputTarget(selected, built.command.target); + const unbound = checkWebuiBrowserInputTarget(viewed, built.command.target); return unbound ?? built; } diff --git a/packages/webui/src/client/features/browser/contracts/browser-panel.ts b/packages/webui/src/client/features/browser/contracts/browser-panel.ts index 01f56e594..0d0c8183c 100644 --- a/packages/webui/src/client/features/browser/contracts/browser-panel.ts +++ b/packages/webui/src/client/features/browser/contracts/browser-panel.ts @@ -15,6 +15,13 @@ // A generation that moves forward therefore replaces the entry under the same key // rather than adding a second one, which is what keeps the frame buffer bounded // to one frame per tab however long a session runs. +// +// Two cells live here, one writer each (ADR 0022): `runSelection` is the runtime's +// and the Agent writes it, `viewedPage` is the client's and the person writes it. +// Anything in this module that answers "what is the panel showing" or "what does a +// gesture land on" reads `viewedPage`, and anything that answers "what is the Agent +// working on" reads `runSelection`. The bootstrap is the one write that crosses, and +// it is the runtime's to make. import type { WebuiBrowserAuthorization, @@ -63,13 +70,39 @@ export interface WebuiBrowserTargetChange { readonly previousGeneration: WebuiBrowserTargetGeneration; } +/** + * The run's selection — the runtime's cell. + * + * The page one run acts on, and the runtime owns it: the Agent writes it through + * its own `open_tab` and its own selection operation, and the client only ever + * *reads* it, out of the target list the runtime publishes, to be able to say which + * page the Agent is working on. Nothing a person does writes this cell — not a + * click, not a tab, not a gesture. The one exception is the bootstrap, and it is + * decided by the runtime at the moment a person opens a web tab (ADR 0022). + */ +export type WebuiBrowserRunSelectionTarget = WebuiBrowserTargetId; + +/** + * The tab the person is looking at — the client's cell. + * + * This is the page a web tab holds, and the person writes it by choosing a tab; the + * Agent never writes it. It is what the panel shows and what a pointer, key or text + * gesture is bound to, which is the whole reason it is not the run's selection: an + * input aimed at the page in front must never be delivered to the page the Agent is + * working on. + */ +export type WebuiBrowserViewedPage = WebuiBrowserTargetId; + export interface WebuiBrowserPanelState { /** The four switches. Off until a read says otherwise. */ readonly authorization: WebuiBrowserAuthorization; /** The surface whose targets are listed. */ readonly surface: WebuiBrowserSurface; readonly targets: readonly WebuiBrowserTargetState[]; - readonly selectedTarget?: WebuiBrowserTargetId; + /** The runtime's cell: which page this run acts on. Read-only for the client. */ + readonly runSelection?: WebuiBrowserRunSelectionTarget; + /** The client's cell: which page the person is looking at, and what is shown. */ + readonly viewedPage?: WebuiBrowserViewedPage; /** * The newest frame per target, keyed by `webuiBrowserFrameKey`. * @@ -81,7 +114,7 @@ export interface WebuiBrowserPanelState { readonly status: WebuiBrowserPanelStatus; readonly error?: WebuiBrowserError; readonly capabilities?: readonly WebuiBrowserCapabilityEntry[]; - /** Set while the selected target's generation has moved past what was shown. */ + /** Set while the viewed page's generation has moved past what was shown. */ readonly targetChange?: WebuiBrowserTargetChange; /** How many frames were dropped as stale, so the panel can say so. */ readonly droppedFrameCount: number; @@ -169,9 +202,23 @@ export type WebuiBrowserPanelEvent = | { readonly type: "targets-loaded"; readonly list: WebuiBrowserTargetList; - readonly selected?: WebuiBrowserTargetId; + /** The runtime's answer for this run's cell. Never written by the person. */ + readonly selected?: WebuiBrowserRunSelectionTarget; } - | { readonly type: "target-selected"; readonly target: WebuiBrowserTargetId } + /** + * The page the person is looking at changed. + * + * This is the client's cell and the person's write: the strip holds the tabs and + * hands the host the tab in front, so the host reports which page that is. It + * moves the run's selection by nothing — the two cells are separate (ADR 0022). + */ + | { readonly type: "page-viewed"; readonly target: WebuiBrowserViewedPage } + /** + * The run's page moved on. + * + * The runtime's cell only. It never moves the page in front: the Agent working + * on its own tab is not a reason to change which tab the person is looking at. + */ | { readonly type: "target-changed"; readonly target: WebuiBrowserTargetId } | { readonly type: "frame"; readonly frame: WebuiBrowserImageFrame } | { readonly type: "failed"; readonly error: WebuiBrowserError } diff --git a/packages/webui/src/client/features/browser/contracts/browser-port.ts b/packages/webui/src/client/features/browser/contracts/browser-port.ts index 83e60b085..cf3119d4f 100644 --- a/packages/webui/src/client/features/browser/contracts/browser-port.ts +++ b/packages/webui/src/client/features/browser/contracts/browser-port.ts @@ -30,6 +30,9 @@ import type { WebuiBrowserTargetId, WebuiBrowserTargetList, WebuiBrowserTargetState, + WebuiBrowserWebTabClose, + WebuiBrowserWebTabOpen, + WebuiBrowserWebTabSnapshot, WebuiBridgeBrowserSurface, } from "../../../../shared/contracts/browser.js"; @@ -199,18 +202,65 @@ export interface BrowserPort { ) => Promise<{ readonly accepted: true; readonly result?: unknown }>; /** - * The live image stream for one surface, carrying the named run's own target. + * The live image stream for one **target**. + * + * The stream is keyed by the target rather than by the run: the client asks + * for the page it is showing, so two tabs get their own pictures and the + * person and the Agent can work on different pages at the same time. * * Returns the disposer, matching `TerminalPort.watchTerminal`, because the * subscription outlives the render that opened it and something has to close - * it when the panel goes away. Switching runs closes this one and opens the - * active run's: a frame belonging to another run's target is not this - * stream's to show. + * it when the panel goes away. */ readonly watchBrowserFrames?: ( - request: WebuiBrowserRunScope & { readonly surface: WebuiBrowserSurface }, + request: { readonly surface: WebuiBrowserSurface; readonly target: WebuiBrowserTargetId }, onFrame: (frame: WebuiBrowserImageFrame) => void, ) => () => void; + /** + * The session's web tabs, as a snapshot, on a stream. + * + * The runtime owns the record of what the session has open, and this is how + * the client learns it: the strip renders what arrives here rather than + * keeping a copy of its own, because two copies of "what is open" disagree + * exactly when a tab is closed. + * + * Returns the disposer, like `watchBrowserFrames`: the subscription outlives + * the render that opened it. + */ + readonly watchBrowserWebTabs?: ( + request: WebuiBrowserRunScope & { readonly surface: WebuiBrowserSurface }, + onSnapshot: (snapshot: WebuiBrowserWebTabSnapshot) => void, + ) => () => void; + /** + * Opens a page and answers with the page's identity. + * + * The person's half of the tab model: the new tab page's URL box calls this, + * and the answer is what the tab binds to. It is the same `open_tab` the + * Agent's tools perform, so a page typed by hand and a page opened by the + * Agent go through the same surface switch and the same refusals — the panel + * does not get a browser of its own by typing into its own launcher. + * + * `runId` is sent when the workspace knows its session, and that is what lets the + * runtime apply the one bootstrap: the page joins the run's selection when the run + * has none at that moment, and a run already working on a page keeps it. The + * launcher does not decide that — it cannot see the run's selection, and deciding + * it on the client would make the person a standing writer of the Agent's cell. + * Without a `runId` the page is recorded in the panel's own cell, which is the + * whole answer when there is no run behind the workspace at all. + */ + readonly openBrowserWebTab?: ( + request: WebuiBrowserRunScope & { readonly url: string }, + ) => Promise; + /** + * Closes the page a web tab is bound to. + * + * The tab names the target it holds, so the runtime closes the page that + * tab holds. The tab leaves the strip either way — a tab that is still + * published after it was closed would be a tab the strip cannot get rid of. + */ + readonly closeBrowserWebTab?: ( + request: { readonly target: WebuiBrowserTargetId }, + ) => Promise; readonly listBrowserCapabilities?: () => Promise; /** * Reads one extension-reached surface's installation key so the person can diff --git a/packages/webui/src/client/features/browser/projection/browser-state.ts b/packages/webui/src/client/features/browser/projection/browser-state.ts index ae80e37bc..54cc4395d 100644 --- a/packages/webui/src/client/features/browser/projection/browser-state.ts +++ b/packages/webui/src/client/features/browser/projection/browser-state.ts @@ -6,6 +6,14 @@ // // Two decisions carry most of the weight. // +// **Two cells, one writer each.** `viewedPage` is the client's: the person writes +// it by choosing a tab, and everything this panel *shows* or *forwards input to* +// reads it. `runSelection` is the runtime's: the Agent writes it, the client only +// mirrors the mark the runtime puts on the target list, and it decides which page +// the panel reports as the Agent's. No branch below writes one from the other's +// facts — the bootstrap that hands a run the page a person opened is decided by the +// runtime at the moment the page appears, not here. +// // **The frame buffer is a record, keyed by tab.** A live stream pushes a frame // per visual change; an append-only list would grow for as long as the session // ran. Keeping one entry per key makes the bound structural rather than a @@ -14,7 +22,7 @@ // **A frame that is older than what the panel is showing is dropped.** The frame // carries its target's generation, and a page that navigated keeps its tab id but // gets a new generation — so a frame from the previous page is a picture of -// something the user has already moved away from. Displaying it would show one +// something the person has already moved away from. Displaying it would show one // page while the Agent drives another, which is the failure this whole business // exists to prevent (ADR 0019). Dropping is counted rather than silent, because a // panel that quietly stops updating and a panel that is dropping frames look @@ -37,6 +45,7 @@ import { type WebuiBrowserPanelEvent, type WebuiBrowserPanelState, type WebuiBrowserPanelStatus, + type WebuiBrowserTargetChange, } from "../contracts/browser-panel.js"; export const initialWebuiBrowserPanelState: WebuiBrowserPanelState = { @@ -72,18 +81,20 @@ function withoutFrame( /** * The status the state implies. * - * Derived rather than stored on each branch so "ready" means one thing: the - * selected target currently has a frame that belongs to the generation being - * shown. Anything else that claims `ready` is a panel displaying a stale page. + * Derived rather than stored on each branch so "ready" means one thing: the page + * the person is looking at currently has a frame that belongs to the generation + * being shown. Anything else that claims `ready` is a panel displaying a stale + * page. The run's selection does not enter into it — it says which page the Agent + * is on, and the Agent's page is not what this panel is showing. */ function deriveStatus(state: WebuiBrowserPanelState): WebuiBrowserPanelStatus { if (state.error) return "error"; - const selected = state.selectedTarget; - if (!selected) { + const viewed = state.viewedPage; + if (!viewed) { if (state.status === "loading") return "loading"; return state.targets.length === 0 ? state.status : "empty"; } - return frameHeld(state, webuiBrowserFrameKey(selected)) ? "ready" : "loading"; + return frameHeld(state, webuiBrowserFrameKey(viewed)) ? "ready" : "loading"; } function markSelection( @@ -96,6 +107,43 @@ function markSelection( })); } +/** + * Adopts the newer generation the list reports for the page in front. + * + * The list is where a page that navigated shows up first: the tab keeps its id and + * gains a generation, so a buffered picture of the previous page is a picture of + * something the person has already moved away from. The move is reported rather + * than applied quietly, because a panel that silently keeps showing the old page + * and a panel that is waiting for the new one look identical from the outside. + */ +function adoptNewerGeneration( + state: WebuiBrowserPanelState, + page: WebuiBrowserTargetId | undefined, +): { + readonly page?: WebuiBrowserTargetId; + readonly frames: WebuiBrowserPanelState["frames"]; + readonly targetChange?: WebuiBrowserTargetChange; +} { + if (!page) return { page, frames: state.frames }; + const listed = state.targets.find( + (candidate) => webuiBrowserFrameKey(candidate.id) === webuiBrowserFrameKey(page), + ); + if (!listed) { + // The page is no longer in the list. It is cleared rather than moved: a + // fallback to another page is exactly the substitution the ADR forbids, and an + // empty panel says what happened where a different page would not. + return { frames: withoutFrame(state, page), targetChange: undefined }; + } + if (listed.id.generation > page.generation) { + return { + page: listed.id, + frames: withoutFrame(state, listed.id), + targetChange: { target: listed.id, previousGeneration: page.generation }, + }; + } + return { page, frames: state.frames }; +} + export function reduceWebuiBrowserPanelState( state: WebuiBrowserPanelState, event: WebuiBrowserPanelEvent, @@ -110,106 +158,97 @@ export function reduceWebuiBrowserPanelState( case "surface-changed": return state.surface === event.surface ? state - : { ...state, surface: event.surface, targets: [], selectedTarget: undefined, frames: {}, status: "idle" }; + : { ...state, surface: event.surface, targets: [], runSelection: undefined, viewedPage: undefined, frames: {}, status: "idle" }; case "targets-requested": return { ...state, status: "loading", error: undefined }; case "targets-loaded": { const surfaceChanged = event.list.surface !== state.surface; - // A new list for the same surface keeps the selection across refreshes; a - // list for a different surface starts over, because a target id from one - // surface means nothing in another. - let selected = surfaceChanged ? event.selected : (event.selected ?? state.selectedTarget); - let frames = surfaceChanged ? {} : state.frames; - let targetChange = surfaceChanged ? undefined : state.targetChange; - - const listed = selected - ? event.list.targets.find( - (candidate) => webuiBrowserFrameKey(candidate.id) === webuiBrowserFrameKey(selected as WebuiBrowserTargetId), - ) - : undefined; - - if (selected && listed && listed.id.generation > selected.generation) { - // The tab is the same one, on a newer page. The buffered picture is of - // the page that is gone, so it goes with it, and the panel says so - // instead of pretending the old page is still there. - targetChange = { target: listed.id, previousGeneration: selected.generation }; - frames = withoutFrame({ ...state, frames }, listed.id); - selected = listed.id; - } else if (selected && !listed && !surfaceChanged) { - // The selected tab is no longer in the list. The selection is cleared - // rather than moved: silently promoting another tab is exactly the - // fallback the ADR forbids, and an empty panel says what happened where - // a different page would not. - frames = withoutFrame({ ...state, frames }, selected); - selected = undefined; - targetChange = undefined; - } - + // The runtime's cell, mirrored. A new list for the same surface keeps the + // run's selection across refreshes; a list for a different surface starts + // over, because a target id from one surface means nothing in another. + const runSelection = surfaceChanged ? event.selected : (event.selected ?? state.runSelection); + // The client's cell only survives a list for the same surface, and the + // comparison is made against the list that just arrived rather than the one + // held before: that list is where a replaced page first shows up. + const base: WebuiBrowserPanelState = surfaceChanged + ? { ...state, targets: event.list.targets, viewedPage: undefined, frames: {} } + : { ...state, targets: event.list.targets }; + const viewed = adoptNewerGeneration(base, base.viewedPage); const next: WebuiBrowserPanelState = { ...state, surface: event.list.surface, - targets: markSelection(event.list.targets, selected), - selectedTarget: selected, - frames, + // The marked list is the runtime's answer about its own cell, so it is + // taken as it arrives rather than recomputed here. + targets: markSelection(event.list.targets, runSelection), + runSelection, + viewedPage: viewed.page, + frames: viewed.frames, status: event.list.targets.length === 0 ? "empty" : state.status, error: undefined, - ...(targetChange ? { targetChange } : {}), + targetChange: viewed.targetChange, }; return { ...next, status: deriveStatus(next) }; } - case "target-selected": { + case "page-viewed": { + // The client's cell, written by the person choosing a tab. It moves + // nothing else: the run's selection is the runtime's, and the picture and + // the input both follow this page instead. if (!isUsableWebuiBrowserTargetId(event.target)) return state; + const previous = state.viewedPage; + if (previous && isSameWebuiBrowserTarget(previous, event.target)) return state; + const moved = previous + ? { + targetChange: { + target: event.target, + previousGeneration: previous.generation, + }, + // A different tab keeps its own buffered frame — the buffer is one + // frame per tab. The same tab on a newer generation does not: that + // picture is of the page that is gone. + frames: isSameWebuiBrowserTargetSlot(previous, event.target) + ? withoutFrame(state, event.target) + : state.frames, + } + : {}; const next: WebuiBrowserPanelState = { ...state, - selectedTarget: event.target, - targetChange: undefined, + surface: event.target.surface, + viewedPage: event.target, error: undefined, - targets: markSelection(state.targets, event.target), + ...(previous ? moved : { targetChange: undefined }), }; - // Derived rather than set, so selecting a tab the panel has no frame for - // reads as "waiting for the first frame" rather than as "ready". + // Derived rather than set, so a tab the panel has no frame for yet reads as + // "waiting for the first frame" rather than as "ready". return { ...next, status: deriveStatus(next) }; } case "target-changed": { - // The active Agent run moved its own target (ADR decision 4). The panel - // follows it; it does not negotiate. + // The runtime's cell: the run's own page moved on. It moves the run's + // selection and nothing else — the page the person is looking at is not the + // run's, so a move here must not disturb the picture or the input binding. + // A different tab is not "this page moved on" and is ignored rather than + // adopted: the run's selection is the runtime's to report, and the list it + // publishes on the next read is the authoritative answer anyway. const target = event.target; if (!isUsableWebuiBrowserTargetId(target)) return state; - const previous = state.selectedTarget; - const next: WebuiBrowserPanelState = { - ...state, - surface: target.surface, - selectedTarget: target, - error: undefined, - ...(previous - ? { - targetChange: { target, previousGeneration: previous.generation }, - frames: isSameWebuiBrowserTargetSlot(previous, target) - ? previous.generation === target.generation - ? state.frames - : withoutFrame(state, target) - : withoutFrame(state, previous), - } - : {}), - }; - return { ...next, status: deriveStatus(next) }; + const previous = state.runSelection; + if (!previous || !isSameWebuiBrowserTargetSlot(previous, target)) return state; + return { ...state, surface: target.surface, runSelection: target, error: undefined }; } case "run-changed": { // A run's selection is that run's. When the active run changes, the panel - // stops claiming to show the previous run's target: the frames stay - // buffered per tab (so a new run that picks the same tab shows it at - // once), but the selection, the marked tabs and the "what moved on" note - // are cleared and wait for the new run's own answer. - if (state.selectedTarget === undefined && state.targetChange === undefined) return state; + // stops reporting the previous run's selection: the marked tabs are cleared + // and wait for the new run's own answer. What is *not* cleared is the + // page the person is looking at and the frames buffered for it — neither + // belongs to the run that just left. + if (state.runSelection === undefined) return state; const next: WebuiBrowserPanelState = { ...state, - selectedTarget: undefined, - targetChange: undefined, + runSelection: undefined, targets: markSelection(state.targets, undefined), }; return { ...next, status: deriveStatus(next) }; @@ -219,7 +258,7 @@ export function reduceWebuiBrowserPanelState( const frame = event.frame; if (!isUsableWebuiBrowserTargetId(frame.target)) return dropped(state); const key = webuiBrowserFrameKey(frame.target); - const shown = state.selectedTarget; + const shown = state.viewedPage; // The panel is showing a newer generation of this very tab: the frame is a // picture of a page that no longer exists. if (shown && webuiBrowserFrameKey(shown) === key && frame.target.generation < shown.generation) { @@ -259,20 +298,20 @@ function dropped(state: WebuiBrowserPanelState): WebuiBrowserPanelState { export function selectWebuiBrowserFrame( state: WebuiBrowserPanelState, ): WebuiBrowserPanelState["frames"][string] | undefined { - const selected = state.selectedTarget; - if (!selected) return undefined; - const key = webuiBrowserFrameKey(selected); + const viewed = state.viewedPage; + if (!viewed) return undefined; + const key = webuiBrowserFrameKey(viewed); return frameHeld(state, key) ? state.frames[key] : undefined; } /** * The target the live image stream should be open for. * - * It is the run's selected target, and nothing else. A run that has selected - * nothing has no page to stream, so the answer is `undefined` and the panel - * opens no subscription — the server would refuse one anyway, and a - * subscription that can never deliver a frame is a subscription that leaves - * the panel blank. + * It is the page the person is looking at and nothing else — the client's cell, not + * the run's selection. A tab with no page in front has nothing to stream, so the + * answer is `undefined` and the panel opens no subscription: a subscription for a + * page the person is not looking at delivers a picture of somebody else's work, + * and one that can never deliver a frame leaves the panel blank anyway. * * The decision is a pure function so it can be tested without a DOM renderer; * the client test environment has none. @@ -280,7 +319,7 @@ export function selectWebuiBrowserFrame( export function selectWebuiBrowserStreamTarget( state: WebuiBrowserPanelState, ): WebuiBrowserTargetId | undefined { - return state.selectedTarget; + return state.viewedPage; } /** @@ -297,23 +336,29 @@ export function webuiBrowserStreamTargetKey( return `${target.browserId}:${target.targetId}:${target.generation}`; } -export function selectWebuiBrowserSelectedTarget( +/** + * The page the person is looking at, as the target list knows it. + * + * The list is where the title and url come from, so this is what the view reads to + * name the page it is showing and whether input may be forwarded to it. + */ +export function selectWebuiBrowserViewedTarget( state: WebuiBrowserPanelState, ): WebuiBrowserTargetState | undefined { - const selected = state.selectedTarget; - if (!selected) return undefined; - return state.targets.find((candidate) => isSameWebuiBrowserTarget(candidate.id, selected)); + const viewed = state.viewedPage; + if (!viewed) return undefined; + return state.targets.find((candidate) => isSameWebuiBrowserTarget(candidate.id, viewed)); } /** - * The target a run has selected, as the host's target list reports it. + * The run's selection, as the target list reports it. * - * The service marks exactly one target per run, so this is how the panel learns + * The service marks exactly one target per run, so this is how the client learns * which page the *active run* is driving without the run having to announce it — * and `undefined` when that run has selected nothing, which is the answer for a - * panel that is showing a browser with no run behind it. Reading the flag - * instead of the panel's own selection is what makes the panel follow the run - * rather than keep showing whatever it happened to be showing. + * panel that is showing a browser with no run behind it. It is the runtime's cell + * being read, never written: the person cannot hand the Agent a page by clicking + * one here, and the strip's tab choice does not move it either. */ export function selectedWebuiBrowserRunTarget( list: WebuiBrowserTargetList, diff --git a/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx b/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx index 559ace84b..2fa191bf7 100644 --- a/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx +++ b/packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx @@ -23,6 +23,12 @@ // on screen and the page the Agent drives are the same page — in the state, // where it can be asserted without a browser. // +// The panel used to carry a surface switcher at the top of the body: three +// radios, one for each of the surfaces the service knows about. That switcher +// is gone now — Edge and Chrome are reached in the person's own windows, and +// the only surface the panel sees is the isolated Chromium. The tab strip +// decides what target is on screen, and this panel is that target's picture. +// // `.public` because the workspace panel mounts it: the Browser business publishes // the panel and the workspace business renders it. Nothing else in this directory // is mounted from outside. @@ -35,12 +41,10 @@ import { type ReactElement, } from "react"; import { - WEBUI_BROWSER_SURFACES, isWebuiBrowserSurfaceAuthorized, type WebuiBrowserError, type WebuiBrowserInputCommand, type WebuiBrowserSurface, - type WebuiBrowserTargetId, } from "../../../../shared/contracts/browser.js"; import { readWebuiCapability, type WebuiCapabilityView } from "../../../../shared/capability-manifest.js"; import type { BrowserPort } from "../contracts/browser-port.js"; @@ -56,7 +60,7 @@ import { } from "../application/browser-commands.js"; import { selectWebuiBrowserFrame, - selectWebuiBrowserSelectedTarget, + selectWebuiBrowserViewedTarget, webuiBrowserFrameDataUrl, } from "../projection/browser-state.js"; @@ -77,7 +81,7 @@ const PANEL_COPY = { inputHint: "点击画面后可在此输入文字。", textInputLabel: "发送到浏览器的文字", noTargets: "当前浏览器没有可显示的标签页。", - unauthorized: "该浏览器未授权。请在设置的「浏览器」页中打开对应的浏览器开关。", + unauthorized: "隔离浏览器未授权。请在设置的「浏览器」页中打开对应的浏览器开关。", absent: "当前运行时没有浏览器能力,浏览器面板不可用。", notReady: "浏览器能力尚未就绪(例如扩展未连接),面板暂时无法显示页面。", unknown: "当前运行时没有报告浏览器能力,面板按未授权处理。", @@ -97,8 +101,6 @@ export interface BrowserPanelProps { readonly state?: WebuiBrowserPanelState; /** The handshake manifest, so the panel can say *why* a family is unusable. */ readonly capabilities?: WebuiCapabilityView; - readonly onSelectTarget?: (target: WebuiBrowserTargetId) => void; - readonly onSurfaceChange?: (surface: WebuiBrowserSurface) => void; /** Receives only commands that passed validation and the generation binding. */ readonly onSendInput?: (command: WebuiBrowserInputCommand) => void; /** Receives every refusal, so a gesture that went nowhere is visible. */ @@ -109,8 +111,6 @@ export function BrowserPanel({ port, state, capabilities, - onSelectTarget, - onSurfaceChange, onSendInput, onRejected, }: BrowserPanelProps): ReactElement { @@ -120,15 +120,20 @@ export function BrowserPanel({ // unrecognised format or a payload outside the base64 alphabet gets no // element, rather than an element whose `src` guesses what it was given. const frameSrc = webuiBrowserFrameDataUrl(frame); - const selected = state ? selectWebuiBrowserSelectedTarget(state) : undefined; + // The client's cell: the page this tab holds. Everything below — the picture, the + // alt text, the input binding — follows it, and none of it reads the run's + // selection, which is a different page and belongs to the Agent. + const viewed = state ? selectWebuiBrowserViewedTarget(state) : undefined; const authorized = state ? isWebuiBrowserSurfaceAuthorized(state.authorization, surface) : false; const capability = readWebuiCapability(capabilities, WEBUI_BROWSER_CAPABILITY_NAME); - const inputEnabled = Boolean(selected) && authorized && Boolean(port?.sendBrowserInput); + const inputEnabled = Boolean(viewed) && authorized && Boolean(port?.sendBrowserInput); const submit = useCallback( (intent: WebuiBrowserInputIntent) => { - if (!state) return; - const result = prepareWebuiBrowserInputCommand(state, intent); + // Bound to the page in front, at send time: a gesture typed while the strip + // moved to another tab lands on the tab the person is looking at, or is + // refused — it is never re-aimed at whatever the Agent is doing. + const result = prepareWebuiBrowserInputCommand(state?.viewedPage, intent); // The refusal path is the one that matters: a gesture that could not be // aimed has to be visible, because the alternative is a click that appears // to do nothing. @@ -194,26 +199,6 @@ export function BrowserPanel({

{PANEL_COPY.title}

-
- {WEBUI_BROWSER_SURFACES.map((candidate) => { - const allowed = state ? isWebuiBrowserSurfaceAuthorized(state.authorization, candidate) : false; - return ( - - ); - })} -
{capability === "absent" ? ( @@ -265,6 +250,13 @@ export function BrowserPanel({ ) : null}
+ {/* + The session's pages, marked with the one the Agent is working on. It is a + report, not a control: the run's selection is the runtime's cell and the + person cannot write it from here (ADR 0022) — the strip owns which tab is + in front, and the one bootstrap in the runtime's hands. Which tab to look + at is chosen above, not here. + */}
{targets.map((target) => (
  • - +
  • ))} @@ -309,12 +299,12 @@ export function BrowserPanel({ data-frame-sequence={frame?.sequence} data-target-generation={frame?.target.generation} src={frameSrc} - alt={selected ? `${selected.title || selected.url} 的浏览器画面` : PANEL_COPY.frameLabel} + alt={viewed ? `${viewed.title || viewed.url} 的浏览器画面` : PANEL_COPY.frameLabel} draggable={false} /> ) : (

    - {frame ? PANEL_COPY.frameUnrenderable : selected ? PANEL_COPY.frameEmpty : PANEL_COPY.frameNoTarget} + {frame ? PANEL_COPY.frameUnrenderable : viewed ? PANEL_COPY.frameEmpty : PANEL_COPY.frameNoTarget}

    )} {/* The input surface covers the picture rather than sitting beside it: diff --git a/packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx b/packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx index 1590e78f3..81a3e56c8 100644 --- a/packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx +++ b/packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx @@ -12,10 +12,32 @@ // rules are right: a foreign business must not be able to construct another // business's state. It may only ask the owner to. // +// The host is now keyed by **a target**, not by a switchable surface. Edge and +// Chrome are reached in the person's own windows, and the only surface the +// host is ever pointed at is the isolated Chromium: a web tab is the +// isolated Chromium's page, and the tab decides which target the host is on. +// The surface-switcher that used to sit at the top of `BrowserPanel` is gone. +// // The authorization switches are not here any more: they are settings, and they // live in the Settings page now. What is left in the panel is the answer — // whether the surface on screen is authorized — and it comes from the browser // family's owner, which the Settings page writes to. +// +// The **toolbar** is here too, above the panel. It is chrome for one page, and +// the page is this business's: the strip owns the row of tabs and nothing +// between them, and a workspace component that drew the chrome of a foreign +// business's page would be the dependency rule inverted. So the toolbar is a +// view over what the tab holds — its target and the url the runtime published +// with it — and both are values, not authority: the host drives the port and +// the strip never does. +// +// **Two cells meet here.** The `target` prop is the client's: the tab the person +// is looking at, which the strip chooses and this host binds every display and +// every gesture to. The run's selection is the runtime's, and the host only +// reads it — out of the target list the runtime publishes — so that the panel +// can say which page the Agent is on. Nothing a person does in this component +// writes it, and the one write that crosses cells is the runtime's bootstrap +// (ADR 0022). import { useCallback, @@ -27,6 +49,7 @@ import { } from "react"; import type { + WebuiBrowserCommandRequest, WebuiBrowserError, WebuiBrowserInputCommand, WebuiBrowserSurface, @@ -40,7 +63,10 @@ import { } from "../bindings/use-browser-authorization.public.js"; import { prepareWebuiBrowserInputCommand } from "../application/browser-commands.js"; import type { BrowserPort } from "../contracts/browser-port.js"; -import type { WebuiBrowserInputIntent } from "../contracts/browser-panel.js"; +import { + isUsableWebuiBrowserTargetId, + type WebuiBrowserInputIntent, +} from "../contracts/browser-panel.js"; import { initialWebuiBrowserPanelState, reduceWebuiBrowserPanelState, @@ -50,15 +76,52 @@ import { } from "../projection/browser-state.js"; import { BrowserPanel } from "./BrowserPanel.public.js"; +/** + * The commands a web tab's toolbar can send. + * + * Three, and no more. They are the page's own history and its own re-read — + * what a tab does to the page it holds. Opening a url is not one of them: the + * address box exists in the new tab page, which is the only place a person + * types a url. + */ +export const WEBUI_BROWSER_TOOLBAR_COMMANDS = ["back", "forward", "reload"] as const; + +export type WebuiBrowserToolbarCommand = (typeof WEBUI_BROWSER_TOOLBAR_COMMANDS)[number]; + +const TOOLBAR_COPY = { + label: "网页工具栏", + urlLabel: "当前网址", + commands: { + back: "后退", + forward: "前进", + reload: "刷新", + }, +} as const; + export interface BrowserPanelHostProps { /** - * The surface the panel is showing. + * The target the panel is showing — the tab the person is looking at. + * + * The strip passes the web tab's target here, and the host reads the + * surface off the target. This is the client's cell: the picture, the toolbar + * and every pointer, key and text gesture are bound to this page and to no + * other, so a person working in one tab never types into the Agent's page + * (ADR 0022). Edge and Chrome are no longer reachable from the + * panel — they are driven in the person's own windows — so a target that + * names another surface is left to show the unauthorized notice rather + * than being hidden by a switcher. + */ + readonly target?: WebuiBrowserTargetId; + /** + * The url of the page the target is on, as the runtime last reported it. * - * The isolated Chromium by default; the person's own Edge or Chrome once the - * panel is pointed at them. Changing it re-reads the targets and re-opens the - * live image stream for the new surface. + * The toolbar shows this as text and never as a field: the only address box + * in the workspace is the new tab page's. It arrives as a prop for the same + * reason the target does — the runtime publishes the url with the target, and + * the host reads both together so the toolbar and the frame describe the same + * navigation rather than a pair that has drifted. */ - readonly surface?: WebuiBrowserSurface; + readonly url?: string; /** * The port. * @@ -72,17 +135,20 @@ export interface BrowserPanelHostProps { */ readonly port?: BrowserPort; /** - * The run whose selection the panel is showing, when the host knows it. + * The run whose selection the panel is reporting, when the host knows it. * * Normally left out: the panel reads the active run itself (ADR 0019 — the - * panel follows the active run's target), so a caller that happens to know it - * does not have to keep saying so. + * panel follows the active run), so a caller that happens to know it does not + * have to keep saying so. It scopes the *runtime's* cell and the fences that + * are scoped with it; it is not what the panel shows, which is the tab's own + * target. */ readonly runId?: string; } export function BrowserPanelHost({ - surface: surfaceProp, + target: targetProp, + url: urlProp, port: portProp, runId: runIdProp, }: BrowserPanelHostProps): ReactElement { @@ -92,7 +158,11 @@ export function BrowserPanelHost({ reduceWebuiBrowserPanelState, initialWebuiBrowserPanelState, ); - const surface = surfaceProp ?? state.surface; + // The target the strip or the run handed us is the surface-bearing one; the + // state still carries a `surface` because the reducer and the stream key + // both want it, but the only writes it gets now are derived from the + // target's surface, never from a switcher click. + const surface = targetProp?.surface ?? state.surface; const activeRunId = useWebuiBrowserRunId(); const runId = runIdProp ?? activeRunId; const runScope = useMemo(() => (runId ? { runId } : {}), [runId]); @@ -112,13 +182,11 @@ export function BrowserPanelHost({ // a screencast running for a surface nobody is looking at. const subscribedSurface = useRef(undefined); - // The run the stream was opened for. The stream carries one run's target, so - // the active run changing means the old one has to be closed and the new one's - // opened — otherwise the panel would keep showing the previous run's page. - const subscribedRunId = useRef(undefined); - /** The target the open subscription is attached to. */ + // The target the open subscription is attached to. The stream is keyed by + // the target rather than by the run, so a run change does not reopen it — + // the run decides what the Agent's commands act on, and the panel decides + // what it shows. const subscribedTarget = useRef(undefined); - // The run the panel is on, kept where a read that is still in flight can see // it. The effect below is what moves it, so it is current before any read for // the new run starts. @@ -175,12 +243,24 @@ export function BrowserPanelHost({ }; }, [port]); + // A target's surface moves the panel to that surface — the same effect the + // old switcher used to drive, only the input is a target rather than a click. useEffect(() => { if (surface !== state.surface) { dispatch({ type: "surface-changed", surface }); } }, [surface, state.surface]); + // The client's cell, written here and only here. The strip hands the host the + // tab that is in front, and this is what tells the panel which page that is — so + // the picture, the alt text and the input binding all follow the tab the person + // chose. The run's selection is a different cell (ADR 0022) and this effect does + // not touch it: a person switching tabs never moves the Agent off its page. + useEffect(() => { + if (!isUsableWebuiBrowserTargetId(targetProp)) return; + dispatch({ type: "page-viewed", target: targetProp }); + }, [targetProp]); + // The panel follows the active run. A run's selection belongs to that run, so // when the active run changes the previous one is forgotten rather than shown // as if it were still the run's target — and this runs before the read below, @@ -195,58 +275,48 @@ export function BrowserPanelHost({ void refreshTargets(surface); }, [port, refreshTargets, surface]); - // The live image stream is opened for the run's **selected** target. + // The live image stream is opened for the **target** the panel is showing. // - // The server refuses a stream for a run that has selected nothing, so the - // subscription is tied to the selection rather than to the panel's lifetime: - // when the person picks a target the stream opens, and when the run's target - // moves the previous subscription is closed and a new one opened for the new - // target. A subscription opened before there was anything to show would never - // deliver a frame, and the panel would stay blank until something else - // changed. - const selectedTargetKey = webuiBrowserStreamTargetKey( - selectWebuiBrowserStreamTarget(state), - ); + // The stream is keyed by the target rather than by the run, so two tabs get + // their own pictures and the person and the Agent can work on different + // pages at the same time. The subscription is tied to the target rather than + // to the panel's lifetime: a subscription opened before there was anything + // to show would never deliver a frame, and the panel would stay blank until + // something else changed. A page that navigates gets a new generation, and + // the subscription follows it. + const streamTarget = selectWebuiBrowserStreamTarget(state); + const streamTargetKey = webuiBrowserStreamTargetKey(streamTarget); useEffect(() => { if (!port?.watchBrowserFrames) return; - if (!selectedTargetKey) { - // Nothing is selected, so there is no page to stream. + if (!streamTarget || !streamTargetKey) { + // Nothing is shown, so there is no page to stream. return; } if ( subscribedSurface.current === surface && - subscribedRunId.current === runId && - subscribedTarget.current === selectedTargetKey + subscribedTarget.current === streamTargetKey ) { return; } subscribedSurface.current = surface; - subscribedRunId.current = runId; - subscribedTarget.current = selectedTargetKey; - const stop = port.watchBrowserFrames({ surface, ...runScope }, (frame) => { + subscribedTarget.current = streamTargetKey; + const stop = port.watchBrowserFrames({ surface, target: streamTarget }, (frame) => { dispatch({ type: "frame", frame }); }); return () => { stop(); subscribedSurface.current = undefined; - subscribedRunId.current = undefined; subscribedTarget.current = undefined; }; - }, [port, runScope, selectedTargetKey, surface, runId]); - - const onSelectTarget = useCallback( - (target: WebuiBrowserTargetId) => { - dispatch({ type: "target-selected", target }); - void port?.selectBrowserTarget?.({ target, ...runScope }).catch((error: unknown) => { - dispatch({ type: "failed", error: toBrowserError(error) }); - }); - }, - [port, runScope], - ); + }, [port, streamTargetKey, streamTarget, surface]); const onSendInput = useCallback( (command: WebuiBrowserInputCommand) => { + // The command already names the page in front — the target was bound to it + // when the gesture was built — so this is a send, not a selection. The run + // scope rides along only because the runtime's own fence is scoped; nothing + // here reads or moves the run's selection. void port ?.sendBrowserInput?.({ command, ...runScope }) .catch((error: unknown) => { @@ -260,22 +330,121 @@ export function BrowserPanelHost({ dispatch({ type: "failed", error }); }, []); + // The toolbar's three commands go through the browser family's own command + // operation — the same one a run's tools use, with the page named so that the + // runtime acts on the tab rather than on whichever page a selection points at. + const onToolbarCommand = useCallback( + (command: WebuiBrowserToolbarCommand) => { + if (!targetProp) return; + void webuiBrowserToolbarRun({ + send: port?.sendBrowserCommand, + target: targetProp, + command, + }).catch((error: unknown) => { + dispatch({ type: "failed", error: toBrowserError(error) }); + }); + }, + [port, targetProp], + ); + const buildInput = useMemo( () => - (intent: WebuiBrowserInputIntent) => prepareWebuiBrowserInputCommand(state, intent), + // Bound to the page the person is looking at, and read at send time from + // that cell alone: a gesture can never be re-aimed at the Agent's page. + (intent: WebuiBrowserInputIntent) => + prepareWebuiBrowserInputCommand(state.viewedPage, intent), [state], ); return (
    - dispatch({ type: "surface-changed", surface: next })} - onSendInput={onSendInput} - onRejected={onRejected} - /> + {targetProp ? ( + + ) : null} + +
    + ); +} + +/** + * The request one toolbar press sends. + * + * The target rides along with the command rather than being resolved on the + * other end. A web tab holds exactly one page, so the person pressing back means + * the page in front of them; a request that left the target off would act on the + * surface's selection — a run's page, or the panel's own cell — which is a page + * the tab may not even be showing. + */ +export function webuiBrowserToolbarRequest( + target: WebuiBrowserTargetId, + command: WebuiBrowserToolbarCommand, +): WebuiBrowserCommandRequest { + return { surface: target.surface, command, target }; +} + +/** + * Runs one toolbar press through the browser family's command path. + * + * The send function is injected so the transition is testable on its own, and a + * refusal travels out unchanged: the runtime's own code and message are what the + * person needs — a bridge that is switched off says `browser_capability_denied`, + * and a page that moved says `browser_target_stale`. Neither is papered over here. + */ +export async function webuiBrowserToolbarRun(input: { + readonly send: ((request: WebuiBrowserCommandRequest) => Promise) | undefined; + readonly target: WebuiBrowserTargetId; + readonly command: WebuiBrowserToolbarCommand; +}): Promise { + if (!input.send) { + throw { + code: "browser_provider_unavailable", + message: "当前运行时没有浏览器命令能力,工具栏不可用。", + retryable: false, + } satisfies WebuiBrowserError; + } + await input.send(webuiBrowserToolbarRequest(input.target, input.command)); +} + +/** + * The chrome above one page. + * + * The url is a ``, not an ``. A web tab is not somewhere a person + * types a url: the address box is the new tab page's, and a field here would be + * a second one that could disagree with it. + */ +function WebuiBrowserToolbar({ + url, + onCommand, +}: { + readonly url?: string; + readonly onCommand: (command: WebuiBrowserToolbarCommand) => void; +}): ReactElement { + return ( +
    + {WEBUI_BROWSER_TOOLBAR_COMMANDS.map((command) => ( + + ))} + + {url ?? ""} +
    ); } diff --git a/packages/webui/src/client/features/browser/ui/browser.css b/packages/webui/src/client/features/browser/ui/browser.css index 79a89be9e..f61a212c4 100644 --- a/packages/webui/src/client/features/browser/ui/browser.css +++ b/packages/webui/src/client/features/browser/ui/browser.css @@ -11,6 +11,61 @@ @layer components { + /* The host stacks the toolbar over the stage, so the stage is the child that + * gives up height rather than the toolbar: a chrome row that grows would push + * the picture — the thing the person is looking at — off the panel. */ + .webui-browser-host { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + } + + .webui-browser-host > .webui-browser-panel { + flex: 1 1 auto; + min-height: 0; + } + + /* The toolbar sits above the stage rather than inside it: the stage is + * measured to aim the pointer, and anything drawn beside the picture would + * move the coordinates the input surface computes. The url takes the free + * width and clips itself, because a long url is not a reason to push the + * commands off the row. */ + .webui-browser-toolbar { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; + padding: 6px 12px; + background: var(--bg_grouped_secondary); + border-bottom: 1px solid var(--border_default); + } + + .webui-browser-toolbar-command { + flex: 0 0 auto; + padding: 2px 10px; + font-size: var(--font-size_13, 13px); + color: var(--text_default_primary); + background: var(--bg_interaction_tertiary_hover, transparent); + border: 1px solid var(--border_default); + border-radius: 6px; + cursor: pointer; + } + + .webui-browser-toolbar-command:hover { + background: var(--bg_interaction_tertiary_hover); + } + + .webui-browser-toolbar-url { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--font-size_13, 13px); + color: var(--text_default_secondary); + } + .webui-browser-panel { display: flex; flex-direction: column; @@ -113,7 +168,6 @@ background: transparent; color: var(--text_default_primary); text-align: left; - cursor: pointer; } .webui-browser-target.is-active { diff --git a/packages/webui/src/client/features/workspace/application/workspace-files-owner.ts b/packages/webui/src/client/features/workspace/application/workspace-files-owner.ts index 344727f23..e5c0e4344 100644 --- a/packages/webui/src/client/features/workspace/application/workspace-files-owner.ts +++ b/packages/webui/src/client/features/workspace/application/workspace-files-owner.ts @@ -320,32 +320,39 @@ export function createWebuiWorkspaceFilesOwner(deps: { } }; - const readFile = (tabId: string, workspaceDir: string, path: string): void => { - if (state.files.get(tabId)?.content) return; - const key = `file:${tabId}`; + /** + * Reads a file into the cache. + * + * The first argument is the **cache key**, not a tab id: the cache is keyed + * by what the tab is showing, so a tab that changed from one file to another + * reads the new one instead of keeping the old one's result. + */ + const readFile = (cacheKey: string, workspaceDir: string, path: string): void => { + if (state.files.get(cacheKey)?.content) return; + const key = `file:${cacheKey}`; const version = bump(key); if (!port.readWorkspaceFile) { - setFile(tabId, { loading: false, error: "文件读取能力暂不可用。" }); + setFile(cacheKey, { loading: false, error: "文件读取能力暂不可用。" }); return; } const read = port.readWorkspaceFile; - setFile(tabId, { loading: true }); + setFile(cacheKey, { loading: true }); void read({ workspaceDir, path }) .then((content) => { if (!isCurrent(key, version)) return; - setFile(tabId, { loading: false, content }); + setFile(cacheKey, { loading: false, content }); }) .catch((reason: unknown) => { if (!isCurrent(key, version)) return; - setFile(tabId, { loading: false, error: message(reason) }); + setFile(cacheKey, { loading: false, error: message(reason) }); }); }; - const setInlineFile = (tabId: string, content: string, resolvedPath: string): void => { + const setInlineFile = (cacheKey: string, content: string, resolvedPath: string): void => { // Supersede any in-flight read for this tab so a late read does not // overwrite the inline content the tab already carries. - bump(`file:${tabId}`); - setFile(tabId, { + bump(`file:${cacheKey}`); + setFile(cacheKey, { loading: false, content: { type: "text", content, resolvedPath }, }); diff --git a/packages/webui/src/client/features/workspace/application/workspace-owner-contracts.ts b/packages/webui/src/client/features/workspace/application/workspace-owner-contracts.ts index fb2d12dee..d9cbd2cdc 100644 --- a/packages/webui/src/client/features/workspace/application/workspace-owner-contracts.ts +++ b/packages/webui/src/client/features/workspace/application/workspace-owner-contracts.ts @@ -34,8 +34,17 @@ export interface WebuiWorkspaceFilesCommands { readonly selectWorkspace: (workspaceDir: string | undefined) => void; readonly loadFileTree: (workspaceDir: string) => void; readonly loadDirectory: (workspaceDir: string, path: string) => Promise; - readonly readFile: (tabId: string, workspaceDir: string, path: string) => void; - readonly setInlineFile: (tabId: string, content: string, resolvedPath: string) => void; + /** + * Reads a file into the cache. + * + * The first argument is the **cache key** — what the tab is showing — not a + * tab id. The cache is keyed that way because a tab's id is stable and its + * content is not: a tab that changed from one file to another has to read + * the new one rather than keep the old one's result. + */ + readonly readFile: (cacheKey: string, workspaceDir: string, path: string) => void; + /** Files an inline text under the same cache key a read would use. */ + readonly setInlineFile: (cacheKey: string, content: string, resolvedPath: string) => void; /** Release a closed tab's file slot and its read commit rights. */ readonly discardFileTab: (tabId: string) => void; /** List one directory for the workspace picker (superseded browses dropped). */ diff --git a/packages/webui/src/client/features/workspace/contracts/panel-command.ts b/packages/webui/src/client/features/workspace/contracts/panel-command.ts index c65def8e6..994b388b5 100644 --- a/packages/webui/src/client/features/workspace/contracts/panel-command.ts +++ b/packages/webui/src/client/features/workspace/contracts/panel-command.ts @@ -5,6 +5,7 @@ // from the projection that happens to reduce it. import type { WebuiTurnDiffView } from "../../../../shared/contracts/session.js"; +import type { WebuiBrowserTargetId } from "../../../../shared/contracts/browser.js"; export type WorkspacePanelCommand = | { readonly type: "open-file"; readonly sessionId: string; readonly workspaceDir: string; readonly path: string; readonly lineStart?: number; readonly lineEnd?: number } @@ -15,29 +16,83 @@ export type WorkspacePanelCommand = * workspace root. */ | { readonly type: "open-plan-file"; readonly sessionId: string; readonly workspaceDir: string; readonly path: string; readonly content: string } - | { readonly type: "open-workspace-review"; readonly sessionId: string; readonly workspaceDir: string; readonly selectedPath?: string } + /** + * Opens the workspace's change set. + * + * `tabId` names the tab this opens into. The new tab page's changes tile + * carries it, because a launcher is a tab's content like any other: choosing + * the tile replaces that tab's content rather than activating a change set + * somebody opened earlier. Absent means the singleton lookup. + */ + | { readonly type: "open-workspace-review"; readonly sessionId: string; readonly workspaceDir: string; readonly selectedPath?: string; readonly tabId?: string } | { readonly type: "open-turn-review"; readonly sessionId: string; readonly workspaceDir: string; readonly messageId: string; readonly assistantMessageId?: string; readonly turnId?: string; readonly changeSetId?: string; readonly files: WebuiTurnDiffView["fileChanges"]; readonly selectedPath?: string } | { readonly type: "open-primary-view"; readonly kind: "files"; readonly sessionId?: string; readonly workspaceDir?: string } - | { readonly type: "open-tab"; readonly kind: WebuiWorkspaceMenuTabKind; readonly sessionId?: string; readonly workspaceDir?: string } + /** + * Opens a web tab, or updates the one already bound to that target. + * + * The command carries the target the tab is bound to. The tab's own id is + * the client's and is assigned when the tab is opened, so the same target + * always finds the same tab. + * + * `tabId` names the tab the page belongs in, for the launcher: the person + * typed the url in that tab, so that tab becomes the page. It is optional + * because the runtime's snapshot names no tab — a snapshot has to reconcile + * against whatever tab is already bound, and opens a tab of its own for a + * page nothing is showing yet. + */ + | { readonly type: "open-web-tab"; readonly target: WebuiBrowserTargetId; readonly title: string; readonly url: string; readonly tabId?: string } + /** + * Opens one of the tool views, as the new tab page's tiles do. + * + * `tabId` names the tab this opens into, for the same reason as the changes + * tile's. Absent means the singleton lookup — one tab per session and + * directory — which is what every other caller wants. + */ + | { readonly type: "open-tab"; readonly kind: WebuiWorkspaceOpenTabKind; readonly sessionId?: string; readonly workspaceDir?: string; readonly tabId?: string } + /** + * The new tab page's URL box has asked the runtime for a page. + * + * Dispatched before the browser's port is called, and again with the + * runtime's refusal when there is one. The request rides on the tab's own + * content because the port's answer and the runtime's snapshot can arrive in + * either order, and both have to reach the tab that asked: the request is + * what names it, and a launcher that is waiting for a page is the tab a + * published page belongs to. + */ + | { readonly type: "request-web-page"; readonly tabId: string; readonly url: string; readonly error?: string } + /** + * Open the new tab page. + * + * Dispatched by the strip's "+" affordance, which opens a fresh tab every + * time: a person who wants a second url presses it again and gets a second + * new tab page, because the one they used is a web page now and has no url + * box left. + */ + | { readonly type: "open-new-tab" } | { readonly type: "select-tab"; readonly tabId: string } | { readonly type: "select-review-file"; readonly tabId: string; readonly path: string } | { readonly type: "set-review-snapshot"; readonly tabId: string; readonly reviewSnapshotId: string } | { readonly type: "close-tab"; readonly tabId: string } | { readonly type: "close-panel" } - | { readonly type: "toggle-expanded" } - | { readonly type: "toggle-add-menu" }; + | { readonly type: "toggle-expanded" }; /** - * The kinds an `open-tab` command may name: exactly those whose add-menu entry is - * an `open-tab`. Derived, so a kind that gains or loses a menu entry moves this + * The kinds an `open-tab` command may name: exactly those whose launcher tile + * is an `open-tab`. Derived, so a kind that gains or loses a tile moves this * union with it. */ -export type WebuiWorkspaceMenuTabKind = Extract< - WebuiWorkspaceAddMenuOpen, +export type WebuiWorkspaceOpenTabKind = Extract< + WebuiWorkspaceTileOpen, { readonly type: "open-tab" } >["kind"]; -/** How an add-menu entry opens its tab. Data, so the component only dispatches it. */ -export type WebuiWorkspaceAddMenuOpen = +/** + * How a launcher tile opens its view. Data, so the component only dispatches it. + * + * These were the add menu's entries. The menu went when the launcher's tiles + * took its place, and the rows that described them stayed: one row per kind, + * each carrying the command its tile opens. + */ +export type WebuiWorkspaceTileOpen = | { readonly type: "open-workspace-review" } - | { readonly type: "open-tab"; readonly kind: "files" | "canvas" | "terminal" | "browser" }; + | { readonly type: "open-tab"; readonly kind: "files" | "canvas" | "terminal" }; diff --git a/packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts b/packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts index 0229324f5..05f45b78e 100644 --- a/packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts +++ b/packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts @@ -1,13 +1,14 @@ import type { WebuiTurnDiffView } from "../../../../shared/contracts/session.js"; +import type { WebuiBrowserTargetId } from "../../../../shared/contracts/browser.js"; import { basenamePath } from "../../../projection/common/paths.js"; // --------------------------------------------------------------------------- // The tab-kind registry. The kinds used to be enumerated in three places — the -// union here, the label map and the add menu in `WorkspacePanels.tsx` — so adding -// a kind meant finding all three, and the type system caught only two of them. -// They are declared once, below, and everything else derives: the union from the -// shapes' keys, the tab's label from the table, and the add menu from the table -// plus its order. +// union here, the label map and the launcher's four tiles — so adding a kind +// meant finding all three, and the type system caught only two of them. They are +// declared once, below, and everything else derives: the union from the shapes' +// keys, the tab's label from the table, and the launcher's tiles from the table +// plus their order. // // The registry may carry a host render branch, which is why the *icons* and the // dispatch calls stay in the component: this module has no React and no @@ -15,134 +16,215 @@ import { basenamePath } from "../../../projection/common/paths.js"; // --------------------------------------------------------------------------- /** - * One kind's tab shape. The keys are the closed set — a new kind is a new key - * here, and nothing else spells the set out. + * What a tab's slot is showing. + * + * A tab is a stable slot whose content can be replaced in place: a tab that + * shows the new tab page and the same tab that later shows a web page are one + * tab, and the strip does not flicker when the content changes. The kinds are + * the content's own discriminants. * * `review` has two variants because one tab serves both the workspace's change * set and a turn's; the discriminant is `source`, not `kind`. */ -export interface WebuiWorkspaceTabShapes { - readonly files: { - readonly sessionId?: string; - readonly workspaceDir?: string; - }; - readonly "file-preview": { - readonly sessionId: string; - readonly workspaceDir: string; - readonly path: string; - readonly lineStart?: number; - readonly lineEnd?: number; - readonly content?: string; - }; - readonly review: - | { - readonly sessionId: string; - readonly workspaceDir: string; - readonly source: "workspace"; - readonly selectedPath?: string; - readonly reviewSnapshotId?: string; - } - | { - readonly sessionId: string; - readonly workspaceDir: string; - readonly source: "turn"; - readonly messageId: string; - readonly assistantMessageId?: string; - readonly turnId?: string; - readonly changeSetId?: string; - readonly files: WebuiTurnDiffView["fileChanges"]; - readonly selectedPath?: string; +export type WebuiWorkspaceTabContent = + | { + readonly kind: "files" | "canvas" | "terminal"; + readonly sessionId?: string; + readonly workspaceDir?: string; + } + | { + readonly kind: "file-preview"; + readonly sessionId: string; + readonly workspaceDir: string; + readonly path: string; + readonly lineStart?: number; + readonly lineEnd?: number; + readonly content?: string; + } + | { + readonly kind: "review"; + readonly source: "workspace"; + readonly sessionId: string; + readonly workspaceDir: string; + readonly selectedPath?: string; + readonly reviewSnapshotId?: string; + } + | { + readonly kind: "review"; + readonly source: "turn"; + readonly sessionId: string; + readonly workspaceDir: string; + readonly messageId: string; + readonly assistantMessageId?: string; + readonly turnId?: string; + readonly changeSetId?: string; + readonly files: WebuiTurnDiffView["fileChanges"]; + readonly selectedPath?: string; + } + | { + readonly kind: "web"; + /** + * The target the web tab is bound to. + * + * The binding is the runtime's target identity, not the tab's own + * identity: a page that navigates keeps its target id and gains a + * generation, and the tab that holds it is still the same tab. + */ + readonly target: WebuiBrowserTargetId; + /** The page's title, as the runtime publishes it. */ + readonly title: string; + /** The page's url, as the runtime publishes it. */ + readonly url: string; + } + | { + /** + * The new tab page. + * + * Opened by the strip's "new tab" affordance: a launcher with four tiles + * and a URL box, and choosing either replaces this content. It is a + * content like any other, which is the whole reason the tab is an id and + * a content — the tab that shows the launcher and the tab that later + * shows the page are one tab. + */ + readonly kind: "launcher"; + /** + * The page this launcher has asked the runtime to open, once it has asked. + * + * `undefined` is the new tab page as the person sees it. The request is + * part of the content rather than of the component because the port's + * answer and the runtime's own snapshot can arrive in either order, and + * both have to reach the tab that asked: this is what names it, and it is + * what tells a launcher it is waiting rather than idle. + */ + readonly request?: { + readonly url: string; + /** + * The refusal, when the runtime refused rather than answered. The + * launcher keeps its url box and says why, because a page that cannot + * be opened is not a closed page. + */ + readonly error?: string; }; - readonly canvas: { - readonly sessionId?: string; - }; - readonly terminal: { - readonly workspaceDir?: string; - }; - /** - * The browser panel's tab. - * - * It carries the optional instance fields every menu-opened tab carries and no - * file path: a browser tab opens a browser's page, not a document in the - * workspace. The instance fields are declared rather than refused because the - * add menu sends them with every `open-tab` and the tab is built as one object - * literal — a shape that would not accept them fails to compile the moment the - * command names this kind, instead of carrying two words no view reads. - */ - readonly browser: { - readonly sessionId?: string; - readonly workspaceDir?: string; - }; + }; + +/** The content's kinds — the closed set, derived from the content itself. */ +export type WebuiWorkspaceTabKind = WebuiWorkspaceTabContent["kind"]; + +/** + * A tab: a stable id and the content it is showing. + * + * The id belongs to the client. It is assigned when the tab is opened and it + * does not change when the content does, so it says nothing about what the + * tab is showing — and two web tabs in one session are two tabs. + */ +export interface WorkspacePanelTab { + readonly id: string; + readonly content: WebuiWorkspaceTabContent; } -export type WebuiWorkspaceTabKind = keyof WebuiWorkspaceTabShapes; +/** + * The content, when it is the workspace's change set. + * + * The two review variants are one `kind` with a `source` discriminant, and a + * caller that wants the workspace's one has to say so. The narrowing lives + * here rather than at each call site, so the rule is written once. + */ +export function webuiWorkspaceReviewContent( + content: WebuiWorkspaceTabContent | undefined, +): Extract | undefined { + if (!content || content.kind !== "review" || content.source !== "workspace") return undefined; + return content; +} -/** Every tab, derived: one member per declared kind, each carrying `kind` and its shape. */ -export type WorkspacePanelTab = { - [K in WebuiWorkspaceTabKind]: { readonly id: string; readonly kind: K } & - WebuiWorkspaceTabShapes[K]; -}[WebuiWorkspaceTabKind]; +/** + * A key that names what a tab is showing, not which slot it is. + * + * The caches are keyed by this rather than by the tab's id. The id is stable + * and the content is not, so a cache keyed by the id would hand the previous + * content's result to the new one — a tab that changed from one file to + * another would keep showing the first file. + */ +export function webuiWorkspaceTabContentKey(content: WebuiWorkspaceTabContent): string { + switch (content.kind) { + case "file-preview": + return `file:${content.sessionId}:${content.workspaceDir}:${content.path}`; + case "review": + return `review:${content.source}:${content.sessionId}:${content.workspaceDir}:${ + content.source === "turn" ? content.messageId : "" + }`; + case "web": + return `web:${content.target.browserId}:${content.target.targetId}`; + case "launcher": + return "launcher"; + default: + return `${content.kind}:${content.sessionId ?? ""}:${content.workspaceDir ?? ""}`; + } +} // Both unions describe the shape of a panel command, so they live with it in the // workspace's contract (see `features/workspace/contracts/panel-command.ts`); they // are re-exported here for the panel code that already read them from this module. -export type { WebuiWorkspaceAddMenuOpen, WebuiWorkspaceMenuTabKind }; +export type { WebuiWorkspaceTileOpen, WebuiWorkspaceOpenTabKind }; export interface WebuiWorkspaceTabKindEntry { - /** The tab's label, and the add-menu entry's when it has one. */ + /** The tab's label, and the launcher's tile label when it has one. */ readonly label: string; /** - * The add-menu entry, or `null` for a kind opened only by an instance — a file - * preview is opened by a path from the tree, never by the menu. + * The launcher tile, or `null` for a kind opened only by an instance — a file + * preview is opened by a path from the tree, and a web tab is opened by + * typing a url or by the Agent. */ - readonly addMenu: WebuiWorkspaceAddMenuOpen | null; + readonly tile: WebuiWorkspaceTileOpen | null; } export const WEBUI_WORKSPACE_TAB_KINDS: Readonly< Record > = { - files: { label: "查看文件", addMenu: { type: "open-tab", kind: "files" } }, - "file-preview": { label: "文件", addMenu: null }, - review: { label: "变更", addMenu: { type: "open-workspace-review" } }, - canvas: { label: "画布", addMenu: { type: "open-tab", kind: "canvas" } }, - terminal: { label: "终端", addMenu: { type: "open-tab", kind: "terminal" } }, + files: { label: "查看文件", tile: { type: "open-tab", kind: "files" } }, + "file-preview": { label: "文件", tile: null }, + review: { label: "变更", tile: { type: "open-workspace-review" } }, + canvas: { label: "画布", tile: { type: "open-tab", kind: "canvas" } }, + terminal: { label: "终端", tile: { type: "open-tab", kind: "terminal" } }, + /** + * A web tab. + * + * It has no tile of its own: a web tab is opened by typing a url in the new + * tab page, or by the Agent, and the browser tool tab it used to stand for is + * gone. + */ + web: { label: "网页", tile: null }, /** - * The browser panel. + * The new tab page. * - * The command's `open-tab` names `browser` alongside the other kinds, so this - * row has a menu entry of its own. The panel is mounted by the browser - * business's published host, which owns the browser state — a workspace - * component may not construct another business's state. + * It has no tile of its own: it is the page the tiles are on, opened by the + * strip's "new tab" affordance. */ - browser: { label: "浏览器", addMenu: { type: "open-tab", kind: "browser" } }, + launcher: { label: "新建标签页", tile: null }, }; /** - * The add menu's order. It is not the table's: 审查 leads and 查看文件 trails the - * kinds that open a process. The browser panel leads the file views — it is - * another thing to watch while the work runs — and contributes no entry while its - * row above is `null`. + * The launcher's tiles, in the order they are laid out. It is not the table's: + * 变更 leads and 查看文件 trails the kinds that open a process. */ -export const WEBUI_WORKSPACE_ADD_MENU_ORDER: readonly WebuiWorkspaceTabKind[] = [ +export const WEBUI_WORKSPACE_LAUNCHER_TILE_ORDER: readonly WebuiWorkspaceTabKind[] = [ "review", "terminal", "canvas", - "browser", "files", ]; -export interface WebuiWorkspaceAddMenuEntry { +export interface WebuiWorkspaceLauncherTile { readonly kind: WebuiWorkspaceTabKind; readonly label: string; - readonly opens: WebuiWorkspaceAddMenuOpen; + readonly opens: WebuiWorkspaceTileOpen; } -/** The add menu, derived from the table — entries and order both live there. */ -export function webuiWorkspaceAddMenu(): readonly WebuiWorkspaceAddMenuEntry[] { - return WEBUI_WORKSPACE_ADD_MENU_ORDER.flatMap((kind) => { +/** The launcher's tiles, derived from the table — entries and order both live there. */ +export function webuiWorkspaceLauncherTiles(): readonly WebuiWorkspaceLauncherTile[] { + return WEBUI_WORKSPACE_LAUNCHER_TILE_ORDER.flatMap((kind) => { const entry = WEBUI_WORKSPACE_TAB_KINDS[kind]; - return entry.addMenu ? [{ kind, label: entry.label, opens: entry.addMenu }] : []; + return entry.tile ? [{ kind, label: entry.label, opens: entry.tile }] : []; }); } @@ -152,9 +234,13 @@ export function webuiWorkspaceAddMenu(): readonly WebuiWorkspaceAddMenuEntry[] { * every other kind shows the registry's word. */ export function webuiWorkspaceTabLabel(tab: WorkspacePanelTab): string { - if (tab.kind === "file-preview") return basenamePath(tab.path); - if (tab.kind === "review" && tab.source === "turn") return "Review"; - return WEBUI_WORKSPACE_TAB_KINDS[tab.kind].label; + const content = tab.content; + if (content.kind === "file-preview") return basenamePath(content.path); + if (content.kind === "review" && content.source === "turn") return "Review"; + // A web tab is titled by the page, and the title is the runtime's to + // report. A tab whose page has no title yet falls back to the registry's word. + if (content.kind === "web") return content.title || WEBUI_WORKSPACE_TAB_KINDS.web.label; + return WEBUI_WORKSPACE_TAB_KINDS[content.kind].label; } export interface WorkspacePanelState { @@ -162,7 +248,15 @@ export interface WorkspacePanelState { readonly expanded: boolean; readonly tabs: readonly WorkspacePanelTab[]; readonly activeTabId?: string; - readonly addMenuOpen: boolean; + /** + * The next tab number. + * + * The tab's id is assigned by the client when the tab is opened and it does + * not change when the content does, so it cannot be derived from the + * content. The counter is what makes the assignment unique — and it is why + * two web tabs in one session are two tabs rather than one. + */ + readonly nextTabNumber: number; } export interface WorkspacePanelSessionState { @@ -228,8 +322,8 @@ export function setWorkspaceSessionProgressPanelOpen( } import type { - WebuiWorkspaceAddMenuOpen, - WebuiWorkspaceMenuTabKind, + WebuiWorkspaceTileOpen, + WebuiWorkspaceOpenTabKind, WorkspacePanelCommand, } from "../contracts/panel-command.js"; @@ -240,7 +334,7 @@ export const initialWorkspacePanelState: WorkspacePanelState = { open: false, expanded: false, tabs: [], - addMenuOpen: false, + nextTabNumber: 1, }; export const initialWorkspacePanelSessionState: WorkspacePanelSessionState = { @@ -248,8 +342,57 @@ export const initialWorkspacePanelSessionState: WorkspacePanelSessionState = { progressPanelOpen: true, }; -function tabId(kind: string, ...parts: string[]): string { - return `${kind}:${parts.map((part) => encodeURIComponent(part)).join(":")}`; +/** + * Opens a tab with a fresh id and makes it the active one. + * + * The id is assigned here, by the client, because the tab's id belongs to the + * client: it is assigned when the tab is opened and it does not change when + * the content does. Nothing derives it from the content, so two web tabs in + * one session are two tabs. + */ +function openNewTab( + state: WorkspacePanelState, + content: WebuiWorkspaceTabContent, +): WorkspacePanelState { + const id = `tab-${state.nextTabNumber}`; + return activate( + { ...state, nextTabNumber: state.nextTabNumber + 1 }, + { id, content }, + ); +} + +/** + * Whether the content is the same instance view. + * + * The files view, the canvas and the terminal are one tab per session and + * directory. Their ids used to say so, and the id no longer says anything — + * so the rule is written here instead. + */ +function isSameInstance( + content: WebuiWorkspaceTabContent, + kind: "files" | "canvas" | "terminal", + sessionId?: string, + workspaceDir?: string, +): boolean { + return ( + content.kind === kind && + content.sessionId === sessionId && + content.workspaceDir === workspaceDir + ); +} + +/** + * The tab that is already showing this content, when there is one. + * + * The rules that used to fall out of the id — the same file opens one tab, + * the workspace's change set and a turn's review are one tab — are content + * checks now, because the id says nothing about what the tab is showing. + */ +function tabShowing( + state: WorkspacePanelState, + matches: (content: WebuiWorkspaceTabContent) => boolean, +): WorkspacePanelTab | undefined { + return state.tabs.find((tab) => matches(tab.content)); } function activate(state: WorkspacePanelState, tab: WorkspacePanelTab): WorkspacePanelState { @@ -257,64 +400,258 @@ function activate(state: WorkspacePanelState, tab: WorkspacePanelTab): Workspace return { ...state, open: true, - addMenuOpen: false, tabs: existing ? state.tabs.map((candidate) => candidate.id === tab.id ? tab : candidate) : [...state.tabs, tab], activeTabId: tab.id, }; } +/** + * Puts new content in one tab, keeping the tab. + * + * This is what the new tab page does when a tile or a url is chosen: the slot + * the person opened is the slot that shows what they asked for, so the tab is + * neither closed nor replaced and its id — which every cache in the panel is + * keyed by — does not move. + * + * The tab is **not** activated: a person who asked for a page and then looked + * at another tab keeps looking at that other tab, and the page waits for them + * in the tab they typed it in. + */ +function replaceTabContent( + state: WorkspacePanelState, + tabId: string, + content: WebuiWorkspaceTabContent, +): WorkspacePanelState { + const tab = state.tabs.find((candidate) => candidate.id === tabId); + if (!tab) return state; + return updateWorkspacePanelTab(state, tabId, () => ({ ...tab, content })); +} + export function reduceWorkspacePanelState(state: WorkspacePanelState, command: WorkspacePanelCommand): WorkspacePanelState { switch (command.type) { case "open-file": { const path = command.path.replace(/\\/gu, "/").replace(/^\.\//u, ""); if (!path || path.startsWith("/") || path.split("/").some((part) => part === ".." || part === "")) return state; - return activate(state, { id: tabId("file", command.sessionId, command.workspaceDir, path), kind: "file-preview", sessionId: command.sessionId, workspaceDir: command.workspaceDir, path, ...(command.lineStart !== undefined ? { lineStart: command.lineStart } : {}), ...(command.lineEnd !== undefined ? { lineEnd: command.lineEnd } : {}) }); + const content: WebuiWorkspaceTabContent = { + kind: "file-preview", + sessionId: command.sessionId, + workspaceDir: command.workspaceDir, + path, + ...(command.lineStart !== undefined ? { lineStart: command.lineStart } : {}), + ...(command.lineEnd !== undefined ? { lineEnd: command.lineEnd } : {}), + }; + // The same file opens one tab. The rule is a content check now, not an + // id derivation: the id belongs to the client and says nothing about + // what the tab is showing. + const existing = tabShowing( + state, + (candidate) => + candidate.kind === "file-preview" && + candidate.path === path && + candidate.sessionId === command.sessionId && + candidate.workspaceDir === command.workspaceDir, + ); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); } case "open-plan-file": { const path = command.path.replace(/\\/gu, "/").replace(/^\.\//u, ""); - const id = tabId("file", command.sessionId, command.workspaceDir, path); - return activate(state, { id, kind: "file-preview", sessionId: command.sessionId, workspaceDir: command.workspaceDir, path, content: command.content }); + const content: WebuiWorkspaceTabContent = { + kind: "file-preview", + sessionId: command.sessionId, + workspaceDir: command.workspaceDir, + path, + content: command.content, + }; + const existing = tabShowing( + state, + (candidate) => + candidate.kind === "file-preview" && + candidate.path === path && + candidate.sessionId === command.sessionId && + candidate.workspaceDir === command.workspaceDir, + ); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); } case "open-workspace-review": { - const id = tabId("review", command.sessionId, command.workspaceDir); - return activate(state, { id, kind: "review", source: "workspace", sessionId: command.sessionId, workspaceDir: command.workspaceDir, ...(command.selectedPath ? { selectedPath: command.selectedPath } : {}) }); + const content: WebuiWorkspaceTabContent = { + kind: "review", + source: "workspace", + sessionId: command.sessionId, + workspaceDir: command.workspaceDir, + ...(command.selectedPath ? { selectedPath: command.selectedPath } : {}), + }; + // The launcher's changes tile opens the change set in the tab the tile is + // on, and the singleton lookup is for every other caller. + if (command.tabId) return replaceTabContent(state, command.tabId, content); + // The workspace's change set and a turn's review are two tabs. They used + // to derive the same id, so opening one replaced the other. + const existing = tabShowing( + state, + (candidate) => candidate.kind === "review" && candidate.source === "workspace", + ); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); } case "open-turn-review": { - const id = tabId("review", command.sessionId, command.workspaceDir); - return activate(state, { id, kind: "review", source: "turn", sessionId: command.sessionId, workspaceDir: command.workspaceDir, messageId: command.messageId, assistantMessageId: command.assistantMessageId, turnId: command.turnId, changeSetId: command.changeSetId, files: command.files ?? [], ...(command.selectedPath ? { selectedPath: command.selectedPath } : {}) }); + const content: WebuiWorkspaceTabContent = { + kind: "review", + source: "turn", + sessionId: command.sessionId, + workspaceDir: command.workspaceDir, + messageId: command.messageId, + assistantMessageId: command.assistantMessageId, + turnId: command.turnId, + changeSetId: command.changeSetId, + files: command.files ?? [], + ...(command.selectedPath ? { selectedPath: command.selectedPath } : {}), + }; + const existing = tabShowing( + state, + (candidate) => candidate.kind === "review" && candidate.source === "turn", + ); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); + } + case "open-web-tab": { + const content: WebuiWorkspaceTabContent = { + kind: "web", + target: command.target, + title: command.title, + url: command.url, + }; + // A web tab is bound to its target, and a tab already bound to that + // target is the same tab: the runtime publishes a snapshot, and applying + // a snapshot twice changes nothing. + const existing = tabShowing( + state, + (candidate) => + candidate.kind === "web" && + candidate.target.browserId === command.target.browserId && + candidate.target.targetId === command.target.targetId, + ); + if (existing) { + // The page behind the tab moved: the content updates under a tab that + // is already open, and the person keeps looking at whatever they were + // looking at. A snapshot that changes nothing changes nothing here + // either, so the panel is not re-rendered for no reason. + const current = existing.content; + if ( + current.kind === "web" && + current.title === content.title && + current.url === content.url && + current.target.generation === content.target.generation + ) { + return state; + } + return { + ...state, + tabs: state.tabs.map((tab) => + tab.id === existing.id ? { ...existing, content } : tab, + ), + }; + } + // The launcher asked for this page: the person typed its url in that tab, + // so that tab becomes the web page rather than a second tab appearing + // beside it. The answer names the tab, and the snapshot below names it + // through the request — either order, the same tab. + if (command.tabId) return replaceTabContent(state, command.tabId, content); + // A page nothing is bound to yet, and a launcher waiting for one: the new + // tab page is a tab whose content is a placeholder for whatever comes + // next, so a page the panel has not bound takes it over. This is what + // keeps the runtime's snapshot and the port's answer from opening two + // tabs for one page when the snapshot lands first. + const waiting = tabShowing( + state, + (candidate) => candidate.kind === "launcher" && candidate.request !== undefined, + ); + if (waiting) return replaceTabContent(state, waiting.id, content); + // Nothing is waiting for it: the Agent opened the page, so it opens a tab + // of its own and becomes the active one. + return openNewTab(state, content); + } + case "request-web-page": { + // The request is recorded before the runtime is asked, so a snapshot that + // lands while the call is in flight finds the tab that asked. A refusal + // keeps the request and adds the reason: the launcher still has a url box, + // and the person can say something else. + const tab = state.tabs.find((candidate) => candidate.id === command.tabId); + if (tab?.content.kind !== "launcher") return state; + const content: WebuiWorkspaceTabContent = { + kind: "launcher", + request: { + url: command.url, + ...(command.error ? { error: command.error } : {}), + }, + }; + return replaceTabContent(state, command.tabId, content); } case "open-primary-view": { - const id = tabId(command.kind, command.sessionId ?? "", command.workspaceDir ?? ""); - const tab: WorkspacePanelTab = { id, kind: command.kind, ...(command.sessionId ? { sessionId: command.sessionId } : {}), ...(command.workspaceDir ? { workspaceDir: command.workspaceDir } : {}) }; - const tabs = state.tabs.filter((candidate) => candidate.kind !== "files"); - return { ...state, open: true, addMenuOpen: false, tabs: [...tabs, tab], activeTabId: id }; + const content: WebuiWorkspaceTabContent = { + kind: "files", + ...(command.sessionId ? { sessionId: command.sessionId } : {}), + ...(command.workspaceDir ? { workspaceDir: command.workspaceDir } : {}), + }; + // The files view is a singleton per session and directory, and that is + // an explicit rule now: the id no longer implies it, because the id + // says nothing about what the tab is showing. + const existing = tabShowing(state, (candidate) => isSameInstance(candidate, "files", command.sessionId, command.workspaceDir)); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); } case "open-tab": { - const id = tabId(command.kind, command.sessionId ?? "", command.workspaceDir ?? ""); - return activate(state, { id, kind: command.kind, ...(command.sessionId ? { sessionId: command.sessionId } : {}), ...(command.workspaceDir ? { workspaceDir: command.workspaceDir } : {}) }); + const content: WebuiWorkspaceTabContent = { + kind: command.kind, + ...(command.sessionId ? { sessionId: command.sessionId } : {}), + ...(command.workspaceDir ? { workspaceDir: command.workspaceDir } : {}), + }; + // A tile on the new tab page opens its view in the tab the tile is on. + if (command.tabId) return replaceTabContent(state, command.tabId, content); + const existing = tabShowing(state, (candidate) => isSameInstance(candidate, command.kind, command.sessionId, command.workspaceDir)); + if (existing) return activate(state, { ...existing, content }); + return openNewTab(state, content); + } + case "open-new-tab": { + // A fresh tab page every time the "+" is pressed. It is a browser's rule, + // and here it is the launcher's: the page a person typed a url into is a + // web page now and has no url box left, so pressing "+" again is how they + // ask for another one — without losing the page they just opened. + const content: WebuiWorkspaceTabContent = { kind: "launcher" }; + return openNewTab(state, content); } case "select-tab": - return state.tabs.some((tab) => tab.id === command.tabId) ? { ...state, open: true, activeTabId: command.tabId, addMenuOpen: false } : state; + return state.tabs.some((tab) => tab.id === command.tabId) ? { ...state, open: true, activeTabId: command.tabId } : state; case "select-review-file": - return updateWorkspacePanelTab(state, command.tabId, (tab) => tab.kind === "review" ? { ...tab, selectedPath: command.path } : tab); + return updateWorkspacePanelTab(state, command.tabId, (tab) => + tab.content.kind === "review" ? { ...tab, content: { ...tab.content, selectedPath: command.path } } : tab, + ); case "set-review-snapshot": { const current = state.tabs.find((tab) => tab.id === command.tabId); - if (current?.kind !== "review" || current.source !== "workspace" || current.reviewSnapshotId === command.reviewSnapshotId) return state; - return updateWorkspacePanelTab(state, command.tabId, (tab) => tab.kind === "review" ? { ...tab, reviewSnapshotId: command.reviewSnapshotId } : tab); + if ( + current?.content.kind !== "review" || + current.content.source !== "workspace" || + current.content.reviewSnapshotId === command.reviewSnapshotId + ) { + return state; + } + return updateWorkspacePanelTab(state, command.tabId, (tab) => + tab.content.kind === "review" + ? { ...tab, content: { ...tab.content, reviewSnapshotId: command.reviewSnapshotId } } + : tab, + ); } case "close-tab": { const index = state.tabs.findIndex((tab) => tab.id === command.tabId); if (index < 0) return state; const tabs = state.tabs.filter((tab) => tab.id !== command.tabId); const activeTabId = state.activeTabId === command.tabId ? tabs[Math.min(index, tabs.length - 1)]?.id : state.activeTabId; - return { ...state, tabs, activeTabId, open: tabs.length > 0, addMenuOpen: false }; + return { ...state, tabs, activeTabId, open: tabs.length > 0 }; } case "close-panel": - return { ...state, open: false, addMenuOpen: false }; + return { ...state, open: false }; case "toggle-expanded": return { ...state, expanded: !state.expanded }; - case "toggle-add-menu": - return { ...state, open: true, addMenuOpen: !state.addMenuOpen }; } } diff --git a/packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx b/packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx new file mode 100644 index 000000000..dc059eb72 --- /dev/null +++ b/packages/webui/src/client/features/workspace/ui/WebuiWorkspaceTabStrip.public.tsx @@ -0,0 +1,520 @@ +// The workspace panel's tab strip, and the new tab page it opens. +// +// The strip is the workspace's one row of tabs: tool tabs (files, review, +// canvas, terminal) and web tabs interleaved in the order they were opened. +// It owns three things: +// - the per-tab close button, +// - the single active tab, +// - the "+" affordance that opens a fresh tab showing the new tab page. +// +// The new tab page itself is a launcher — four tiles and a URL box — and it is +// a tab's content rather than a menu over the strip: choosing a tile or typing +// a url replaces that tab's content, and the tab keeps its id. +// +// The web tabs it shows come from the runtime's `watchBrowserWebTabs` +// snapshot, never a parallel copy the client keeps. The strip subscribes +// once, under the session's run id, and turns each published web tab into an +// `open-web-tab` panel command, so the workspace's own reducer is what holds +// the list — the reconciliation lives there, and a snapshot that lands +// twice changes nothing. +// +// `.public` because the Browser business's host renders the strip's +// active web tab: a foreign business's component renders the workspace's +// tabs, and reaching across the boundary from the other side is what the +// dependency rules allow. + +import { + useState, + useEffect, + useMemo, + useRef, + type FormEvent, + type ReactElement, +} from "react"; + +import { + type WebuiBrowserTargetId, + type WebuiBrowserWebTab, + type WebuiBrowserWebTabSnapshot, +} from "../../../../shared/contracts/browser.js"; +import { useWebuiPortSlice } from "../../../bindings/port-slices-context.js"; +import { + webuiWorkspaceLauncherTiles, + webuiWorkspaceTabLabel, + type WebuiWorkspaceLauncherTile, + type WorkspacePanelCommand, + type WorkspacePanelState, + type WorkspacePanelTab, +} from "../projection/workspace-panel-state.js"; +import { + WebuiIconClose, + WebuiIconDiffFile, + WebuiIconFile, + WebuiIconWorkspaceCanvas, + WebuiIconWorkspaceReview, + WebuiIconWorkspaceTerminal, +} from "../../../shell/icons.js"; + +const STRIP_COPY = { + addTab: "新建标签页", + fileClose: "关闭", + tabsLabel: "工作区面板标签", + launcherUrlLabel: "输入网址,打开为网页标签页", + launcherUrlPlaceholder: "输入网址", + launcherUrlSubmit: "打开网页", + launcherPending: "正在打开…", + launcherUnavailable: "当前运行时没有浏览器能力,无法打开网页。", +} as const; + +/** + * The strip's view of the panel. + * + * The strip is a presentation component over the workspace's reducer output: + * the reducer holds the canonical list of tabs, and the strip diffs that + * list against what the runtime just published through + * `watchBrowserWebTabs`, dispatching an `open-web-tab` for each published + * tab that has no panel tab yet. + * + * The strip does not keep its own list of web tabs. Two copies of "what is + * open" disagree exactly when a tab is closed (issue #207), so the strip + * owns the closing gesture only — knowing the tab is gone is the reducer's + * job, and the strip's dispatch is what tells it. + */ +export interface WebuiWorkspaceTabStripProps { + /** The panel's state, projected by the workspace's reducer. */ + readonly state: WorkspacePanelState; + /** The dispatch the strip fires commands at. */ + readonly dispatch: (command: WorkspacePanelCommand) => void; + /** + * The session the strip belongs to, when there is one. + * + * It names the run whose web tabs the strip subscribes to. The pages of a + * session are recorded under that run — whether the Agent opened them or the + * person did — so one subscription shows both, and neither needs a second copy + * of "what is open". + */ + readonly sessionId?: string; +} + +/** + * The commands a published snapshot implies for the workspace. + * + * Exported for the test that exercises the reconciliation rule without a + * DOM: a snapshot's tabs become `open-web-tab` commands for any target the + * panel does not already have a tab for. + */ +export function webuiWorkspaceTabStripReconcile( + tabs: readonly WorkspacePanelTab[], + snapshot: WebuiBrowserWebTabSnapshot, +): readonly { readonly command: WorkspacePanelCommand; readonly target: WebuiBrowserWebTab["target"] }[] { + const known = new Map( + tabs + .filter( + (tab): tab is WorkspacePanelTab & { readonly content: Extract } => + tab.content.kind === "web", + ) + .map((tab) => [ + `${tab.content.target.browserId}/${tab.content.target.targetId}`, + tab.content, + ]), + ); + return snapshot.tabs + .filter((published) => { + const current = known.get(`${published.target.browserId}/${published.target.targetId}`); + // A web tab the panel does not have yet is new, and a web tab whose + // title, url or generation moved is the same tab showing another page. + // A snapshot that changes nothing produces no work. + if (!current) return true; + return ( + current.title !== published.title || + current.url !== published.url || + current.target.generation !== published.target.generation + ); + }) + .map((published) => ({ + command: { + type: "open-web-tab", + target: published.target, + title: published.title, + url: published.url, + }, + target: published.target, + })); +} + +/** + * The glyph a tool tab carries. + * + * The registry carries the kinds, their words and how each opens; the glyphs + * are the strip's, because a glyph is a rendering of the content rather than + * a fact about it. + */ +function fileTabGlyph(item: WorkspacePanelTab): ReactElement | null { + if (item.content.kind === "files") { + return ; + } + if (item.content.kind !== "file-preview") return null; + const extension = item.content.path.split(/[./\\]/u).at(-1)?.toLowerCase(); + if (extension === "md" || extension === "mdx") { + return ; + } + if (["js", "jsx", "mjs", "cjs"].includes(extension ?? "")) { + return ; + } + if (["ts", "tsx", "mts", "cts"].includes(extension ?? "")) { + return ; + } + if (extension === "json") { + return ; + } + return ; +} + +/** + * The strip component. + * + * Mounted by `WorkspacePanels` above the body of the active tab. Each + * published web tab in the runtime's snapshot is brought into the + * workspace's reducer through an `open-web-tab` dispatch, the same shape + * the Agent uses to open a tab (issue #206), so a snapshot that lands + * twice turns into no work and a tab that closes is what the reducer + * records in its place. + */ +export function WebuiWorkspaceTabStrip({ + state, + dispatch, + sessionId, +}: WebuiWorkspaceTabStripProps): ReactElement { + // The strip does not touch its own state. What it does is reconcile the + // snapshot against the workspace's tabs: for each published web tab, dispatch + // an `open-web-tab` if the panel does not already have one. The reducer's + // existing-tab rule (issue #206) keeps duplicates out. + const slice = useWebuiPortSlice("browser"); + const watchBrowserWebTabs = slice?.watchBrowserWebTabs; + const closeBrowserWebTab = slice?.closeBrowserWebTab; + + /** + * Closes a tab. + * + * A web tab's 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. A tool tab holds no page, so + * closing it is the client's business alone. + */ + const closeTab = async (item: WorkspacePanelTab): Promise => { + if (item.content.kind === "web") { + await closeBrowserWebTab?.({ target: item.content.target }); + } + dispatch({ type: "close-tab", tabId: item.id }); + }; + + // A ref-stable handle on the latest published web tabs. The effect that + // subscribes to `watchBrowserWebTabs` is recreated only when the slice's + // identity changes — the strip keeps an up-to-date view of the tabs in + // a ref, so a snapshot that lands between renders is reconciled against + // the tabs the reducer holds now. + const latestTabsRef = useRef(state.tabs); + latestTabsRef.current = state.tabs; + + // The strip owns the row, so it is the strip that brings a focused, + // off-screen tab into view. The row scrolls, the panel does not. + const tabListRef = useRef(null); + useEffect(() => { + tabListRef.current + ?.querySelector('[aria-selected="true"]') + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [state.activeTabId]); + + // The strip subscribes once. When the slice's `watchBrowserWebTabs` changes + // identity we close the previous subscription and open a new one for the + // slice the strip is now wired to. + const watchKey = watchBrowserWebTabs ?? null; + const runScope = useMemo(() => (sessionId ? { runId: sessionId } : {}), [sessionId]); + useEffect(() => { + if (!watchBrowserWebTabs) return; + const stop = watchBrowserWebTabs( + // The session's web tabs, under the session's run id: the run's selection + // and the session's pages are recorded in the same cell, so a page opened + // by the Agent and a page opened by the person arrive on one subscription. + // With no session in view it is the panel's own cell, which is what the + // person has when there is no run behind the workspace. + { surface: "isolated-chromium", ...runScope }, + (snapshot: WebuiBrowserWebTabSnapshot) => { + // The strip owns the closing gesture only; the reducer owns the + // canonical list. Each published web tab that has no panel tab is + // brought in through an `open-web-tab` dispatch — the same shape the + // Agent uses to open a tab (issue #206). A snapshot that lands + // twice turns into no work, because the target is the reducer's + // identity for a web tab. + for (const { command } of webuiWorkspaceTabStripReconcile(latestTabsRef.current, snapshot)) { + dispatch(command); + } + }, + ); + return () => { + stop(); + }; + }, [watchKey, dispatch, runScope]); + + return ( +
    + {state.tabs.map((item) => ( +
    + + +
    + ))} + +
    + ); +} + +export interface WebuiWorkspaceLauncherProps { + /** The tab showing the new tab page. Its id survives whatever it opens. */ + readonly tab: WorkspacePanelTab; + readonly dispatch: (command: WorkspacePanelCommand) => void; + readonly sessionId?: string; + readonly workspaceDir?: string; +} + +/** + * The new tab page. + * + * Four tiles and a URL box, and it is a tab's content rather than a menu over + * the strip: choosing a tile opens that view in this tab, and typing a url + * makes this tab the web page. The tab is not closed and not replaced, which is + * why a tab is an id and a content in the first place. + * + * The URL box calls the browser business's port. That is the point of it: a + * page typed by hand and a page opened by the Agent go through the same + * `open_tab`, the same surface switch and the same refusals. The launcher does + * not keep a browser of its own. + */ +export function WebuiWorkspaceLauncher({ + tab, + dispatch, + sessionId, + workspaceDir, +}: WebuiWorkspaceLauncherProps): ReactElement | null { + const request = tab.content.kind === "launcher" ? tab.content.request : undefined; + const slice = useWebuiPortSlice("browser"); + const openBrowserWebTab = slice?.openBrowserWebTab; + const [typed, setTyped] = useState(""); + if (tab.content.kind !== "launcher") return null; + + const submit = (event: FormEvent): void => { + event.preventDefault(); + const url = webuiWorkspaceLauncherUrl(typed); + if (!url || !openBrowserWebTab) return; + setTyped(""); + void webuiWorkspaceLauncherOpenPage({ + tabId: tab.id, + url, + dispatch, + open: openBrowserWebTab, + // The run is named so the runtime can decide the bootstrap: the page joins + // the run's selection only when the run has none at that moment. The + // launcher does not decide it — it cannot see the run's selection, and + // deciding it here would make the person a standing writer of the Agent's + // cell. + ...(sessionId ? { runId: sessionId } : {}), + }); + }; + + const tiles = webuiWorkspaceLauncherTiles() + .map((tile) => ({ + tile, + command: webuiWorkspaceLauncherTileCommand(tile, tab.id, { sessionId, workspaceDir }), + })) + .filter((entry) => entry.command !== undefined); + + return ( +
    +
    + {tiles.map(({ tile, command }) => ( + + ))} +
    +
    + setTyped(event.target.value)} + /> + +
    + {openBrowserWebTab ? null : ( +

    {STRIP_COPY.launcherUnavailable}

    + )} + {request?.error ? ( +

    + {request.error} +

    + ) : null} +
    + ); +} + +/** + * The URL box, as one transition: ask the runtime, then bind the page or say + * why there is none. + * + * The request is dispatched before the call rather than after it, because the + * runtime publishes the page on its own snapshot as well: the request is what + * tells the reducer which tab that page belongs to, and a snapshot that lands + * while the call is in flight must not open a second tab beside it. + * + * `open` is the browser business's port call, so a page typed by hand and a page + * opened by the Agent go through the same `open_tab` and the same refusal. The + * refusal is the runtime's own message, kept rather than replaced: a person who + * typed a url into a browser that is switched off is told the switch is off. + * + * `runId` is passed through rather than acted on. Naming the run is what lets the + * runtime apply the one bootstrap — the page joins the run's selection when the + * run has none — while a run that is already working on a page keeps it. What the + * person typed is a tab, and the tab is the client's cell either way. + * + * Exported for the test that drives the transition without a DOM — the + * component's only job here is the form around it. + */ +export async function webuiWorkspaceLauncherOpenPage(input: { + readonly tabId: string; + readonly url: string; + readonly dispatch: (command: WorkspacePanelCommand) => void; + readonly open: (request: { + readonly url: string; + readonly runId?: string; + }) => Promise<{ readonly target: WebuiBrowserTargetId }>; + readonly runId?: string; +}): Promise { + const { tabId, url, dispatch, open, runId } = input; + dispatch({ type: "request-web-page", tabId, url }); + try { + const { target } = await open({ url, ...(runId ? { runId } : {}) }); + // The title and the URL are the runtime's to report, and they arrive on the + // snapshot; what the answer carries is the identity this tab is bound to. + dispatch({ type: "open-web-tab", target, title: "", url, tabId }); + } catch (reason) { + dispatch({ + type: "request-web-page", + tabId, + url, + error: reason instanceof Error ? reason.message : String(reason), + }); + } +} + +/** + * The url the browser is told to open. + * + * A person types a host as often as a scheme, and a bare host is not something + * the browser can open — so the scheme is added here rather than answered with + * an error page. Anything that already carries one is passed as typed. + */ +export function webuiWorkspaceLauncherUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) return ""; + return /^[a-z][a-z0-9+.-]*:(?:\/\/|$)/iu.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +/** + * The command a tile opens, or `undefined` when it cannot open one. + * + * The registry row says what the tile opens; the tab says where: the launcher + * the tile is on. The changes tile keeps the requirements it had as a menu + * entry, which are the session and the working directory the change set + * belongs to — a tile that cannot open its view is not rendered rather than + * rendered dead. + */ +export function webuiWorkspaceLauncherTileCommand( + tile: WebuiWorkspaceLauncherTile, + tabId: string, + scope: { readonly sessionId?: string; readonly workspaceDir?: string }, +): WorkspacePanelCommand | undefined { + if (tile.opens.type === "open-workspace-review") { + const { sessionId: session, workspaceDir: dir } = scope; + return session && dir ? { ...tile.opens, sessionId: session, workspaceDir: dir, tabId } : undefined; + } + const { sessionId: session, workspaceDir: dir } = scope; + return { + ...tile.opens, + ...(session ? { sessionId: session } : {}), + ...(dir ? { workspaceDir: dir } : {}), + tabId, + }; +} + +/** The glyph a tile carries. The same icons the strip's own file tabs use. */ +function launcherTileGlyph(kind: WebuiWorkspaceLauncherTile["kind"]): ReactElement { + switch (kind) { + case "review": + return ; + case "terminal": + return ; + case "canvas": + return ; + default: + return ; + } +} diff --git a/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx b/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx index b56ea9cd9..36338c38f 100644 --- a/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx +++ b/packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx @@ -22,6 +22,12 @@ import { WebuiMarkdown } from "../../transcript/ui/markdown.public.js"; // The browser panel is another business's published surface. The workspace // may mount it and hand it a surface; it may not construct the browser // state itself, which is why the host component exists. +// +// The strip itself lives in `WebuiWorkspaceTabStrip.public.tsx` — this file +// is over the size that "everything about the panel" fits in, and the strip +// is the part that crosses into the browser business through its published +// port. The strip subscribes to `watchBrowserWebTabs` and, for the active +// web tab, this file mounts `BrowserPanelHost` with the tab's target. import { BrowserPanelHost } from "../../browser/ui/BrowserPanelHost.public.js"; import type { WebuiFileDiffInfoView } from "../../../../shared/contracts/session.js"; import type { @@ -33,14 +39,14 @@ import type { WebuiWorkspaceReviewSummary, } from "../../../../shared/contracts/review.js"; import type { - WebuiWorkspaceTabKind, + WebuiWorkspaceTabContent, WorkspacePanelCommand, WorkspacePanelState, WorkspacePanelTab, } from "../projection/workspace-panel-state.js"; import { - webuiWorkspaceAddMenu, - webuiWorkspaceTabLabel, + webuiWorkspaceReviewContent, + webuiWorkspaceTabContentKey, } from "../projection/workspace-panel-state.js"; import { focusWebuiFileLine, webuiFileLineTargetId } from "../../../bindings/browser-effects.js"; import { @@ -49,7 +55,7 @@ import { } from "../../terminal/projection/terminal-size.public.js"; import type { WebuiTerminalSize } from "../../terminal/contracts/terminal-port.js"; import type { WebuiTerminalOwner } from "../../terminal/contracts/terminal-owner.js"; -import { WebuiIconClose, WebuiIconDiffFile, WebuiIconFile, WebuiIconFolder, WebuiIconSearch, WebuiIconSidebarToggle, WebuiIconWorkspaceCanvas, WebuiIconWorkspaceExpand, WebuiIconWorkspaceReview, WebuiIconWorkspaceTerminal } from "../../../shell/icons.js"; +import { WebuiIconClose, WebuiIconDiffFile, WebuiIconFolder, WebuiIconSearch, WebuiIconSidebarToggle, WebuiIconWorkspaceExpand } from "../../../shell/icons.js"; import { FileTree, filterWorkspaceFiles, findWorkspaceFile, getWorkspaceFileParentPaths, mergeWorkspaceFileChildren } from "./WorkspaceFileTree.js"; import { WorkspaceMediaPreview } from "./WorkspaceMediaPreview.js"; import { isMediaContent } from "../projection/media-preview.js"; @@ -66,7 +72,7 @@ import { useWebuiWorkspaceQueriesState, } from "../../../bindings/use-query-state.js"; import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js"; -import { WebuiDropdown } from "../../../ui/Dropdown.js"; +import { WebuiWorkspaceLauncher, WebuiWorkspaceTabStrip } from "./WebuiWorkspaceTabStrip.public.js"; import { webuiFileLanguage, projectWebuiUnifiedDiffLines, isWorkspaceArchivePath, isWorkspaceHtmlPath } from "../projection/panel-projection.js"; // `mergeWorkspaceFileChildren`, `chunkWebuiWorkspaceReviewFileIds` and // `WEBUI_WORKSPACE_REVIEW_DIFF_BATCH_SIZE` are re-exported below: the panel @@ -142,36 +148,37 @@ export function WebuiDiffFileSection({ file, selected, loading = false, error, o ; } -export function WebuiFilePreview({ tab, result, codeMode, workspaceFileUrl, }: { - readonly tab: Extract; +export function WebuiFilePreview({ tabId, content, result, codeMode, workspaceFileUrl, }: { + readonly tabId: string; + readonly content: Extract; readonly result?: { readonly loading: boolean; readonly content?: WebuiWorkspaceFileContent; readonly error?: string }; readonly codeMode: boolean; readonly workspaceFileUrl?: (request: { readonly workspaceDir: string; readonly path: string }) => string; }): ReactElement { - const previewMarkdown = /\.md$/iu.test(tab.path) && !codeMode; - const content = result?.content?.content ?? ""; - const language = webuiFileLanguage(tab.path); + const previewMarkdown = /\.md$/iu.test(content.path) && !codeMode; + const text = result?.content?.content ?? ""; + const language = webuiFileLanguage(content.path); // Dispatched ahead of the read result on purpose: an archive never becomes // text, and an HTML preview is a stream the browser fetches rather than a // string the panel holds. Waiting on `readWorkspaceFile` first would either // flash a binary error or buffer a document nobody will read. - const archive = isWorkspaceArchivePath(tab.path) - ? + const archive = isWorkspaceArchivePath(content.path) + ? : undefined; - const html = !archive && isWorkspaceHtmlPath(tab.path) && workspaceFileUrl - ? + const html = !archive && isWorkspaceHtmlPath(content.path) && workspaceFileUrl + ? : undefined; - return
    + return
    {archive ?? html ?? (result?.loading ?

    正在加载文件…

    : result?.error ?

    {result.error}

    - : result?.content && isMediaContent(result.content, tab.path) ? + : result?.content && isMediaContent(result.content, content.path) ? : result?.content?.error ?

    {result.content.error}

    : result?.content?.type === "binary" ?

    无法在文本预览中显示二进制文件。

    - : result?.content ? previewMarkdown ? :
    {content.split("\n").map((line, index) => {
    +              : result?.content ? previewMarkdown ?  : 
    {text.split("\n").map((line, index) => {
                     const lineNumber = index + 1;
                     const highlighted = highlightFileLine(line, language);
    -                return = (tab.lineStart ?? 0) && lineNumber <= (tab.lineEnd ?? tab.lineStart ?? 0) ? "webui-file-preview-line-active" : ""}`} data-line-number={lineNumber} {...(highlighted !== undefined ? { dangerouslySetInnerHTML: { __html: highlighted || " " } } : {})}>{highlighted === undefined ? line || " " : undefined};
    +                return = (content.lineStart ?? 0) && lineNumber <= (content.lineEnd ?? content.lineStart ?? 0) ? "webui-file-preview-line-active" : ""}`} data-line-number={lineNumber} {...(highlighted !== undefined ? { dangerouslySetInnerHTML: { __html: highlighted || " " } } : {})}>{highlighted === undefined ? line || " " : undefined};
                   })}
    :

    文件读取能力暂不可用。

    )}
    ; @@ -203,8 +210,7 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, const reviewDiffs = queryState.reviewDiffs; const reviewSearch = queryState.reviewSearch; const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId); - const tab = activeTab?.kind ?? "files"; - const tabListRef = useRef(null); + const tab = activeTab?.content.kind ?? "files"; const fileTreeScrollRef = useRef(null); const autoNavigationRequests = useRef(new Set()); const [fileSearch, setFileSearch] = useState(""); @@ -285,12 +291,17 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, * grid it was fitted to. */ const reportedTerminalSize = useRef(); - const activeWorkspace = activeTab && "workspaceDir" in activeTab ? activeTab.workspaceDir : workspaceDir; + const activeWorkspace = activeTab && "workspaceDir" in activeTab.content ? activeTab.content.workspaceDir : workspaceDir; const activeWorkspaceRef = useRef(activeWorkspace); activeWorkspaceRef.current = activeWorkspace; - const activeFileResult = activeTab?.kind === "file-preview" ? fileResults.get(activeTab.id)?.content : undefined; - const selectedFilePath = activeTab?.kind === "file-preview" ? activeFileResult?.resolvedPath ?? activeTab.path : undefined; - const hasFileWorkspace = tab === "files" || activeTab?.kind === "file-preview" || activeTab?.kind === "review"; + const activeFilePath = activeTab?.content.kind === "file-preview" ? activeTab.content.path : undefined; + // The file results are keyed by what the tab is showing, not by which slot + // it is: the id is stable and the content is not, so a cache keyed by the id + // would hand the previous file's content to the new one. + const activeFileKey = activeTab?.content.kind === "file-preview" ? webuiWorkspaceTabContentKey(activeTab.content) : undefined; + const activeFileResult = activeTab?.content.kind === "file-preview" ? fileResults.get(activeFileKey!)?.content : undefined; + const selectedFilePath = activeTab?.content.kind === "file-preview" ? activeFileResult?.resolvedPath ?? activeTab.content.path : undefined; + const hasFileWorkspace = tab === "files" || activeTab?.content.kind === "file-preview" || activeTab?.content.kind === "review"; const visibleFiles = filterWorkspaceFiles(fileTree.files ?? [], fileSearch); const currentPanelState = useRef(state); currentPanelState.current = state; @@ -302,58 +313,62 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, setExpandedDirectories(new Set()); }, [activeWorkspace, queries]); useEffect(() => { - setFileCodeMode(activeTab?.kind === "file-preview" && activeTab.lineStart !== undefined); - }, [activeTab?.id]); - useEffect(() => { - tabListRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" }); + setFileCodeMode(activeTab?.content.kind === "file-preview" && activeTab.content.lineStart !== undefined); }, [activeTab?.id]); useEffect(() => { if (!activeWorkspace) return; queries?.loadFileTree(activeWorkspace); }, [activeWorkspace, queries]); useEffect(() => { - if (activeTab?.kind !== "file-preview" || !queries) return undefined; + if (activeTab?.content.kind !== "file-preview" || !queries) return undefined; // A tab opened with inline content (the session's plan file) has nothing // to read: `readWorkspaceFile` would reject the path anyway, since the // artifacts directory is outside the workspace root. - if (activeTab.content !== undefined) { - queries.setInlineFile(activeTab.id, activeTab.content, activeTab.path); + if (activeTab.content.content !== undefined) { + queries.setInlineFile(activeFileKey!, activeTab.content.content, activeTab.content.path); return undefined; } - queries.readFile(activeTab.id, activeTab.workspaceDir, activeTab.path); + queries.readFile(activeFileKey!, activeTab.content.workspaceDir, activeTab.content.path); return undefined; - }, [activeTab?.id, queries]); + // The cache key is the dependency, not the id: the id is stable and the + // content is not, so a tab that changed from one file to another has to + // read the new one. The content *object* is not a dependency — a content + // that is replaced by an equal one is the same file, and re-reading it + // would supersede the read that is already in flight. + }, [activeTab?.id, activeFileKey, queries]); useEffect(() => { - if (activeTab?.kind !== "file-preview" || activeTab.lineStart === undefined || fileResults.get(activeTab.id)?.loading || !fileResults.get(activeTab.id)?.content || fileResults.get(activeTab.id)?.error) return; - const target = dom.getElementById(webuiFileLineTargetId(activeTab.id, activeTab.lineStart)); + if (activeTab?.content.kind !== "file-preview" || activeTab.content.lineStart === undefined || fileResults.get(activeFileKey!)?.loading || !fileResults.get(activeFileKey!)?.content || fileResults.get(activeFileKey!)?.error) return; + const target = dom.getElementById(webuiFileLineTargetId(activeTab.id, activeTab.content.lineStart)); if (target) focusWebuiFileLine(target); - }, [activeTab?.id, activeTab?.kind === "file-preview" ? activeTab.lineStart : undefined, dom, fileResults]); + }, [activeTab?.id, activeFileKey, activeTab?.content.kind === "file-preview" ? activeTab.content.lineStart : undefined, dom, fileResults]); useEffect(() => { - if (activeTab?.kind !== "review" || activeTab.source !== "workspace" || !queries) return undefined; - void queries.loadReviewSummary(activeTab.id, activeTab.workspaceDir).then((summary) => { + if (activeTab?.content.kind !== "review" || activeTab.content.source !== "workspace" || !queries) return undefined; + void queries.loadReviewSummary(activeTab.id, activeTab.content.workspaceDir).then((summary) => { if (summary) dispatch({ type: "set-review-snapshot", tabId: activeTab.id, reviewSnapshotId: summary.reviewSnapshotId }); }); return undefined; - }, [activeTab?.id, activeTab?.kind === "review" && activeTab.source === "workspace" ? activeTab.workspaceDir : undefined, queries, queryState.reviewRevision]); - const workspaceReviewSelectedPath = activeTab?.kind === "review" && activeTab.source === "workspace" ? activeTab.selectedPath : undefined; + // The content is a dependency here too: a tab that changes from a turn's + // review to the workspace's review has to load the summary it now needs. + }, [activeTab?.id, activeTab?.content, activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? activeTab.content.workspaceDir : undefined, queries, queryState.reviewRevision]); + const workspaceReviewSelectedPath = activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? activeTab.content.selectedPath : undefined; const workspaceReviewFiles = reviewSummary !== undefined && reviewSummary.tabId === activeTab?.id ? reviewSummary.summary?.files ?? [] : []; - const turnReviewFiles = activeTab?.kind === "review" && activeTab.source === "turn" ? activeTab.files ?? [] : []; - const selectedReviewPath = activeTab?.kind === "review" && activeTab.source === "workspace" + const turnReviewFiles = activeTab?.content.kind === "review" && activeTab.content.source === "turn" ? activeTab.content.files ?? [] : []; + const selectedReviewPath = activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? workspaceReviewFiles.some((file) => file.path === workspaceReviewSelectedPath) ? workspaceReviewSelectedPath : workspaceReviewFiles[0]?.path - : activeTab?.kind === "review" && activeTab.source === "turn" ? activeTab.selectedPath ?? turnReviewFiles[0]?.file : undefined; + : activeTab?.content.kind === "review" && activeTab.content.source === "turn" ? activeTab.content.selectedPath ?? turnReviewFiles[0]?.file : undefined; useEffect(() => { - if (activeTab?.kind !== "review" || activeTab.source !== "workspace" || !selectedReviewPath || selectedReviewPath === activeTab.selectedPath) return; + if (activeTab?.content.kind !== "review" || activeTab.content.source !== "workspace" || !selectedReviewPath || selectedReviewPath === activeTab.content.selectedPath) return; dispatch({ type: "select-review-file", tabId: activeTab.id, path: selectedReviewPath }); - }, [activeTab?.id, activeTab?.kind === "review" && activeTab.source === "workspace" ? activeTab.selectedPath : undefined, selectedReviewPath, dispatch]); + }, [activeTab?.id, activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? activeTab.content.selectedPath : undefined, selectedReviewPath, dispatch]); useEffect(() => { const summary = reviewSummary && reviewSummary.tabId === activeTab?.id ? reviewSummary.summary : undefined; - if (activeTab?.kind !== "review" || activeTab.source !== "workspace" || !summary || !activeTab.reviewSnapshotId || summary.reviewSnapshotId !== activeTab.reviewSnapshotId || !queries) return undefined; - void queries.loadReviewDiffs({ tabId: activeTab.id, workspaceDir: activeTab.workspaceDir, snapshotId: activeTab.reviewSnapshotId, fileIds: summary.files.map((file) => file.fileId) }); + if (activeTab?.content.kind !== "review" || activeTab.content.source !== "workspace" || !summary || !activeTab.content.reviewSnapshotId || summary.reviewSnapshotId !== activeTab.content.reviewSnapshotId || !queries) return undefined; + void queries.loadReviewDiffs({ tabId: activeTab.id, workspaceDir: activeTab.content.workspaceDir, snapshotId: activeTab.content.reviewSnapshotId, fileIds: summary.files.map((file) => file.fileId) }); return undefined; - }, [activeTab?.id, activeTab?.kind === "review" && activeTab.source === "workspace" ? activeTab.reviewSnapshotId : undefined, activeTab?.kind === "review" && activeTab.source === "workspace" ? activeTab.workspaceDir : undefined, reviewSummary?.summary?.reviewSnapshotId, queries]); + }, [activeTab?.id, activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? activeTab.content.reviewSnapshotId : undefined, activeTab?.content.kind === "review" && activeTab.content.source === "workspace" ? activeTab.content.workspaceDir : undefined, reviewSummary?.summary?.reviewSnapshotId, queries]); const runReviewSearch = () => { - if (activeTab?.kind !== "review" || activeTab.source !== "workspace" || !activeTab.reviewSnapshotId || !queries) return; - void queries.searchReviewDiffs({ tabId: activeTab.id, workspaceDir: activeTab.workspaceDir, snapshotId: activeTab.reviewSnapshotId, query: reviewSearchQuery }); + if (activeTab?.content.kind !== "review" || activeTab.content.source !== "workspace" || !activeTab.content.reviewSnapshotId || !queries) return; + void queries.searchReviewDiffs({ tabId: activeTab.id, workspaceDir: activeTab.content.workspaceDir, snapshotId: activeTab.content.reviewSnapshotId, query: reviewSearchQuery }); }; const toggleWorkspaceDirectory = (directory: WebuiWorkspaceFile) => { const path = directory.path; @@ -371,14 +386,14 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, void queries.loadDirectory(activeWorkspace, path); }; useEffect(() => { - if (activeTab?.kind !== "file-preview" || !selectedFilePath || !activeWorkspace || !queries || fileTree.workspaceDir !== activeWorkspace || fileTree.loading) return; + if (activeTab?.content.kind !== "file-preview" || !selectedFilePath || !activeWorkspace || !queries || fileTree.workspaceDir !== activeWorkspace || fileTree.loading) return; let cancelled = false; const isCurrentFile = () => { const currentTab = currentPanelState.current.tabs.find((candidate) => candidate.id === currentPanelState.current.activeTabId); return activeWorkspaceRef.current === activeWorkspace - && currentTab?.kind === "file-preview" + && currentTab?.content.kind === "file-preview" && currentTab.id === activeTab.id - && (filesRef.current.get(currentTab.id)?.content?.resolvedPath ?? currentTab.path) === selectedFilePath; + && (filesRef.current.get(currentTab.id)?.content?.resolvedPath ?? currentTab.content.path) === selectedFilePath; }; const navigateToFile = async () => { setFileSearch(""); @@ -414,11 +429,12 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, }; void navigateToFile(); return () => { cancelled = true; }; - }, [activeTab?.id, activeTab?.kind === "file-preview" ? activeTab.path : undefined, selectedFilePath, activeWorkspace, fileTree.workspaceDir, fileTree.loading, queries]); + }, [activeTab?.id, activeTab?.content.kind === "file-preview" ? activeTab.content.path : undefined, selectedFilePath, activeWorkspace, fileTree.workspaceDir, fileTree.loading, queries]); useEffect(() => { fileTreeScrollRef.current?.querySelector('[aria-current="true"]')?.scrollIntoView({ block: "nearest", inline: "nearest" }); }, [selectedFilePath, selectedReviewPath, expandedDirectories, visibleFiles]); - const activeSessionId = activeTab && "sessionId" in activeTab ? activeTab.sessionId : sessionId; + const activeSessionId = activeTab && "sessionId" in activeTab.content ? activeTab.content.sessionId : sessionId; + const activeReview = webuiWorkspaceReviewContent(activeTab?.content); useEffect(() => { if (!terminalOwner) { setTerminals([]); @@ -487,67 +503,14 @@ export function WebuiWorkspacePanel({ state, dispatch, sessionId, workspaceDir, // its commands are stable, so a panel drag cannot re-run this effect and // rebuild a shell that is running. }, [terminalTargetId, terminalOwner, tab]); - // The label rule lives in the registry; the icon below is the host render - // branch the registry is allowed to have. - const tabLabel = (item: WorkspacePanelTab): string => webuiWorkspaceTabLabel(item); - // The registry carries the kinds, their words and how each opens; the glyphs are - // this file's. - const addMenuGlyph = (kind: WebuiWorkspaceTabKind) => { - switch (kind) { - case "review": - return ; - case "terminal": - return ; - case "canvas": - return ; - case "files": - return ; - default: - return null; - } - }; - const fileTabGlyph = (item: WorkspacePanelTab) => { - if (item.kind === "files") return ; - if (item.kind !== "file-preview") return null; - const extension = item.path.split(/[./\\]/u).at(-1)?.toLowerCase(); - if (extension === "md" || extension === "mdx") return ; - if (["js", "jsx", "mjs", "cjs"].includes(extension ?? "")) return ; - if (["ts", "tsx", "mts", "cts"].includes(extension ?? "")) return ; - if (extension === "json") return ; - return ; - }; + // The registry carries the kinds, their words and how each opens. Both the tab + // glyphs the strip needs and the launcher's tiles live in the strip itself: + // the new tab page took the place of this panel's add menu, which was the same + // four kinds behind a dropdown. return