diff --git a/.tickets/ticket-051-restore-host-setup-commands/prd.md b/.tickets/ticket-051-restore-host-setup-commands/prd.md new file mode 100644 index 00000000..895e7a67 --- /dev/null +++ b/.tickets/ticket-051-restore-host-setup-commands/prd.md @@ -0,0 +1,117 @@ +# Ticket 051 — Restore Host Setup Commands (Fix Gateway Button Regression) + +## 2.1 Problem Statement + +After v3.6.1, commit `93953d5` registered `openclaw.host.setup.local`, `openclaw.host.setup.docker`, and `openclaw.host.setup.ssh` inside the main `openclaw` extension (`apps/editor/extensions/openclaw/src/extension.ts:723-744`). Those same command IDs are already registered by three adapter extensions that ship pre-bundled in the fork: + +| Command | Registered by | Target | +|---|---|---| +| `openclaw.host.setup.local` | `openclaw-local/src/extension.ts:27` | `LocalSetupPanel` | +| `openclaw.host.setup.docker` | `openclaw-docker/src/extension.ts:90` | `DockerSetupPanel` | +| `openclaw.host.setup.ssh` | `openclaw-ssh/src/extension.ts:20` | `SSHSetupPanel` | + +Because the main extension activates first (the adapters declare `extensionDependencies: ["openclaw.home"]`), its generic handler wins and the subsequent adapter registrations throw `A command '…' already exists`. Two user-visible regressions follow: + +1. **Gateway button loop.** Clicking **Docker** (or **Local**) on the OCC Home host picker disposes the picker and runs `openclaw.host.setup.docker`. The winning (main-extension) handler just calls `HomePanel.createOrShow()`. `_update()` detects that Docker is running and re-renders the picker — the button visually "does nothing". +2. **Dedicated setup wizards are unreachable.** `LocalSetupPanel` / `DockerSetupPanel` / `SSHSetupPanel` are never opened because their adapter-registered commands are clobbered. The adapter `activate()` throws before later subscriptions run. + +Tag `v3.6.1` is the last known-good commit: only adapters registered these commands and they routed to the correct setup panels. + +## 2.2 Proposed Solution + +Remove the three `openclaw.host.setup.{local,docker,ssh}` registrations from `apps/editor/extensions/openclaw/src/extension.ts` (the block added by commit `93953d5`). The adapter extensions are authoritative. + +No other behavior change is needed. `routeHome()`, the home-panel host picker, and `openclaw.install` already `executeCommand('openclaw.host.setup.*')`, so once the adapter handlers are re-exposed they take over automatically. + +### Architecture (after fix) + +``` +HomePanel host picker (Docker/Local cards) + └─ vscode.commands.executeCommand('openclaw.host.setup.docker') + └─ openclaw-docker.activate() ← registers this command + └─ DockerSetupPanel.createOrShow() + +routeHome() binding=docker + └─ same path → DockerSetupPanel +``` + +## 2.3 Acceptance Criteria + +```gherkin +Feature: Host picker buttons route to the correct setup wizard + +Scenario: Clicking Docker on the host picker opens the Docker setup panel + Given the OCC Home host picker is visible + And the Docker container "occ-openclaw" is running + When the user clicks the "Docker" card + Then the Home panel disposes + And the Docker setup panel ("OpenClaw Docker Setup") opens + And the host picker does NOT re-appear + +Scenario: Clicking Local on the host picker opens the Local setup panel + Given the OCC Home host picker is visible + When the user clicks the "Local" card + Then the Home panel disposes + And the Local setup panel opens + +Scenario: routeHome with an existing binding opens the correct panel + Given the window has WindowHostBinding type="docker" + When the extension activates and calls routeHome + Then the Docker setup panel opens without showing the host picker + +Scenario: No duplicate command registration errors + Given all openclaw extensions have activated + When the extension host log is inspected + Then there are no "A command 'openclaw.host.setup.*' already exists" errors + And openclaw-local / openclaw-docker / openclaw-ssh report successful adapter registration +``` + +## 2.4 Technical Considerations + +- **No new code.** This is a surgical revert of the three command-registration blocks in the main extension. The adapter code already provides the correct behavior. +- **Activation order.** Main extension activates first; adapters depend on it and activate after. With the duplicates removed, adapter registrations succeed and their `activate()` completes (HostAdapter + setup command both registered). +- **No tests changed.** Existing Playwright onboarding-auth / docker-to-ide-flow tests cover this path at the UI level; verifying them passes is sufficient. +- **Commit `93953d5` message claims the commands were "never registered" — this was incorrect.** The adapters were (and still are) responsible for them. The duplicate made things worse, not better. +- **No effect on other commands.** `openclaw.install` at extension.ts:805-808 already delegates to `openclaw.host.setup.local` via `executeCommand`, which is unchanged. + +## 2.5 Dependencies + +- None. + +--- + +## Tasks + +- [ ] Task 1: Remove the duplicate host-setup command registrations + - **Problem**: `apps/editor/extensions/openclaw/src/extension.ts` currently registers `openclaw.host.setup.{local,docker,ssh}`, clobbering the adapter extensions. + - **Test**: `grep -n "openclaw.host.setup" apps/editor/extensions/openclaw/src/extension.ts` returns only the three `executeCommand` callsites (in `routeHome` and `openclaw.install`), no `registerCommand` lines. + - **Depends on**: None + - **Subtasks**: + - [ ] Subtask 1.1: Delete lines 720-744 (the 25-line block introduced by commit `93953d5`) from extension.ts + - **Objective**: Restore the pre-`93953d5` state of this file. + - **Test**: `git diff v3.6.1 -- apps/editor/extensions/openclaw/src/extension.ts` is empty. + - **Depends on**: None + +- [ ] Task 2: Recompile the extension and verify + - **Problem**: TypeScript output under `out/` must be regenerated so the running editor picks up the change. + - **Test**: `docker exec occ-editor-dev bash -c "cd /workspace/apps/editor/extensions/openclaw && npx tsc -p ./"` exits 0. + - **Depends on**: Task 1 + - **Subtasks**: + - [ ] Subtask 2.1: Recompile the `openclaw` extension inside the dev container + - **Objective**: Emit fresh `out/extension.js`. + - **Test**: `out/extension.js` mtime is newer than `src/extension.ts`. + - **Depends on**: Task 1 + - [ ] Subtask 2.2: Reload the extension host and click Docker on the host picker + - **Objective**: Confirm `DockerSetupPanel` opens instead of a blank re-render. + - **Test**: Visual / Playwright verification — the DockerSetupPanel tab replaces the picker. + - **Depends on**: Subtask 2.1 + +- [ ] Task 3: Run the relevant Playwright E2E suite + - **Problem**: Catch any surface regressions (home panel onboarding, docker-to-ide flow). + - **Test**: `npm run test:e2e -- --workers=1 tests/e2e/docker-to-ide-flow.spec.ts` passes. + - **Depends on**: Task 2 + - **Subtasks**: + - [ ] Subtask 3.1: Run docker-to-ide-flow spec + - **Objective**: Confirm no regressions in the Docker setup path. + - **Test**: All tests green. + - **Depends on**: Task 2 diff --git a/.tickets/ticket-052-docker-setup-flicker-reset/prd.md b/.tickets/ticket-052-docker-setup-flicker-reset/prd.md new file mode 100644 index 00000000..a78945c5 --- /dev/null +++ b/.tickets/ticket-052-docker-setup-flicker-reset/prd.md @@ -0,0 +1,251 @@ +# Ticket 052 — Docker Setup Flicker Reset to Step 1 + +## 2.1 Problem Statement + +After Docker setup completes and the gateway `/health` probe succeeds, the OCC Home webview flickers and the UI returns to **step 1 of the Docker setup wizard** instead of transitioning to the **Home → Status page**. The gateway is actually running, but users see a disorienting "reset" of their onboarding state. + +### State source of truth + +- The Docker wizard step is stored **client-side only** in the webview at `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:1686` (`let currentStep = 1;`). It is never persisted. +- Extension-side provisioning phase lives in `_configStep` at `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:63-64`. + +### Completion signal path + +- `_handleLaunchGateway()` at `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:910-975` probes `/health`, posts `{ type: 'launchDone' }` at line 970, and schedules `_showStatusPanel()` after 1800ms at line 971. +- `StatusPanelController` is created at `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:716-739`; its `.show()` swaps in the status HTML. + +### Reset path (prime suspect root cause) + +- `apps/editor/extensions/openclaw/src/panels/home.ts:139-141` wires an `onDidChangeViewState` listener that calls `_update()` on every visibility change. +- `apps/editor/extensions/openclaw/src/panels/home.ts:385-405`: `_update()` probes `isDockerRunning` and at line 405 executes: + + ```ts + if (isDockerRunning || this._forcePicker || (!isConfigured && !isGatewayReachable)) { + this._stopPolling(); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); + return; + } + ``` + + After setup completes with Docker running, a visibility change re-runs `_update()`, sees `isDockerRunning=true`, and overwrites the status page with the host-picker. That is the observed flicker-back-to-step-1. + +### Top hypotheses (ranked) + +1. **HIGH** — `home.ts` `onDidChangeViewState` → `_update()` → host-picker render races with `StatusPanelController.show()`. +2. **MEDIUM** — webview `postMessage` race: messages queued before `_statusController` is fully initialized in `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:154-217`. +3. **LOWER** — workspace-folder updates in `apps/editor/extensions/openclaw-docker/src/statusController.ts:256-258` triggering reload. Ticket-051 already killed one reload loop in this area, so this lane may already be mitigated. + +### Test gap + +`tests/e2e/docker-to-ide-flow.spec.ts` stops at "Connect to Gateway" and does not verify that the post-completion Status page persists across webview visibility changes. + +## 2.2 Proposed Solution + +Fix the category error in `home.ts` where "Docker container is up" is treated as "user hasn't chosen a host yet". The host-picker branch should only fire when the user has not yet configured a host — not whenever Docker happens to be running. + +### Canonical flow this ticket must honour + +See the updated startup flow diagram in [`docs/plans/multihost/08-ui-design.md`](../../docs/plans/multihost/08-ui-design.md) (Section 0). Two rules from that diagram are load-bearing for this fix: + +- **Rule 2 — "Setup completion re-enters detection, not the Status Panel directly."** The last step of the Docker Setup Wizard must loop back to the **Detect Gateway** node. The detection node owns the decision to render Status Panel vs. Setup View. Setup panels must not swap the UI to the Status page on their own. +- **Rule 4 — "No direct setup → Status jump."** The old edge from "gateway now running" directly into Status Panel is removed. This is exactly the edge `_handleLaunchGateway()` / `_showStatusPanel()` implement today, which races with `home.ts._update()` and produces the observed flicker. + +Note: the diagram also adds an **auth gate before gateway detection** (Rule 1). That is an adjacent concern and is out of scope for this ticket, but the fix here must not obstruct the later addition of the auth gate — i.e. the detection node should remain the single entrypoint that setup and disconnect loop back into. + +### High-level approach + +- Gate `apps/editor/extensions/openclaw/src/panels/home.ts` `_update()` so it does **not** re-render the host-picker while a `DockerSetupPanel` / `StatusPanelController` session is active, or when the gateway is already configured and reachable (regardless of `isDockerRunning`). +- Re-examine the condition at `home.ts:405`. Once configuration is complete, `isDockerRunning === true` should route to the **Status** view, not the **Host-Picker**. +- Route the Docker Setup Wizard's completion through the **Detect Gateway** node (i.e. trigger `home.ts._update()` / the detection/polling path) instead of having `_showStatusPanel()` unilaterally swap HTML from `setup-panel.ts`. This aligns the runtime behaviour with the diagram. +- Harden the race during `StatusPanelController` activation in `setup-panel.ts:154-217` so the completion signal cannot be lost if an `onDidChangeViewState` fires mid-init. +- Extend the Playwright E2E coverage past the gateway-connect step to assert the Status page is stable across tab hide/show. + +## 2.3 Acceptance Criteria + +```gherkin +Feature: Docker setup completes and stays on the Status page + +Scenario: Setup completion re-enters Detect Gateway (per docs/plans/multihost/08-ui-design.md §0 Rule 2) + Given the user has completed the final step of the Docker setup wizard + And the gateway /health probe returns 200 OK + When the wizard finishes + Then control loops back to the Detect Gateway node + And the detection node (not the setup panel) decides the next view + And the Status Panel is rendered because the gateway is reachable + And no direct setup-panel → Status HTML swap occurs + +Scenario: Gateway health check succeeds and Home shows Status (no flicker) + Given the user has completed the Docker setup wizard + And the gateway /health probe returns 200 OK + When _handleLaunchGateway posts { type: 'launchDone' } + And StatusPanelController.show() runs after its 1800ms delay + Then the Home panel displays the Status page + And the Home panel does NOT re-render the host-picker + And currentStep is not reset to 1 + +Scenario: Webview visibility change does not reset onboarding state + Given the Docker setup is complete and the Status page is visible + And isDockerRunning is true + And the gateway is configured and reachable + When the user switches to another tab and back (triggers onDidChangeViewState) + Then _update() does NOT render the host-type selection HTML + And the Status page remains visible + And no flicker to step 1 of the Docker wizard is observed + +Scenario: Concurrent StatusPanelController init survives a visibility event + Given _handleLaunchGateway has just posted launchDone + And StatusPanelController is mid-initialization + When onDidChangeViewState fires before _statusController is ready + Then the completion signal is NOT dropped + And once initialization finishes the Status page renders + And no host-picker render occurs + +Scenario: Playwright regression covers post-completion Status page + Given tests/e2e/docker-to-ide-flow.spec.ts runs end-to-end + When the gateway connect step succeeds + Then the test asserts the Status page is rendered + And the test toggles webview visibility at least once + And the Status page is still rendered afterward + And the test fails if the host-picker DOM appears +``` + +## 2.4 Technical Considerations + +- **Canonical flow reference.** `docs/plans/multihost/08-ui-design.md` §0 is the source of truth for the startup / setup-completion / disconnect loops. Any fix here must be consistent with Rules 2 and 4 (setup loops back into Detect Gateway; no direct setup → Status jump). +- **Auth gate adjacency.** The same diagram adds a sign-up / sign-in step before gateway detection (Rule 1). That work is out of scope here; this ticket must not bake in any shortcut that would make it harder to insert the auth gate in front of Detect Gateway later. +- **Scope narrow.** Primary surface is `apps/editor/extensions/openclaw/src/panels/home.ts` (condition at `:405`) plus a small race hardening in `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:154-217`. Avoid broader refactors of `StatusPanelController`. +- **State persistence.** `currentStep` at `setup-panel.ts:1686` is webview-local; nothing should attempt to persist it as part of this fix — the real issue is that the wrong HTML is being swapped in. +- **Reload-loop adjacency.** Ticket-051 already removed one reload loop in this area. Be careful not to reintroduce one when hardening the `_update()` gating. +- **Hot reload.** Per project memory, compile with `tsc` and reload the extension host — do NOT restart the dev container (clears nls.js, forces ~9-min recompile). +- **Test runner.** Playwright E2E spec already has webview iframe helpers; extend `tests/e2e/docker-to-ide-flow.spec.ts` rather than creating a new spec file. + +## 2.5 Dependencies + +- **Related — ticket-051 (`restore-host-setup-commands`, merged).** Fixed a reload loop in the overlapping `statusController.ts:256-258` area. No hard block, but any new gating logic must not conflict with the ticket-051 change. +- **Related — ticket-049 (`gateway-ui-and-test-optimization`, merged).** Gateway UI work touched adjacent surfaces. No hard block. +- No other ticket dependencies. + +--- + +## Tasks + +- [x] Task 1: Confirm root cause and fix the `home.ts` host-picker gating + - **Problem**: `apps/editor/extensions/openclaw/src/panels/home.ts:405` renders the host-picker whenever `isDockerRunning` is true, even after setup has finished and the Status page is active. Every `onDidChangeViewState` (`home.ts:139-141`) triggers `_update()` and overwrites the Status page. + - **Test**: + ```gherkin + Given the Docker setup has completed and StatusPanelController.show() has rendered the Status page + And isDockerRunning is true and the gateway is configured and reachable + When onDidChangeViewState fires on the Home panel + Then _update() does not execute the host-picker branch at home.ts:405 + And the Status page HTML is preserved + ``` + - **Depends on**: None + - **Subtasks**: + - [x] Subtask 1.1: Reproduce the flicker with instrumentation + - **Objective**: Add temporary trace logs around `home.ts:385-405` and `setup-panel.ts:910-975` to confirm the `_update()` → host-picker render runs after `launchDone`. + - **Test**: + ```gherkin + Given trace logs are enabled + When the Docker setup finishes end-to-end + Then the logs show _handleLaunchGateway posting launchDone + And the logs show _update() entering the isDockerRunning branch afterwards + And the logs show _getHostTypeSelectionHtml being assigned to the webview + ``` + - **Depends on**: None + - [x] Subtask 1.2: Rework the `home.ts:405` condition + - **Objective**: Change the host-picker gate so it only fires when the user has not yet configured a host. "Docker is running AND gateway is configured AND gateway is reachable" must route to the Status view, not the host-picker. + - **Test**: + ```gherkin + Given isConfigured is true and isGatewayReachable is true + When _update() runs with isDockerRunning=true and _forcePicker=false + Then _update() does NOT render the host-type selection HTML + And _update() renders the Status / connected view instead + ``` + - **Depends on**: Subtask 1.1 + - [x] Subtask 1.3: Remove the temporary trace logs + - **Objective**: Keep production logs quiet. + - **Test**: + ```gherkin + Given the fix is verified + When the final diff is reviewed + Then no temporary trace logs remain in home.ts or setup-panel.ts + ``` + - **Depends on**: Subtask 1.2 + +- [x] Task 2: Harden the race during `StatusPanelController` activation + - **Problem**: In `apps/editor/extensions/openclaw-docker/src/setup-panel.ts:154-217`, messages can be queued before `_statusController` is fully initialized. If an `onDidChangeViewState` fires during this window, the completion signal can be dropped and the user lands on an uninitialized state that the Home panel then overwrites with the host-picker. + - **Test**: + ```gherkin + Given _handleLaunchGateway has posted launchDone + And StatusPanelController is still initializing + When onDidChangeViewState fires on the Home panel before init completes + Then the completion signal is buffered or replayed once _statusController is ready + And the Status page is rendered after init completes + And no host-picker render occurs + ``` + - **Depends on**: Task 1 + - **Subtasks**: + - [x] Subtask 2.1: Buffer / defer postMessage handling until `_statusController` is ready + - **Objective**: Ensure any message arriving during the init window (`setup-panel.ts:154-217`) is processed after initialization, not dropped. + - **Test**: + ```gherkin + Given a unit-level simulation posts launchDone during controller init + When initialization completes + Then the buffered launchDone is processed exactly once + And StatusPanelController.show() is invoked + ``` + - **Depends on**: Task 1 + - [x] Subtask 2.2: Audit `statusController.ts:256-258` workspace-folder updates for reload regression + - **Objective**: Verify the hardening does NOT reintroduce the reload loop that ticket-051 killed. + - **Test**: + ```gherkin + Given the hardened controller is running + When Docker setup completes and the Status page renders + Then no workspace-folder update triggers a window reload + And the existing ticket-051 regression check still passes + ``` + - **Depends on**: Subtask 2.1 + +- [-] Task 3: Playwright regression test for post-completion Status page stability + - **Problem**: `tests/e2e/docker-to-ide-flow.spec.ts` stops at "Connect to Gateway" and does not verify the Status page persists across visibility changes. Without coverage, a future regression would silently reintroduce the flicker. + - **Test**: + ```gherkin + Given tests/e2e/docker-to-ide-flow.spec.ts is executed + When the spec runs to completion + Then it asserts the Status page is rendered after gateway connect + And it toggles webview visibility at least once + And it asserts the Status page is still rendered afterwards + And it fails if the host-picker DOM appears after completion + ``` + - **Depends on**: Task 1 + - **Subtasks**: + - [x] Subtask 3.1: Extend `docker-to-ide-flow.spec.ts` past the gateway-connect step + - **Objective**: Add assertions for Status-page presence immediately after `_handleLaunchGateway` completes its 1800ms `_showStatusPanel()` schedule. + - **Test**: + ```gherkin + Given the spec has just completed Connect to Gateway + When the test waits past 1800ms + Then the Status page selector is present in the webview iframe + And the host-picker selector is absent + ``` + - **Depends on**: Task 1 + - [x] Subtask 3.2: Add a visibility-toggle assertion + - **Objective**: Exercise `onDidChangeViewState` by hiding and re-showing the Home webview, then re-assert the Status page. + - **Test**: + ```gherkin + Given the Status page is rendered + When the test toggles the webview tab away and back + Then the Status page selector is still present + And the host-picker selector is still absent + ``` + - **Depends on**: Subtask 3.1 + - [-] Subtask 3.3: Run the extended spec under the standard E2E harness + - **Objective**: Confirm the new assertions pass on a clean run. + - **Test**: + ```gherkin + Given npm run test:e2e -- --workers=1 tests/e2e/docker-to-ide-flow.spec.ts is executed + When the run completes + Then all tests are green + And the new assertions are included in the report + ``` + - **Depends on**: Subtask 3.2 diff --git a/.tickets/ticket-053-persist-host-choice-and-gateway-control/prd.md b/.tickets/ticket-053-persist-host-choice-and-gateway-control/prd.md new file mode 100644 index 00000000..f02c1170 --- /dev/null +++ b/.tickets/ticket-053-persist-host-choice-and-gateway-control/prd.md @@ -0,0 +1,412 @@ +# Ticket 053 — Persist Host Choice and Surface Gateway Control + +## 2.1 Problem Statement + +Once ticket-052 landed, `HomePanel._update()` no longer flickers back to the host-picker when the gateway *is* reachable. But the picker gate at `apps/editor/extensions/openclaw/src/panels/home.ts:454` still routes entirely off a fresh probe of gateway reachability and `isConfigured` (derived from `this._host.exists(configFile)` at `panels/home.ts:367-368`). That means: + +- **User is stuck on the picker whenever the gateway happens to be down.** After a user finishes the Docker setup wizard and picks Docker as their host, if they later kill the `occ-openclaw` container (or the machine reboots and docker desktop isn't running yet), `_update()` sees `!isConfigured || !isGatewayReachable` and renders the host-picker **as if the user never chose a host**. The user's explicit choice is lost every time the gateway dies. +- **"Default local" is indistinguishable from "chose local".** `HostRegistry.getActiveHostId()` at `registry.ts:202-204` returns `this._hostsFile?.activeHostId ?? 'local'`. The seed at `registry.ts:42-48` also initialises `activeHostId: 'local'` before any user interaction. Nothing in the schema records whether the user ever completed a setup, so we cannot safely say "user picked local" from `activeHostId === 'local'` alone. +- **No Start/Stop/Restart affordances in the Status panel when the gateway is down.** `HostConnection` already declares `gatewayStart/Stop/Restart` at `hosts/types.ts:256-258`, and `statusHtml.ts:1214-1219` has the button state table (`Running → Stop`, `Stopped → Start`, `Errored → Restart`) with `gw-start/gw-stop/gw-restart` CSS classes and a `gatewayAction` postMessage handler at `statusHtml.ts:1256`. But the extension side never receives those messages as commands — there is no `openclaw.gateway.start/stop/restart` registration, and the Docker adapter's `gatewayStart/Stop/Restart` implementation needs to be verified to shell out to the canonical compose command. +- **No escape hatch when an adapter breaks.** If Docker is uninstalled / the compose file is deleted / `hosts.json` is corrupted, a persisted host choice would keep the user pinned to a broken Status view forever. We need an explicit "Reconfigure / Pick Different Host" action that clears the persisted choice. + +## 2.2 Proposed Solution + +Make the Home panel route on **"did the user complete a setup?"** — not on "can I reach the gateway right now?". Reachability only decides between `Status (online)` and `Status (offline, with Start/Stop/Restart controls)`. + +See the revised startup flow diagram in [`docs/plans/multihost/08-ui-design.md`](../../docs/plans/multihost/08-ui-design.md) §0 and the new section §0a "Status Panel — Offline & Control" for the canonical flow this ticket must honour. The key invariant this ticket adds is the new flow rule: + +> **Persisted host choice beats gateway reachability for view routing.** The picker only appears when the user has never completed a setup, or has explicitly invoked `openclaw.host.reconfigure`. + +High-level: + +1. Record an **explicit-choice marker** at the end of each setup wizard's success path, before dispatching `openclaw.home.refresh`. +2. Replace the `isConfigured` derivation in `HomePanel._update()` with a read of that marker plus the HostRegistry. +3. Wire per-adapter gateway control (`gatewayStart/Stop/Restart`) through to Status-panel buttons that already exist in the HTML template. +4. Add a Reconfigure escape hatch that clears the marker and loops back into the picker via `_forcePicker + openclaw.home.refresh`. +5. Extend the Playwright coverage added in ticket-052 to lock in the four new invariants. + +## 2.3 Acceptance Criteria + +```gherkin +Feature: Persisted host choice survives gateway outages and gives the user control over the gateway + +Scenario: Setup completion persists the user's host choice + Given the user has just finished the Docker setup wizard + And the gateway /health probe returned 200 OK + When _handleLaunchGateway reaches its success path + Then the active host id in hosts.json is set to the chosen docker host + And an explicit-choice marker is written before openclaw.home.refresh is dispatched + And HomePanel._update() reads the marker and renders the Status page + +Scenario: Persisted choice beats gateway-down on reload + Given a user has previously completed Docker setup + And the explicit-choice marker is true + And the occ-openclaw container is not running + When the editor is reloaded and HomePanel._update() runs + Then the Status page is rendered (in its "offline" variant) + And the host-picker is NOT rendered + And the Start Gateway button is visible + +Scenario: Status panel Start button starts the gateway via the active adapter + Given the Status page is rendered in offline state + And the active host is Docker + When the user clicks Start + Then the extension executes openclaw.gateway.start + And the command calls activeHost.gatewayStart(onLog) + And on the Docker adapter that shells to `docker compose -f docker/docker-compose.openclaw.yml up -d` + And after success the Status page transitions to the running state + +Scenario: Status panel Stop and Restart buttons mirror Start wiring + Given the Status page is rendered in running state + When the user clicks Stop + Then openclaw.gateway.stop is dispatched + And activeHost.gatewayStop runs the adapter's stop command + And the Status page transitions to the stopped state + When the user then clicks Restart on the Errored state + Then openclaw.gateway.restart is dispatched + And the adapter's restart command runs + +Scenario: Reconfigure escape hatch returns the user to the picker + Given the Status page is rendered + And the active adapter is broken (e.g. Docker binary missing) + When the user clicks "Pick Different Host" / "Reconfigure" + Then openclaw.host.reconfigure is dispatched + And the explicit-choice marker is cleared + And HomePanel._forcePicker is set to true + And openclaw.home.refresh is dispatched + And the host-picker is rendered on the next _update() + +Scenario: Default local is NOT treated as an explicit choice + Given a fresh install with no hosts.json + And HostRegistry seeds activeHostId = "local" as default + And no setup wizard has been completed + When HomePanel._update() runs + Then the explicit-choice marker is absent / false + And the host-picker is rendered + And the Status page is NOT rendered + +Scenario: Playwright regression covers all four invariants + Given tests/e2e/docker-to-ide-flow.spec.ts runs + Then it asserts the explicit-choice marker is written after gateway-connect + And it asserts a simulated editor reload with gateway down lands on the Status page (offline) + And it asserts Start / Stop / Restart buttons dispatch the new commands + And it asserts Reconfigure returns to the picker + And the ticket-052 "no flicker" assertion still passes +``` + +## 2.4 Technical Considerations + +### Schema: how to record "user explicitly chose a host" + +`HostRegistry.getActiveHostId()` falling back to `'local'` (`registry.ts:45, 108-109, 202-203, 233`) collapses two distinct states — "we seeded a default for you" vs. "you finished setup and picked local" — into one id. We need to disambiguate them without silently breaking existing `hosts.json` files on disk. + +**Pinned approach.** The setup choices are mutually exclusive (a user picks exactly one of Local / Docker / SSH), so the right encoding is a TypeScript literal-union const type on the `HostsFile` root — not a separate boolean, not a timestamp on each `HostEntry`. Presence of the field means "user has explicitly completed setup for this type"; absence means "default fallback, show picker". + +```ts +// apps/editor/extensions/openclaw/src/hosts/types.ts +export const HOST_CHOICE_TYPES = ['local', 'docker', 'ssh'] as const; +export type HostChoiceType = typeof HOST_CHOICE_TYPES[number]; + +export interface HostsFile { + activeHostId: string; + chosenHostType?: HostChoiceType; // absent → no explicit choice yet + hosts: HostEntry[]; + // ...existing fields +} +``` + +- **Writing.** Setup wizards call a new `HostRegistry.markActiveHostChosen(type: HostChoiceType)` on success, which sets `hostsFile.chosenHostType = type` and persists. +- **Reading.** `HomePanel._update()` reads `chosenHostType`. Absent → picker. Present → Status (online or offline per gateway reachability). +- **Clearing.** `openclaw.host.reconfigure` (Task 4) deletes `chosenHostType` and re-runs `openclaw.home.refresh`. +- **Migration.** None. Existing `hosts.json` files have no `chosenHostType` field; absent reads as `undefined`, which correctly means "show picker". First successful setup writes the field. + +**Alternatives considered and rejected:** + +- **Rename the default id from `'local'` to `'local-default'`.** Breaking schema change against every existing `hosts.json` and every hardcoded `'local'` literal at `registry.ts:109, 203, 233`. Too invasive. +- **Single boolean (`explicitChoice: boolean`).** Doesn't capture *which* type was chosen — needs a parallel field for that and couples two pieces of state. Weaker than a literal-union type. +- **Per-host `setupCompletedAt: string` on each `HostEntry`.** Richer but over-specified: in the current single-active-host model we only care "did the user ever complete a setup, and which type". Revisit if/when multi-host ships and multiple hosts can be independently configured. +- **VS Code `globalState` boolean.** Loses host-type fidelity; regresses as soon as there's more than one host type. + +### Other considerations + +- **Awaiting the persistence write.** `HostRegistry.setActiveHostId()` at `registry.ts:240-246` is currently synchronous (it calls `this._persist()` which writes with `fs.writeFileSync`). But `HostManager.setActiveHost()` at `manager.ts:136` is async and may do additional I/O. `_handleLaunchGateway()` in `apps/editor/extensions/openclaw-docker/src/setup-panel.ts` must `await` both the `setActiveHost` call and the new `markActiveHostChosen`-equivalent before dispatching `openclaw.home.refresh`, otherwise `HomePanel._update()` races and reads the stale value. +- **Corrupted `hosts.json` or deleted compose file.** If a user's `hosts.json` is corrupted, `_readFromDisk()` at `registry.ts:116-123` falls back to the seed and the explicit-choice marker is lost. That is the correct behaviour (treat as fresh install → picker). Task 4's Reconfigure button is the escape hatch when the adapter itself is broken but the marker is still set. +- **`gatewayStart/Stop/Restart` must not race with the setup wizard's own compose commands.** The Docker adapter's start/stop/restart will shell to the same `docker compose -f docker/docker-compose.openclaw.yml ...` invocations the setup wizard uses. During the setup flow the buttons should either be absent (the Status panel isn't rendered yet) or disabled; this ticket's scope is the Status panel's own use, not the wizard's. +- **Do not regress ticket-052's no-flicker assertion.** The new `isConfigured`-replacement logic must still return the same view decision for the "setup just completed + gateway reachable" case. The ticket-052 Playwright spec must pass unchanged when this ticket is merged. +- **Scope.** Primary surface: + - `apps/editor/extensions/openclaw/src/hosts/registry.ts` (new marker field + methods) + - `apps/editor/extensions/openclaw/src/hosts/types.ts` (schema additions) + - `apps/editor/extensions/openclaw/src/panels/home.ts:365-459` (rewrite `isConfigured` derivation and picker gate) + - `apps/editor/extensions/openclaw/src/panels/statusHtml.ts` (Reconfigure button, already has Start/Stop/Restart template at `:1214-1219`, `:1256`) + - `apps/editor/extensions/openclaw-docker/src/setup-panel.ts` (`_handleLaunchGateway` success path — add `setActiveHost` + `markActiveHostChosen` awaits before `openclaw.home.refresh` at ~`:1047`) + - `apps/editor/extensions/openclaw-local/src/setup-panel.ts` (same pattern for local setup completion) + - New command registrations: `openclaw.gateway.start`, `openclaw.gateway.stop`, `openclaw.gateway.restart`, `openclaw.host.reconfigure` +- **Hot reload.** Per `CLAUDE.md` project memory: compile with `tsc` and reload the extension host. Do NOT restart the dev container. + +## 2.5 Dependencies + +- **Depends on ticket-052 (merged).** ticket-052 rewired `DockerSetupPanel._handleLaunchGateway()` to dispatch `openclaw.home.refresh` as its final step (`apps/editor/extensions/openclaw-docker/src/setup-panel.ts:~1047`) and owns the `openclaw.home.refresh` command on the `HomePanel` side (`panels/home.ts` constructor). Task 1 hooks into that same success path. Task 2 reworks the same `_update()` gate that ticket-052 already narrowed at `home.ts:454`. +- **Related — auth gate landed in parallel** (`apps/editor/extensions/openclaw/src/authGate.ts`). The new `openclaw.home.refresh` path must remain the single entrypoint downstream of the auth gate — this ticket does not change that. + +--- + +## Tasks + +- [x] Task 1: Persist the host choice at setup completion + - **Problem**: When a setup wizard finishes successfully today, nothing records that the user explicitly chose this host. `HostRegistry.getActiveHostId()` at `registry.ts:202-204` always falls back to `'local'`, so "default" and "chosen" are the same state. We need the `chosenHostType: HostChoiceType` marker on `HostsFile` (see §2.4) written before the existing `openclaw.home.refresh` dispatch at `setup-panel.ts:~1047` so `HomePanel._update()` can route off persisted intent rather than live reachability. + - **Test**: + ```gherkin + Given the user has just completed the final step of the Docker setup wizard + And the gateway /health probe returned 200 OK + When _handleLaunchGateway's success path runs + Then HostManager.setActiveHost() is awaited + And HostRegistry.markActiveHostChosen('docker') is awaited + And both writes complete before vscode.commands.executeCommand('openclaw.home.refresh') is dispatched + And the subsequent HomePanel._update() reads chosenHostType as 'docker' + ``` + - **Depends on**: None + - **Subtasks**: + - [x] Subtask 1.1: Extend `hosts/types.ts` with the `HostChoiceType` const-union and add `chosenHostType` to `HostsFile` + - **Objective**: Add `export const HOST_CHOICE_TYPES = ['local', 'docker', 'ssh'] as const` and `export type HostChoiceType = typeof HOST_CHOICE_TYPES[number]` to `apps/editor/extensions/openclaw/src/hosts/types.ts`. Add the optional `chosenHostType?: HostChoiceType` field to `HostsFile`. Teach `HostRegistry._readFromDisk()` / `_persist()` / `makeEmptyHostsFile()` to round-trip it, and add `markActiveHostChosen(type: HostChoiceType)` and `clearActiveHostChoice()` methods on `HostRegistry` (with pass-throughs on `HostManager`). + - **Test**: + ```gherkin + Given a fresh hosts.json written by the old registry code (no chosenHostType field) + When the new registry reads it + Then HostRegistry.getChosenHostType() returns undefined + And calling markActiveHostChosen('docker') persists chosenHostType = 'docker' + And reading hosts.json from disk shows the new field with value 'docker' + And calling clearActiveHostChoice() removes the field and persists + ``` + - **Depends on**: None + - [x] Subtask 1.2: Wire `setActiveHost` + `markActiveHostChosen` into `DockerSetupPanel._handleLaunchGateway` success path + - **Objective**: In `apps/editor/extensions/openclaw-docker/src/setup-panel.ts` (around `:1047`), after the `/health` probe succeeds and before the `openclaw.home.refresh` dispatch, await `coreApi.setActiveHost()` and `registry.markActiveHostChosen('docker')`. Do NOT dispatch `openclaw.home.refresh` until both awaits resolve. + - **Test**: + ```gherkin + Given _handleLaunchGateway has just confirmed /health returns 200 + When the success branch runs + Then setActiveHost is awaited + And markActiveHostChosen is awaited + And only then does vscode.commands.executeCommand('openclaw.home.refresh') run + And on the next HomePanel._update() the Status page is rendered + ``` + - **Depends on**: Subtask 1.1 + - [x] Subtask 1.3: Mirror the same wiring for `LocalSetupPanel` + - **Objective**: Apply the same "await setActiveHost + markActiveHostChosen before openclaw.home.refresh" pattern in `apps/editor/extensions/openclaw-local/src/setup-panel.ts` at its gateway-completion equivalent. + - **Test**: + ```gherkin + Given a user completes the local setup flow end-to-end + When the local success path runs + Then markActiveHostChosen('local') is awaited and chosenHostType is persisted as 'local' + And openclaw.home.refresh is dispatched only after the marker write + And the subsequent _update() renders the local Status page (not the picker) + ``` + - **Depends on**: Subtask 1.1 + +- [-] Task 2: Redefine `HomePanel._update()` routing — persisted choice beats gateway reachability + - **Problem**: `panels/home.ts:367-368` derives `isConfigured` from `this._host.exists(configFile)` and `:440-442, :454` routes to the picker whenever `!isConfigured && !isGatewayReachable`. That means a completed-setup user lands back on the picker every time the gateway happens to be down. The picker gate should only fire when the user has NEVER completed a setup. + - **Test**: + ```gherkin + Given the explicit-choice marker is true (user completed setup previously) + And isGatewayReachable is false (gateway down right now) + When _update() runs with _forcePicker=false + Then the host-picker branch at home.ts:454 does NOT fire + And the Status page is rendered in its offline variant + And the Start Gateway button is visible + ``` + - **Depends on**: Task 1 + - **Subtasks**: + - [x] Subtask 2.1: Replace `isConfigured` with a persisted-choice read + - **Objective**: In `HomePanel._update()` at `panels/home.ts:365-459`, stop deriving the routing signal from `this._host.exists(configFile)`. Instead read `registry.getActiveHostId()` + the explicit-choice marker and compute an `isExplicitlyChosen` boolean. Keep the existing `cfg`/port reading but gate it on `isExplicitlyChosen` rather than `isConfigured`. + - **Test**: + ```gherkin + Given the explicit-choice marker is false and no openclaw.json exists + When _update() runs + Then isExplicitlyChosen is false + And the picker is rendered + Given the explicit-choice marker is true + When _update() runs (regardless of isGatewayReachable) + Then isExplicitlyChosen is true + And the picker is NOT rendered + ``` + - **Depends on**: Subtask 1.1 + - [x] Subtask 2.2: Rewrite the picker gate at `home.ts:454` + - **Objective**: Change `if (this._forcePicker || (!isConfigured && !isGatewayReachable))` to `if (this._forcePicker || !isExplicitlyChosen)`. Drop the `!isGatewayReachable` clause — reachability no longer decides picker-vs-Status, only online-vs-offline within Status. + - **Test**: + ```gherkin + Given _forcePicker is false and isExplicitlyChosen is true + And isGatewayReachable is false + When _update() runs + Then the picker HTML is NOT assigned to the webview + And the Status-panel offline variant is rendered instead + Given _forcePicker is true + When _update() runs + Then the picker is rendered (escape hatch still works) + ``` + - **Depends on**: Subtask 2.1 + - [-] Subtask 2.3: Verify ticket-052's no-flicker Playwright assertion still passes + - **Objective**: Run `tests/e2e/docker-to-ide-flow.spec.ts` end-to-end locally and confirm the ticket-052 post-completion / visibility-toggle assertions are still green. The new gate must not weaken them. + - **Test**: + ```gherkin + Given ticket-052's Playwright spec runs against this branch + When the full spec completes + Then the no-flicker assertion passes + And the visibility-toggle assertion passes + ``` + - **Depends on**: Subtask 2.2 + +- [x] Task 3: Wire per-adapter gateway control and Status panel Start/Stop/Restart + - **Problem**: `HostConnection` declares `gatewayStart/Stop/Restart` at `hosts/types.ts:256-258`. `statusHtml.ts:1214-1219, :1256` already contains the Start/Stop/Restart button template and a `gatewayAction` postMessage, but the extension-side never receives it as a registered command, and we have not confirmed that `DockerHostConnection.gatewayStart/Stop/Restart` actually shells to the canonical compose commands per root `AGENTS.md` § "OpenClaw Docker Gateway". + - **Test**: + ```gherkin + Given the Status page is rendered + When the user clicks Start / Stop / Restart + Then the webview posts { command: 'gatewayAction', action: 'start' | 'stop' | 'restart' } + And HomePanel's message handler dispatches openclaw.gateway.{start|stop|restart} + And that command resolves activeHost.gateway{Start|Stop|Restart}(onLog) + And on a Docker active host the adapter runs `docker compose -f docker/docker-compose.openclaw.yml up -d / down / restart` + And the Status page transitions through starting → running (or stopping → stopped) via the existing state machine at statusHtml.ts:1214-1219 + ``` + - **Depends on**: Task 2 + - **Subtasks**: + - [x] Subtask 3.1: Audit `DockerHostConnection` and `LocalHostConnection` gateway-control implementations + - **Objective**: Verify the three methods exist and exercise the canonical commands. Docker must shell to `docker compose -f docker/docker-compose.openclaw.yml up -d / down / restart` (see root `AGENTS.md` § "OpenClaw Docker Gateway"). Local adapter should shell to `openclaw gateway start/stop/restart` per [`docs/plans/multihost/04-local-adapter.md`](../../docs/plans/multihost/04-local-adapter.md). Fill in any missing implementation. + - **Test**: + ```gherkin + Given a unit-level harness calls activeHost.gatewayStart(onLog) on a Docker adapter + Then the adapter invokes `docker compose -f docker/docker-compose.openclaw.yml up -d` + Given the same call on a local adapter + Then the adapter invokes `openclaw gateway start` + ``` + - **Depends on**: None + - [x] Subtask 3.2: Register `openclaw.gateway.start/stop/restart` commands + - **Objective**: In the core extension (`apps/editor/extensions/openclaw/src/extension.ts` or `panels/home.ts`, wherever `openclaw.home.refresh` is registered per ticket-052), register three new commands that each resolve the active `HostConnection` via the `OpenClawCoreAPI` and call the matching `gatewayStart/Stop/Restart(onLog)` method. Stream the log to the existing output channel. + - **Test**: + ```gherkin + Given the extension is activated + When vscode.commands.executeCommand('openclaw.gateway.start') runs + Then the active host's gatewayStart is called + And the returned promise resolves after the shell command completes + ``` + - **Depends on**: Subtask 3.1 + - [x] Subtask 3.3: Hook the `gatewayAction` postMessage to the new commands + - **Objective**: In `HomePanel`'s webview message handler (the block around `panels/home.ts:300+` that already routes `chooseHostType` etc.), add a case for `msg.command === 'gatewayAction'` that switches on `msg.action` and dispatches `openclaw.gateway.start/stop/restart`. After the command resolves, call `HomePanel.refresh()` so the state machine transitions. + - **Test**: + ```gherkin + Given the Status page posts { command: 'gatewayAction', action: 'start' } + When HomePanel's message handler runs + Then openclaw.gateway.start is dispatched + And after it resolves, _update() runs and the Status page re-renders in the running state + ``` + - **Depends on**: Subtask 3.2 + +- [x] Task 4: Reconfigure / Pick Different Host escape hatch + - **Problem**: Once the explicit-choice marker is set, a user with a broken adapter (Docker uninstalled, compose file deleted, etc.) has no way to return to the picker. Without an explicit escape, Task 2's new gate would trap them on a Status page that cannot recover. + - **Test**: + ```gherkin + Given the Status page is rendered with a broken adapter (gatewayStart returns an error) + When the user clicks "Pick Different Host" + Then openclaw.host.reconfigure is dispatched + And the explicit-choice marker is cleared + And HomePanel._forcePicker is set to true + And openclaw.home.refresh is dispatched + And the next _update() renders the host-picker + ``` + - **Depends on**: Task 2 + - **Subtasks**: + - [x] Subtask 4.1: Add a "Reconfigure / Pick Different Host" button to `statusHtml.ts` + - **Objective**: Add a small secondary button in the Status panel (near the gateway-control row) that posts `{ command: 'reconfigure' }` to the extension. + - **Test**: + ```gherkin + Given the Status page is rendered + Then a "Pick Different Host" button is visible + When the user clicks it + Then vscode.postMessage({ command: 'reconfigure' }) is posted + ``` + - **Depends on**: None + - [x] Subtask 4.2: Register and implement `openclaw.host.reconfigure` + - **Objective**: New command. Clears the explicit-choice marker via the registry, sets `HomePanel.currentPanel._forcePicker = true`, and calls `vscode.commands.executeCommand('openclaw.home.refresh')`. Exposed as a palette command as well so users can recover even if the Status page is somehow unresponsive. + - **Test**: + ```gherkin + Given the explicit-choice marker is true + When openclaw.host.reconfigure runs + Then the marker is false afterwards + And _forcePicker is true on HomePanel + And the next _update() renders the picker + ``` + - **Depends on**: Subtask 4.1, Subtask 1.1 + - [x] Subtask 4.3: Route the Status page's `reconfigure` postMessage into the command + - **Objective**: Extend HomePanel's message handler to dispatch `openclaw.host.reconfigure` on receipt of the new postMessage. + - **Test**: + ```gherkin + Given the Status page posts { command: 'reconfigure' } + When HomePanel's message handler runs + Then openclaw.host.reconfigure is dispatched + ``` + - **Depends on**: Subtask 4.2 + +- [ ] Task 5: Playwright regression coverage + - **Problem**: ticket-052's spec stops at "post-completion Status page stable across visibility toggle". It does not cover: persistence across editor reload, routing when the gateway is down but the choice is persisted, Start/Stop/Restart button wiring, or the Reconfigure escape hatch. Without coverage, any of the four new invariants could regress silently. + - **Test**: + ```gherkin + Given tests/e2e/docker-to-ide-flow.spec.ts runs end-to-end + When the spec completes + Then it includes assertions for: persisted-choice survives reload; gateway-down + persisted-choice lands on Status (not picker); Start/Stop/Restart buttons dispatch and transition state; Reconfigure returns to the picker + And the existing ticket-052 no-flicker and visibility-toggle assertions still pass + ``` + - **Depends on**: Task 4 + - **Subtasks**: + - [ ] Subtask 5.1: Persisted-choice-across-reload assertion + - **Objective**: After the existing gateway-connect step succeeds, simulate an editor reload (or re-open the Home panel), and assert the Status page is rendered and the picker DOM is absent. Tag `@slow` consistent with existing Docker flow specs. + - **Test**: + ```gherkin + Given the spec has just completed the gateway-connect step + When the Home panel is closed and re-opened (or the test reloads the editor window) + Then the Status page selector is present in the webview iframe + And the host-picker selector is absent + ``` + - **Depends on**: Task 4 + - [ ] Subtask 5.2: Gateway-down-with-persisted-choice assertion + - **Objective**: With the explicit-choice marker set, stop the `occ-openclaw` container (or mock the health probe to fail), trigger `_update()`, and assert the Status page's offline variant is rendered — not the picker. Start button must be visible. + - **Test**: + ```gherkin + Given the explicit-choice marker is true + And the gateway container is stopped + When the Home panel re-runs _update() + Then the Status-offline variant selector is present + And the Start Gateway button is present + And the host-picker selector is absent + ``` + - **Depends on**: Subtask 5.1 + - [ ] Subtask 5.3: Start/Stop/Restart button assertions + - **Objective**: Click Start, wait for the state machine to transition to `starting → running`, assert the gateway is reachable. Click Stop, assert `stopping → stopped`. Click Restart from the errored state, assert `restarting → running`. + - **Test**: + ```gherkin + Given the Status page is in the stopped state + When the user clicks Start + Then the Status page transitions through starting to running within the configured timeout + And the gateway /health endpoint returns 200 + When the user clicks Stop + Then the Status page transitions through stopping to stopped + ``` + - **Depends on**: Subtask 5.2 + - [ ] Subtask 5.4: Reconfigure escape-hatch assertion + - **Objective**: Click "Pick Different Host", assert the picker is re-rendered and the explicit-choice marker on disk is cleared. + - **Test**: + ```gherkin + Given the Status page is rendered with the explicit-choice marker set + When the user clicks "Pick Different Host" + Then the host-picker selector appears in the webview iframe + And the Status page selector is gone + And the explicit-choice marker in hosts.json is false / absent + ``` + - **Depends on**: Subtask 5.3 + - [ ] Subtask 5.5: Run the extended spec under the standard harness + - **Objective**: `npm run test:e2e -- --workers=1 tests/e2e/docker-to-ide-flow.spec.ts` passes green including all four new assertions and ticket-052's existing assertions. + - **Test**: + ```gherkin + Given the extended spec runs under the standard harness + When it completes + Then every assertion is green + And the report lists the five new test scenarios alongside ticket-052's + ``` + - **Depends on**: Subtask 5.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 66cf512f..158faa31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [3.7.0](https://github.com/asieduernest12/occ/compare/v3.6.1...v3.7.0) (2026-04-20) + + +### Features + +* **ticket-052+053:** fix Docker setup flicker, persist chosenHostType, surface gateway controls ([23521fe](https://github.com/asieduernest12/occ/commit/23521feae3673f40eaaba4b7c61662c315dc5f51)) + + +### Bug Fixes + +* **config:** open gateway dashboard externally to bypass Chrome LNA ([85a1576](https://github.com/asieduernest12/occ/commit/85a1576607d627e9902b34387b95517ef37e242c)) +* register missing openclaw.host.setup.{local,docker,ssh} commands ([93953d5](https://github.com/asieduernest12/occ/commit/93953d5a01ecc490314300ad7250373ab34976c1)) +* **ticket-051:** restore host picker flow and kill workspace-folder reload loop ([60e0d4b](https://github.com/asieduernest12/occ/commit/60e0d4badb1f6cb775ab1c3067f339536e91f5e0)) + +## [3.6.1](https://github.com/asieduernest12/occ/compare/v3.6.0...v3.6.1) (2026-04-17) + + +### Bug Fixes + +* **macos-ci:** publish unsigned artifacts when signing secrets missing ([1d5c5b9](https://github.com/asieduernest12/occ/commit/1d5c5b9205c06639bb01a0f26ec1df739c2042a8)) + ## [3.6.1](https://github.com/asieduernest12/occ/compare/v3.6.0...v3.6.1) (2026-04-17) diff --git a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts index 4c72c3d2..68e62afd 100644 --- a/apps/editor/extensions/openclaw-docker/src/setup-panel.ts +++ b/apps/editor/extensions/openclaw-docker/src/setup-panel.ts @@ -59,6 +59,15 @@ export class DockerSetupPanel { private _disposables: vscode.Disposable[] = []; private _statusController: StatusPanelController | undefined; private _disposed = false; + /** + * ticket-052 race hardening: true once `_statusController` has been fully + * constructed and assigned. `_messageBuffer` holds any webview messages + * that arrive during the asynchronous controller construction (the + * `await import('./connection')` gap in `_showStatusPanel`). Flushed + * exactly once when the controller becomes ready. + */ + private _statusControllerReady = false; + private _messageBuffer: Array<{ command: string; [key: string]: unknown }> = []; // Config flow state (0=Config, 1=Confirm, 2=Preflight, 3=Pull, 4=Onboard, 5=Launch, 6=Done) private _configStep: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0; @@ -153,11 +162,22 @@ export class DockerSetupPanel { this._panel.webview.onDidReceiveMessage( async (msg: { command: string; [key: string]: unknown }) => { - if (this._statusController) { + if (this._statusController && this._statusControllerReady) { this._statusController.handleMessage(msg); return; } + // ticket-052: StatusPanelController construction is asynchronous (it + // `await import('./connection')` before assigning `_statusController`). + // If a webview message arrives during that gap — e.g. an + // onDidChangeViewState-triggered probe — we must not drop it on the + // default `switch` below and route it to a random VS Code command. + // Buffer until the controller is ready, then flush exactly once. + if (this._isStatusControllerPending()) { + this._messageBuffer.push(msg); + return; + } + // Handle config flow messages switch (msg.command) { case 'dockerCheckEnvironment': @@ -299,12 +319,18 @@ export class DockerSetupPanel { } /** Run all validation checks for form fields */ - private async _runValidationChecks(config: Partial): Promise<{ [key: string]: string | null }> { - const errors: { [key: string]: string | null } = { - image: null, - port: null, - bindHost: null, - dataDir: null, + private async _runValidationChecks(config: Partial): Promise<{ + errors: { image: string | null; port: string | null; bindHost: string | null; dataDir: string | null }; + warnings: { dataDir: string | null }; + }> { + const errors = { + image: null as string | null, + port: null as string | null, + bindHost: null as string | null, + dataDir: null as string | null, + }; + const warnings = { + dataDir: null as string | null, }; // Validate image — blank is fine (defaults to DEFAULT_CONFIG.image) @@ -329,7 +355,8 @@ export class DockerSetupPanel { errors.bindHost = 'Bind host must be 127.0.0.1 or 0.0.0.0'; } - // Validate dataDir + // Validate dataDir — "required" and path-access remain blocking errors. + // Disk space is a soft recommendation surfaced as a warning, never blocking. if (!config.dataDir || config.dataDir.trim() === '') { errors.dataDir = 'Data directory is required'; } else { @@ -337,15 +364,11 @@ export class DockerSetupPanel { const accessError = await this._checkPathAccess(resolvedPath); if (accessError) { errors.dataDir = accessError; - } else { - const spaceError = await this._checkDiskSpace(resolvedPath); - if (spaceError) { - errors.dataDir = spaceError; - } } + warnings.dataDir = await this._checkDiskSpace(resolvedPath); } - return errors; + return { errors, warnings }; } /** Check if a port is in use */ @@ -387,7 +410,7 @@ export class DockerSetupPanel { }); } - /** Check available disk space (minimum 5GB) */ + /** Soft disk-space recommendation (5 GB). Returns advisory text, never blocks. */ private async _checkDiskSpace(fsPath: string): Promise { return new Promise((resolve) => { try { @@ -403,13 +426,14 @@ export class DockerSetupPanel { if (lines.length > 1) { const parts = lines[1].split(/\s+/); const available = parseInt(parts[3], 10); - const requiredBytes = 5 * 1024 * 1024 * 1024; // 5GB + const recommendedBytes = 5 * 1024 * 1024 * 1024; // 5 GB recommended - if (available < requiredBytes) { + if (available < recommendedBytes) { const availableGB = (available / (1024 * 1024 * 1024)).toFixed(1); - resolve(`Insufficient disk space: ${availableGB}GB available, 5GB required`); - } else { - resolve(null); + resolve( + `Only ${availableGB} GB free here — we recommend at least 5 GB. You can continue, but setup may run out of space.`, + ); + return; } } } @@ -576,9 +600,9 @@ ${logs.substring(0, 3000)} } private async _handleValidateFields(msg: { image: string; port: string; dataDir: string; bindHost: string }): Promise { - const errors = await this._runValidationChecks(msg); + const { errors, warnings } = await this._runValidationChecks(msg); try { - this._panel.webview.postMessage({ type: 'dockerValidationErrors', errors }); + this._panel.webview.postMessage({ type: 'dockerValidationErrors', errors, warnings }); } catch { /* ignore */ } } @@ -709,23 +733,63 @@ ${logs.substring(0, 3000)} this._panel.webview.html = this._getConfigHtml(webviewIconUri, this._activeConfig); } + /** + * ticket-052: true while `_showStatusPanel()` is in-flight but the + * StatusPanelController has not yet been fully constructed and assigned. + * During this window, webview messages are buffered in `_messageBuffer` + * so an `onDidChangeViewState`-triggered probe cannot be silently + * routed to the default command dispatcher. + */ + private _statusControllerPending = false; + + private _isStatusControllerPending(): boolean { + return this._statusControllerPending; + } + + /** Flush any messages buffered during StatusPanelController construction. */ + private _flushMessageBuffer(): void { + if (this._messageBuffer.length === 0) return; + const queued = this._messageBuffer; + this._messageBuffer = []; + if (this._statusController && this._statusControllerReady) { + for (const msg of queued) { + try { this._statusController.handleMessage(msg); } catch { /* non-fatal */ } + } + } + // If controller is missing (initialisation failed), the queued messages + // are dropped — the fallback path will have re-rendered the wizard, so + // any stale message is no longer meaningful. + } + private async _showStatusPanel(): Promise { if (!this._statusController) { - const { DockerHostConnection } = await import('./connection'); - const host = new DockerHostConnection( - { type: 'docker', containerLabel: CONTAINER, portMappings: { gateway: this._hostPort }, localMountPath: this._dataDir }, - CONTAINER, - ); - this._statusController = new StatusPanelController( - this._panel, - this._homeUri, - host, - () => { - // Disconnect: clear binding, dispose this panel, reopen the host picker (never auto-route). - this.dispose(); - void vscode.commands.executeCommand('openclaw.home.picker'); - }, - ); + this._statusControllerPending = true; + try { + const { DockerHostConnection } = await import('./connection'); + const host = new DockerHostConnection( + { type: 'docker', containerLabel: CONTAINER, portMappings: { gateway: this._hostPort }, localMountPath: this._dataDir }, + CONTAINER, + ); + this._statusController = new StatusPanelController( + this._panel, + this._homeUri, + host, + () => { + // Disconnect: clear binding, dispose this panel, reopen the host picker (never auto-route). + this.dispose(); + void vscode.commands.executeCommand('openclaw.home.picker'); + }, + ); + this._statusControllerReady = true; + } finally { + // Clear the pending flag even if construction threw, so future + // messages are routed rather than held indefinitely. + this._statusControllerPending = false; + } + // Flush exactly once, right after the controller becomes visible to + // external callers. Subsequent messages go straight through the + // `_statusController.handleMessage` path at the top of the handler. + this._flushMessageBuffer(); } await this._statusController.show(); this._panel.title = `OCC Home {Docker:${this._hostPort}}`; @@ -964,7 +1028,34 @@ ${logs.substring(0, 3000)} log('\n✓ Setup complete!\n'); try { this._panel.webview.postMessage({ type: 'launchDone' }); } catch { /* ignore */ } - setTimeout(() => void this._showStatusPanel(), 1800); + + // ticket-052: honour docs/plans/multihost/08-ui-design.md §0 Rules 2 and 4. + // Rule 2: the last step of setup must loop back to the Detect Gateway node. + // Rule 4: no direct setup → Status jump. + // + // We keep the `launchDone` postMessage (user-visible "Setup complete!") + // but replace the old `setTimeout(() => this._showStatusPanel(), 1800)` + // with a call that re-enters detection via `openclaw.home.refresh`. + // That command is registered by HomePanel and either re-runs `_update()` + // on an existing HomePanel or creates one (whose constructor runs + // `_update()`). `_update()` then sees `isGatewayReachable === true` and + // routes to the Status view — so detection, not this setup panel, owns + // the render decision. + // + // The 1800ms delay is preserved so the "Setup complete!" message stays + // visible briefly before the detection-driven transition. + setTimeout(async () => { + // ticket-053: persist the explicit-choice marker BEFORE dispatching + // refresh, so HomePanel._update() reads `chosenHostType === 'docker'` + // on re-entry and routes to the Status view (instead of the picker). + try { + await vscode.commands.executeCommand('openclaw.host.markChosen', 'docker'); + } catch { /* non-fatal — refresh will fall back to legacy isConfigured check */ } + void vscode.commands.executeCommand('openclaw.home.refresh'); + // Hand over the tab: the setup wizard is done, and the detection node + // (HomePanel) owns the next view. + this.dispose(); + }, 1800); } catch (err) { fail(String(err)); } @@ -1115,6 +1206,8 @@ ${logs.substring(0, 3000)} .field-error { border-color: #f87171 !important; } .error-msg { color: #f87171; font-size: 11px; margin-top: 4px; display: none; } .error-msg.show { display: block; } + .warn-msg { color: #fbbf24; font-size: 11px; margin-top: 4px; display: none; line-height: 1.4; } + .warn-msg.show { display: block; } .validation-badge { display: inline-block; margin-left: 6px; font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 3px; @@ -1211,6 +1304,7 @@ ${logs.substring(0, 3000)}
+
@@ -1273,7 +1367,7 @@ ${logs.substring(0, 3000)} } } - function updateValidationUI(errors) { + function updateValidationUI(errors, warnings) { validationState = errors; // Update error messages and field styling @@ -1292,7 +1386,19 @@ ${logs.substring(0, 3000)} } }); - // Update Next button state + // Render soft warnings (non-blocking advisories — amber, Next stays enabled) + const warnEl = document.getElementById('dataDir-warn'); + if (warnEl) { + const w = warnings && warnings.dataDir; + if (w) { + warnEl.textContent = w; + warnEl.classList.add('show'); + } else { + warnEl.classList.remove('show'); + } + } + + // Update Next button state — warnings never gate submission const allValid = Object.values(errors).every(e => e === null); document.getElementById('btn-next').disabled = !allValid; } @@ -1324,7 +1430,7 @@ ${logs.substring(0, 3000)} } else if (msg.type === 'dockerEnvironmentCheck') { updateDockerWarning(msg.result); } else if (msg.type === 'dockerValidationErrors') { - updateValidationUI(msg.errors); + updateValidationUI(msg.errors, msg.warnings); } else if (msg.type === 'dockerConfigError') { const err = document.getElementById('error'); err.textContent = msg.message; diff --git a/apps/editor/extensions/openclaw-local/src/setup-panel.ts b/apps/editor/extensions/openclaw-local/src/setup-panel.ts index f1e19440..d789319b 100644 --- a/apps/editor/extensions/openclaw-local/src/setup-panel.ts +++ b/apps/editor/extensions/openclaw-local/src/setup-panel.ts @@ -917,6 +917,12 @@ export class LocalSetupPanel { await this._host.writeConfig(cfg); } catch { /* non-fatal */ } } + // ticket-053: persist the explicit-choice marker so HomePanel._update() + // will see `chosenHostType === 'local'` on its next tick and route to + // the Status view without the user needing to re-pick from the host picker. + try { + await vscode.commands.executeCommand('openclaw.host.markChosen', 'local'); + } catch { /* non-fatal — local legacy path still works via isConfigured */ } setTimeout(() => { void this._showStatusPanel(); if (isFree) { diff --git a/apps/editor/extensions/openclaw/src/authGate.ts b/apps/editor/extensions/openclaw/src/authGate.ts new file mode 100644 index 00000000..93593792 --- /dev/null +++ b/apps/editor/extensions/openclaw/src/authGate.ts @@ -0,0 +1,278 @@ +import * as vscode from 'vscode'; + +/** + * Auth Gate — the first node in the startup user flow (docs/plans/multihost/08-ui-design.md §0 Rule 1). + * + * Runs BEFORE gateway detection. If a JWT is present in SecretStorage, it resolves + * immediately. If the JWT is absent, it opens a webview with "Sign In" and "Sign Up" + * buttons and waits for either: + * - the deep-link URI handler to fire `authCompleted` (JWT just got stored), or + * - the user to close the panel (treated as a graceful cancel). + * + * The gate deliberately does NOT validate the JWT against /api/v1/me — that is the + * balance bar's job (extension.ts:405-486), which already clears invalid tokens + * server-side-401. Our gate only checks for presence. + */ + +// ── Shared singletons ────────────────────────────────────────────────────────── + +const OCC_JWT_KEY = 'occJwtV1'; // must match extension.ts:317 +const SIGN_IN_URL = 'https://occ.mba.sh/login?ref=occ-editor'; +const SIGN_UP_URL = 'https://occ.mba.sh/signup?ref=occ-editor'; + +/** + * Fires when the deep-link URI handler in extension.ts stores a fresh JWT. + * The auth gate subscribes to this so it can close its panel and continue. + * + * Exposed as a module-level singleton so extension.ts can `emit()` without + * passing the emitter through activation arguments. + */ +const _authCompletedEmitter = new vscode.EventEmitter(); +export const onAuthCompleted: vscode.Event = _authCompletedEmitter.event; + +/** Called from the deep-link URI handler in extension.ts after the JWT is stored. */ +export function notifyAuthCompleted(): void { + _authCompletedEmitter.fire(); +} + +// ── JWT presence check (mirrors balance-bar fallback at extension.ts:411-420) ─ + +async function readStoredJwt(context: vscode.ExtensionContext): Promise { + let jwt = (await context.secrets.get(OCC_JWT_KEY)) ?? ''; + if (!jwt) { + try { + const legacyJwt = await vscode.commands.executeCommand('occ.auth.getLegacyJwt'); + if (legacyJwt) { + jwt = legacyJwt; + await context.secrets.store(OCC_JWT_KEY, jwt); + } + } catch { + // Renderer not ready — treat as no JWT; next activation or balance poll will retry. + } + } + return jwt; +} + +// ── Gate panel ──────────────────────────────────────────────────────────────── + +class AuthGatePanel { + public static current: AuthGatePanel | undefined; + private readonly _panel: vscode.WebviewPanel; + private readonly _disposables: vscode.Disposable[] = []; + /** Resolved when the panel is disposed — either by auth success or user close. */ + public readonly closed: Promise; + private _resolveClosed!: () => void; + + private constructor(panel: vscode.WebviewPanel) { + this._panel = panel; + this.closed = new Promise(resolve => { this._resolveClosed = resolve; }); + + this._panel.webview.html = AuthGatePanel._renderHtml(); + + this._panel.webview.onDidReceiveMessage( + (msg: { type?: string }) => { + if (msg?.type === 'signIn') { + void vscode.env.openExternal(vscode.Uri.parse(SIGN_IN_URL)); + } else if (msg?.type === 'signUp') { + void vscode.env.openExternal(vscode.Uri.parse(SIGN_UP_URL)); + } + }, + null, + this._disposables, + ); + + this._panel.onDidDispose(() => this.dispose(), null, this._disposables); + } + + public static show(): AuthGatePanel { + if (AuthGatePanel.current) { + AuthGatePanel.current._panel.reveal(); + return AuthGatePanel.current; + } + const panel = vscode.window.createWebviewPanel( + 'openclawAuthGate', + 'Sign in to OpenClaw', + vscode.ViewColumn.One, + { enableScripts: true, retainContextWhenHidden: true }, + ); + AuthGatePanel.current = new AuthGatePanel(panel); + return AuthGatePanel.current; + } + + /** Close the panel programmatically — triggers onDidDispose, which resolves `closed`. */ + public closeProgrammatically(): void { + try { this._panel.dispose(); } catch { /* already disposed */ } + } + + public dispose(): void { + AuthGatePanel.current = undefined; + this._resolveClosed(); + this._disposables.forEach(d => { try { d.dispose(); } catch { /* non-fatal */ } }); + } + + // ── HTML ────────────────────────────────────────────────────────────────── + + private static _renderHtml(): string { + return ` + + + + + Sign in to OpenClaw + + + +

Sign in to OpenClaw

+

Authenticate first so OpenClaw can connect to your account. Your gateway will be detected right after.

+ +
+ + +
+ +
+ + Waiting for sign-in... +
+ +

Clicking a button opens your browser. After signing in, your browser will redirect back to the editor and this window will close automatically.

+ + + +`; + } +} + +// ── Public entrypoint ──────────────────────────────────────────────────────── + +/** + * Initializes the auth gate. Resolves when the user is authenticated or has + * dismissed the gate. Callers should run gateway detection only after this + * resolves — even if the user cancelled, we unblock activation so the rest + * of the extension (hosts, commands, etc.) stays usable. + */ +export async function initAuthGate( + context: vscode.ExtensionContext, + _extensionUri: vscode.Uri, +): Promise { + const jwt = await readStoredJwt(context); + if (jwt) { + // Already signed in — skip the gate entirely. + return; + } + + const gate = AuthGatePanel.show(); + + // Race: either auth completes (deep-link fires) or the user closes the panel. + let authSub: vscode.Disposable | undefined; + const authPromise = new Promise<'auth'>(resolve => { + authSub = onAuthCompleted(() => resolve('auth')); + }); + const closedPromise = gate.closed.then(() => 'closed' as const); + + const outcome = await Promise.race([authPromise, closedPromise]); + authSub?.dispose(); + + if (outcome === 'auth') { + // Auth succeeded — close the panel and continue. If it's already closed + // this is a no-op. + gate.closeProgrammatically(); + return; + } + + // Panel closed before auth completed — log a graceful cancel. We still + // resolve so activation isn't wedged forever; downstream code will render + // the usual unauthenticated UI (balance bar hidden, etc.). + console.info('[openclaw] Auth gate dismissed before sign-in completed.'); +} diff --git a/apps/editor/extensions/openclaw/src/extension.ts b/apps/editor/extensions/openclaw/src/extension.ts index d506cb47..2bea4786 100644 --- a/apps/editor/extensions/openclaw/src/extension.ts +++ b/apps/editor/extensions/openclaw/src/extension.ts @@ -8,13 +8,14 @@ import * as https from 'https'; import { HomePanel } from './panels/home'; import { StatusPanel } from './panels/status'; import { setActiveOpenClawWorkspaceFolder } from './panels/statusController'; -import { stopConfigProxy, getDashboardUrl, ConfigPanel } from './panels/config'; +import { stopConfigProxy, getDashboardUrl } from './panels/config'; import { HostRegistry } from './hosts/registry'; import { HostManager } from './hosts/manager'; import { HostStatusBarItem } from './hosts/statusbar'; import { HostTreeProvider } from './hosts/tree'; import type { OpenClawCoreAPI } from './hosts/types'; import { mergeDashboardWithProxy } from './utils/proxyUrl'; +import { initAuthGate, notifyAuthCompleted } from './authGate'; const DEFAULT_GATEWAY_PORT = 18789; @@ -272,19 +273,13 @@ async function openOpenClawFolder(context?: vscode.ExtensionContext): Promise { + if (type !== 'local' && type !== 'docker' && type !== 'ssh') { return; } + await hostManager.markActiveHostChosen(type); + }), + vscode.commands.registerCommand('openclaw.host.reconfigure', async () => { + await hostManager.clearActiveHostChoice(); + void vscode.commands.executeCommand('openclaw.home.refresh'); + }), ); // Inference balance bar (shown at bottom-right, tracks $1.00 free budget). @@ -700,6 +706,9 @@ export async function activate(context: vscode.ExtensionContext): Promise { // Also sync to renderer settings service (for chat / other renderer consumers). vscode.commands.executeCommand('occ.auth.setLegacyJwt', token); + // Signal the auth gate (if open) that sign-in completed so it + // can close itself and allow gateway detection to proceed. + notifyAuthCompleted(); }); } } @@ -736,8 +745,16 @@ export async function activate(context: vscode.ExtensionContext): Promise { - routeHome(context.extensionUri, context); + void (async () => { + await initAuthGate(context, context.extensionUri); + routeHome(context.extensionUri, context); + })(); }, 500); // Return OpenClawCoreAPI so adapter extensions can register their adapters. diff --git a/apps/editor/extensions/openclaw/src/hosts/manager.ts b/apps/editor/extensions/openclaw/src/hosts/manager.ts index 03befcca..1e057602 100644 --- a/apps/editor/extensions/openclaw/src/hosts/manager.ts +++ b/apps/editor/extensions/openclaw/src/hosts/manager.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import type { HostAdapter, + HostChoiceType, HostConnection, HostEntry, HostStatus, @@ -142,6 +143,20 @@ export class HostManager implements OpenClawCoreAPI, vscode.Disposable { this._onDidChangeActiveHost.fire(this._connections.get(id)); } + // ── ticket-053: chosenHostType pass-throughs ── + + getChosenHostType(): HostChoiceType | undefined { + return this.registry.getChosenHostType(); + } + + async markActiveHostChosen(type: HostChoiceType): Promise { + await this.registry.markActiveHostChosen(type); + } + + async clearActiveHostChoice(): Promise { + await this.registry.clearActiveHostChoice(); + } + // ── Connection management ───────────────── private async _connectPersistedHosts(type: HostType): Promise { diff --git a/apps/editor/extensions/openclaw/src/hosts/registry.ts b/apps/editor/extensions/openclaw/src/hosts/registry.ts index ebcd70ad..95ac78e9 100644 --- a/apps/editor/extensions/openclaw/src/hosts/registry.ts +++ b/apps/editor/extensions/openclaw/src/hosts/registry.ts @@ -2,7 +2,7 @@ import * as vscode from 'vscode'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import type { HostConnectionConfig, HostEntry, HostsFile, HostType, HostStatus } from './types'; +import type { HostChoiceType, HostConnectionConfig, HostEntry, HostsFile, HostType, HostStatus } from './types'; // ───────────────────────────────────────────── // Paths @@ -245,6 +245,29 @@ export class HostRegistry implements vscode.Disposable { this._onDidChange.fire(); } + // ── ticket-053: explicit-choice marker ──── + + /** Returns the persisted chosenHostType, or undefined when the user has not yet completed a setup. */ + getChosenHostType(): HostChoiceType | undefined { + return this._hostsFile?.chosenHostType; + } + + /** Called by a setup wizard's success handler. Persists the marker and fires onDidChange. */ + async markActiveHostChosen(type: HostChoiceType): Promise { + if (!this._hostsFile) { return; } + this._hostsFile.chosenHostType = type; + this._persist(); + this._onDidChange.fire(); + } + + /** Called by the Reconfigure escape hatch. Removes the marker so HomePanel falls back to the host picker. */ + async clearActiveHostChoice(): Promise { + if (!this._hostsFile) { return; } + delete this._hostsFile.chosenHostType; + this._persist(); + this._onDidChange.fire(); + } + setHostStatus(id: string, status: HostStatus, error?: string): void { this.updateHost(id, { lastStatus: status, diff --git a/apps/editor/extensions/openclaw/src/hosts/types.ts b/apps/editor/extensions/openclaw/src/hosts/types.ts index a18dd812..dafad013 100644 --- a/apps/editor/extensions/openclaw/src/hosts/types.ts +++ b/apps/editor/extensions/openclaw/src/hosts/types.ts @@ -159,6 +159,14 @@ export type HostConnectionConfig = export type HostType = 'local' | 'docker' | 'ssh' | 'cloud'; export type HostStatus = 'online' | 'offline' | 'error' | 'unknown'; +/** + * ticket-053: the set of host types the user can explicitly pick and complete + * setup for. Mutually exclusive — `chosenHostType` on HostsFile holds exactly + * one of these (or is absent, meaning "no explicit choice, show picker"). + */ +export const HOST_CHOICE_TYPES = ['local', 'docker', 'ssh'] as const; +export type HostChoiceType = typeof HOST_CHOICE_TYPES[number]; + export interface HostEntry { id: string; type: HostType; @@ -181,6 +189,14 @@ export interface HostEntry { export interface HostsFile { version: 1; activeHostId: string; + /** + * ticket-053: set once the user completes a host setup wizard. Absence means + * "no explicit choice yet" → HomePanel shows the host-picker. Presence means + * "user finished setup" → HomePanel routes to Status (online or offline per + * reachability). Never infer from `activeHostId` alone: the default seed + * sets activeHostId='local' regardless of whether the user picked it. + */ + chosenHostType?: HostChoiceType; hosts: HostEntry[]; } @@ -306,6 +322,11 @@ export interface OpenClawCoreAPI { getAllHosts(): HostEntry[]; setActiveHost(id: string): Promise; + /** ticket-053: explicit-choice marker API — see HostsFile.chosenHostType. */ + getChosenHostType(): HostChoiceType | undefined; + markActiveHostChosen(type: HostChoiceType): Promise; + clearActiveHostChoice(): Promise; + readonly onDidChangeActiveHost: vscode.Event; readonly onDidChangeHostStatus: vscode.Event<{ hostId: string; status: HostStatus }>; readonly onDidAddHost: vscode.Event; diff --git a/apps/editor/extensions/openclaw/src/panels/config.ts b/apps/editor/extensions/openclaw/src/panels/config.ts index 824dce2c..dac19d99 100644 --- a/apps/editor/extensions/openclaw/src/panels/config.ts +++ b/apps/editor/extensions/openclaw/src/panels/config.ts @@ -435,20 +435,28 @@ export class ConfigPanel { const targetPort = dashInfo?.port ?? DEFAULT_GATEWAY_PORT; const proxyPort = await getOrStartConfigProxy(targetPort); - let proxySrc: string; + // Build the raw loopback URL, then ask VS Code to tunnel it through the + // editor's own origin. In VS Code Web this returns a same-origin URL so + // Chrome's Private Network Access policy doesn't block the iframe — + // without this the webview shows "The connection is blocked because it + // was initiated by a public page to connect to devices or servers on + // your local network." whenever the editor is accessed via anything + // other than `localhost`. In desktop VS Code it's a no-op. + let loopbackSrc: string; let externalSrc: string; if (dashInfo?.url) { - // Replace the host in the tokenized URL with the proxy address. const parsed = new URL(dashInfo.url); - proxySrc = `http://127.0.0.1:${proxyPort}${parsed.pathname}${parsed.search}${parsed.hash}`; + loopbackSrc = `http://127.0.0.1:${proxyPort}${parsed.pathname}${parsed.search}${parsed.hash}`; externalSrc = dashInfo.url; } else { - // Fallback: open root of gateway (no token) - proxySrc = `http://127.0.0.1:${proxyPort}/`; + loopbackSrc = `http://127.0.0.1:${proxyPort}/`; externalSrc = `http://localhost:${targetPort}/`; } + const proxySrc = await vscode.env.asExternalUri(vscode.Uri.parse(loopbackSrc)) + .then(uri => uri.toString(true), () => loopbackSrc); + this._panel.webview.html = this._iframeHtml(proxySrc, externalSrc); } catch (err) { this._panel.webview.html = this._errorHtml(String(err)); diff --git a/apps/editor/extensions/openclaw/src/panels/home.ts b/apps/editor/extensions/openclaw/src/panels/home.ts index 1cf3a9c3..36247491 100644 --- a/apps/editor/extensions/openclaw/src/panels/home.ts +++ b/apps/editor/extensions/openclaw/src/panels/home.ts @@ -84,8 +84,37 @@ function getOpenClawWorkspaceDir(): string { return fallback; } +/** + * ticket-053: read the `chosenHostType` marker from ~/.occ/hosts.json without + * requiring a HostRegistry reference inside HomePanel. This is the same pattern + * getOpenClawWorkspaceDir() uses for openclaw.json — direct file read, best-effort. + * + * Returns 'local' | 'docker' | 'ssh' when the user has completed a setup wizard, + * or undefined when no explicit choice has been recorded (→ show host picker). + */ +function readChosenHostType(): 'local' | 'docker' | 'ssh' | undefined { + try { + const hostsPath = path.join(os.homedir(), '.occ', 'hosts.json'); + const raw = fs.readFileSync(hostsPath, 'utf-8'); + const json = JSON.parse(raw) as { chosenHostType?: unknown }; + const v = json.chosenHostType; + return (v === 'local' || v === 'docker' || v === 'ssh') ? v : undefined; + } catch { + return undefined; + } +} + export class HomePanel { public static currentPanel: HomePanel | undefined; + /** + * ticket-052: last-seen extensionUri, cached so the module-scope + * `openclaw.home.refresh` command can (re)open HomePanel even if the + * current panel was disposed — e.g. after the user picked Docker, the + * HomePanel disposes at home.ts ~273 and only the DockerSetupPanel is + * alive when `_handleLaunchGateway` fires the refresh command. + */ + private static _lastExtensionUri: vscode.Uri | undefined; + private static _refreshCommandRegistered = false; private static _installTerminal: vscode.Terminal | undefined; private readonly _panel: vscode.WebviewPanel; private readonly _extensionUri: vscode.Uri; @@ -139,6 +168,33 @@ export class HomePanel { this._panel.onDidChangeViewState(e => { if (e.webviewPanel.visible) { void this._update(); } }, null, this._disposables); + // ticket-052: register a command that re-enters detection (Rules 2/4 of + // docs/plans/multihost/08-ui-design.md §0). Setup adapters call this after + // completion instead of swapping UI directly to the Status view — this keeps + // `_update()` as the single source of truth for "gateway up? show Status". + // + // Registration is process-global and ownership-free: disposing a HomePanel + // must not unregister the command, otherwise a setup flow that disposes + // HomePanel (home.ts ~273 on "chooseHostType") and then fires refresh would + // hit an unregistered command. The handler refreshes the current panel if + // one exists, otherwise opens a new panel (constructor runs `_update()`). + HomePanel._lastExtensionUri = extensionUri; + if (!HomePanel._refreshCommandRegistered) { + HomePanel._refreshCommandRegistered = true; + try { + vscode.commands.registerCommand('openclaw.home.refresh', () => { + const existing = HomePanel.currentPanel; + if (existing) { + void existing._update(); + } else if (HomePanel._lastExtensionUri) { + HomePanel.createOrShow(HomePanel._lastExtensionUri, false); + } + }); + } catch { + // Command already registered — nothing to do; flag stays true so we + // don't try again on subsequent panel creates. + } + } // Watch ~/.openclaw/openclaw.json for when OpenClaw first initialises. const configWatcher = vscode.workspace.createFileSystemWatcher( new vscode.RelativePattern(vscode.Uri.file(path.join(os.homedir(), '.openclaw')), 'openclaw.json'), @@ -264,6 +320,10 @@ export class HomePanel { if (args && args.length > 0) { void vscode.commands.executeCommand('void.openChatWithMessage', args[0], 'agent'); } + } else if (msg.command === 'reconfigure') { + // ticket-053 Task 4: escape hatch — clears chosenHostType and + // dispatches openclaw.home.refresh which re-renders the picker. + void vscode.commands.executeCommand('openclaw.host.reconfigure'); } else if (msg.command === 'chooseHostType') { const t = msg.hostType as string; // Best-effort: close files from the other host's dir (non-blocking). @@ -278,8 +338,6 @@ export class HomePanel { } else if (t === 'ssh') { void vscode.commands.executeCommand('openclaw.host.setup.ssh'); } - } else if (msg.command === 'checkHostsStatus') { - void this._handleCheckHostsStatus(); } }, null, this._disposables); } @@ -397,62 +455,76 @@ export class HomePanel { // Probe the gateway HTTP endpoint — used below to skip the setup picker // when a gateway is already reachable (e.g. container running, editor reloaded). - const isGatewayReachable = !isConfigured && !isDockerRunning + // + // NOTE (ticket-052): previously this probe was skipped when `isDockerRunning` + // was true, because the old gate at the host-picker branch treated + // `isDockerRunning` itself as "show the picker." With `isDockerRunning` removed + // from that gate, we need the probe to run whenever we don't already have a + // definitive signal — i.e. any time `isConfigured` is false. + const isGatewayReachable = !isConfigured ? (await this._checkGatewayStatusRaw()) === 'running' : false; - // Show hosts overview when Docker container is up (regardless of local config), - // or when both modes are active, or when forced (e.g. after disconnect). - if (isDockerRunning || this._forcePicker) { - this._stopPolling(); - let localPort = 18789; + // ticket-053: route on the persisted explicit-choice marker, not on the + // live gateway probe. `chosenHostType` is set by setup wizards on success + // (via `openclaw.host.markChosen`) and cleared by `openclaw.host.reconfigure`. + // See docs/plans/multihost/08-ui-design.md §0 Rule 6. + let chosenHostType = readChosenHostType(); + + // Backward compatibility: legacy installs have a config file but no + // chosenHostType marker. Infer 'local' once so we don't kick existing users + // back to the host picker on upgrade. Docker legacy is rarer and will fall + // through to the picker one time, then setup will re-mark it on completion. + if (!chosenHostType && isConfigured && this._host.type === 'local') { try { - const raw = fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf-8'); - const cfg = JSON.parse(raw) as Record; - const gateway = cfg['gateway'] as Record | undefined; - const p = gateway?.['port'] ?? cfg['port'] ?? cfg['gateway_port'] ?? cfg['gatewayPort']; - const n = typeof p === 'string' ? parseInt(p, 10) : typeof p === 'number' ? p : NaN; - if (Number.isFinite(n) && n > 0 && n < 65536) { localPort = n; } - } catch { /* use default */ } - this._panel.webview.html = this._getHostsOverviewHtml(iconUri.toString(), localPort); + await vscode.commands.executeCommand('openclaw.host.markChosen', 'local'); + chosenHostType = 'local'; + } catch { /* non-fatal — picker will still show, user can re-pick */ } + } + + // Host picker is shown only when: + // (a) the user has explicitly invoked Reconfigure (`_forcePicker`), or + // (b) no `chosenHostType` marker has been recorded yet. + // + // ticket-052 removed `isDockerRunning` from this gate to stop the flicker. + // ticket-053 replaces `isConfigured` / `isGatewayReachable` entirely — + // once the user has completed setup, the Status panel owns the view + // regardless of whether the gateway is currently up (it will render an + // offline variant with a Start button, per §0a of the UI design doc). + if (this._forcePicker || !chosenHostType) { + this._stopPolling(); + this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); + this._autoUpdateTriggered = false; return; } - // Show unified setup view only when there is no evidence of a running gateway. - // If the gateway is already reachable (e.g. container running, editor reloaded after - // setup) skip straight to the dashboard so the user doesn't see the host picker again. - if (!isConfigured && !isGatewayReachable) { - this._panel.webview.html = this._getHostTypeSelectionHtml(iconUri.toString()); - this._autoUpdateTriggered = false; // reset so check fires when they reach the dashboard - } else { - // Local is configured and Docker is not running — show local status. - setActiveOpenClawWorkspaceFolder(path.join(os.homedir(), '.openclaw')); - - const emojiBaseUri = this._panel.webview.asWebviewUri( - vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') - ).toString(); - let aiModelName = ''; - try { - const cfg = await this._host.readConfig() as Record; - const primaryModel = (cfg as Record>>>) - ?.agents?.defaults?.model?.primary ?? ''; - if (primaryModel) { - const slashIdx = primaryModel.indexOf('/'); - const providerId = slashIdx >= 0 ? primaryModel.slice(0, slashIdx) : ''; - const modelId = slashIdx >= 0 ? primaryModel.slice(slashIdx + 1) : primaryModel; - const providers = (cfg as Record>>>) - ?.models?.providers ?? {}; - const providerModels = providers[providerId]?.models ?? []; - const modelDef = providerModels.find((m: { id: string; name?: string; input?: string[] }) => m.id === modelId); - aiModelName = modelDef?.name ?? primaryModel; - } - } catch { /* openclaw.json unreadable or missing fields */ } + // Local is configured and Docker is not running — show local status. + setActiveOpenClawWorkspaceFolder(path.join(os.homedir(), '.openclaw')); - this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName); - if (!this._autoUpdateTriggered) { - this._autoUpdateTriggered = true; - setTimeout(() => void this._autoUpdateIfOutdated(), 3000); + const emojiBaseUri = this._panel.webview.asWebviewUri( + vscode.Uri.joinPath(this._extensionUri, 'media', 'emojis') + ).toString(); + let aiModelName = ''; + try { + const cfg = await this._host.readConfig() as Record; + const primaryModel = (cfg as Record>>>) + ?.agents?.defaults?.model?.primary ?? ''; + if (primaryModel) { + const slashIdx = primaryModel.indexOf('/'); + const providerId = slashIdx >= 0 ? primaryModel.slice(0, slashIdx) : ''; + const modelId = slashIdx >= 0 ? primaryModel.slice(slashIdx + 1) : primaryModel; + const providers = (cfg as Record>>>) + ?.models?.providers ?? {}; + const providerModels = providers[providerId]?.models ?? []; + const modelDef = providerModels.find((m: { id: string; name?: string; input?: string[] }) => m.id === modelId); + aiModelName = modelDef?.name ?? primaryModel; } + } catch { /* openclaw.json unreadable or missing fields */ } + + this._panel.webview.html = this._getHtml(isInstalled, dirExists, cliCheck, iconUri.toString(), occJwt, occUser, emojiBaseUri, aiModelName); + if (!this._autoUpdateTriggered) { + this._autoUpdateTriggered = true; + setTimeout(() => void this._autoUpdateIfOutdated(), 3000); } this._startPolling(); if (isInstalled) { @@ -596,37 +668,32 @@ export class HomePanel { this._commandAction = action; try { this._panel.webview.postMessage({ type: 'gatewayStatus', status: intermediary }); } catch {} - // Hand off to AI — it will run the command and handle any errors - const verb = action === 'restart' ? 'restart' : action; - const osInfo = `${process.platform} ${os.release()} (${process.arch})`; - const port = this._getConfiguredPort(); - const portCheckCmd = process.platform === 'win32' - ? `netstat -ano | findstr :${port}` - : `lsof -iTCP:${port} -sTCP:LISTEN -n -P 2>/dev/null || ss -tlnp 2>/dev/null | grep :${port}`; - const aiMessage = [ - `Please ${verb} the OpenClaw gateway.`, - '', - `Run the following command in your terminal:`, - '```', - `openclaw gateway ${action}`, - '```', - '', - `Environment: ${osInfo}`, - `Configured gateway port: ${port}`, - '', - `After running the command, verify the gateway has reached the expected state by checking`, - `whether port ${port} is ${expectedState === 'running' ? 'actively listening' : 'no longer listening'}:`, - '```', - portCheckCmd, - '```', - '', - `The gateway is confirmed ${expectedState === 'running' ? 'running' : 'stopped'} when port ${port} ` + - `${expectedState === 'running' ? 'shows an active LISTEN entry' : 'shows no LISTEN entry'}.`, - `If the command fails or the port does not reach the expected state, diagnose and fix the issue.`, - ].join('\n'); - - await vscode.commands.executeCommand('void.openChatWithMessage', aiMessage, 'agent'); - void vscode.commands.executeCommand('openclaw.balance.spend'); + // ticket-053 Task 3: invoke the active HostConnection's gateway control directly + // instead of handing off to the AI. `this._host` is always the active registry + // host (see constructor: coreAPI.onDidChangeActiveHost + getActiveHost). + const onLog = (line: string) => { + try { this._outputChannel.append(line); } catch { /* non-fatal */ } + writeLog(line); + }; + this._outputChannel.show(true); + this._outputChannel.appendLine(`[gateway:${action}] invoking host adapter…`); + try { + if (action === 'start') { + await this._host.gatewayStart(onLog); + } else if (action === 'stop') { + await this._host.gatewayStop(onLog); + } else { + await this._host.gatewayRestart(onLog); + } + this._outputChannel.appendLine(`[gateway:${action}] completed`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this._outputChannel.appendLine(`[gateway:${action}] failed: ${msg}`); + vscode.window.showErrorMessage(`Gateway ${action} failed: ${msg}`); + } + + // Re-render the Status page so the new gateway state is reflected. + void this._update(); // Poll in the background until gateway reaches expected state this._pollUntilState(expectedState, intermediary); @@ -757,171 +824,6 @@ export class HomePanel { }); } - private async _handleCheckHostsStatus(): Promise { - // Local: "running" = config file exists (OpenClaw is installed & configured). - // Gateway port check is unreliable because the gateway may not be auto-started. - const localConfigured = fs.existsSync(path.join(os.homedir(), '.openclaw', 'openclaw.json')); - const localStatus: 'running' | 'stopped' = localConfigured ? 'running' : 'stopped'; - - // Docker: "running" = container is up (regardless of whether gateway is started inside it). - const dockerContainerRunning = await new Promise(resolve => { - try { - const result = cp.spawnSync( - 'docker', - ['ps', '--filter', 'name=^/occ-openclaw$', '--format', '{{.Status}}'], - { timeout: 3000, windowsHide: true }, - ); - const st = (result.stdout?.toString() ?? '').trim(); - resolve(st.length > 0 && st.toLowerCase().startsWith('up')); - } catch { resolve(false); } - }); - const dockerStatus: 'running' | 'stopped' = dockerContainerRunning ? 'running' : 'stopped'; - - try { - this._panel.webview.postMessage({ type: 'hostsStatus', local: localStatus, docker: dockerStatus }); - } catch { /* ignore */ } - } - - private _getHostsOverviewHtml(iconUri: string, localPort: number): string { - return ` - - - - - - - - -

OCC Home

-

Choose a host to open

- -
- - - -
- - - -`; - } - private _getHostTypeSelectionHtml(iconUri: string): string { return ` diff --git a/apps/editor/extensions/openclaw/src/panels/statusController.ts b/apps/editor/extensions/openclaw/src/panels/statusController.ts index 84ac565a..c01022e5 100644 --- a/apps/editor/extensions/openclaw/src/panels/statusController.ts +++ b/apps/editor/extensions/openclaw/src/panels/statusController.ts @@ -91,17 +91,33 @@ function _applyActiveOpenClawWorkspaceFolder(targetPath: string): void { if (targetFound && toRemove.length === 0) return; // already correct - // Remove stale dirs in descending index order so indices stay valid - for (const idx of [...toRemove].sort((a, b) => b - a)) { - vscode.workspace.updateWorkspaceFolders(idx, 1); + // VS Code only permits one pending updateWorkspaceFolders operation at a time; + // separate remove+add calls drop the second, leaving folders=[] which then + // triggers openOpenClawFolder() to rewrite the file on the next activate — + // a reload loop. Fuse the remove+add into a single atomic call. + const addSpec = targetFound ? undefined : { uri: targetUri, name: path.basename(targetPath) }; + + if (toRemove.length > 0) { + const sorted = [...toRemove].sort((a, b) => a - b); + const start = sorted[0]; + const contiguous = sorted.every((v, i) => v === start + i); + if (contiguous) { + if (addSpec) { + vscode.workspace.updateWorkspaceFolders(start, sorted.length, addSpec); + } else { + vscode.workspace.updateWorkspaceFolders(start, sorted.length); + } + return; + } + // Non-contiguous: remove highest index first; the add (if needed) will + // happen on the next debounced call. Safe — next call sees correct state. + vscode.workspace.updateWorkspaceFolders(sorted[sorted.length - 1], 1); + if (addSpec) { _wsUpdateTarget = targetPath; } + return; } - if (!targetFound) { - const count = vscode.workspace.workspaceFolders?.length ?? 0; - vscode.workspace.updateWorkspaceFolders(count, null, { - uri: targetUri, - name: path.basename(targetPath), - }); + if (addSpec) { + vscode.workspace.updateWorkspaceFolders(folders.length, null, addSpec); } } catch { /* non-fatal */ } } diff --git a/apps/editor/extensions/openclaw/src/panels/statusHtml.ts b/apps/editor/extensions/openclaw/src/panels/statusHtml.ts index 0fb7667c..636c376f 100644 --- a/apps/editor/extensions/openclaw/src/panels/statusHtml.ts +++ b/apps/editor/extensions/openclaw/src/panels/statusHtml.ts @@ -1044,6 +1044,7 @@ export function renderStatusHtml(
+
` : ` @@ -1286,6 +1287,11 @@ export function renderStatusHtml( vscode.postMessage({ command: 'toggleChat' }); } + // ticket-053 Task 4: escape hatch back to the host picker + function reconfigure() { + vscode.postMessage({ command: 'reconfigure' }); + } + // ── Version check ───────────────────────────────────────────── function checkVersion() { const btn = document.getElementById('btn-version'); diff --git a/docker-compose.yml b/docker-compose.yml index e40b80db..39c9304f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: # - "9888:9888" environment: NODE_ENV: development - NODE_OPTIONS: --max-old-space-size=7096 + NODE_OPTIONS: --max-old-space-size=2048 GITHUB_TOKEN: ${GITHUB_TOKEN:-OCC_GITHUB_TOKEN_NOT_SET} TSC_TRANSPILE_CPU_RATIO: ${TSC_TRANSPILE_CPU_RATIO:-0.75} # HOST_WORKSPACE lets extensions resolve host-side paths when invoking diff --git a/docs/plans/multihost/01-host-registry.md b/docs/plans/multihost/01-host-registry.md index a20a28bd..e3c3360d 100644 --- a/docs/plans/multihost/01-host-registry.md +++ b/docs/plans/multihost/01-host-registry.md @@ -30,6 +30,18 @@ ## `hosts.json` Schema +> **Note — ticket-053 (persist-host-choice-and-gateway-control).** `activeHostId` +> alone is **not sufficient** to infer "user has completed setup". The host +> registry seeds `activeHostId = "local"` on a fresh install (see `registry.ts` +> `makeEmptyHostsFile()`), and `getActiveHostId()` also falls back to the string +> `"local"` when `hosts.json` is missing. That makes the default `"local"` +> indistinguishable from an explicit local choice, and would strand a user on a +> Status page whenever the gateway happens to be down. ticket-053 introduces an +> explicit-choice marker (see that ticket's §2.4 Technical Considerations for +> the chosen mechanism — most likely a sibling `HostsFile.explicitChoice: boolean` +> or a per-host `HostEntry.setupCompletedAt`) that setup wizards flip on success, +> and that `HomePanel._update()` reads before deciding picker-vs-Status. + ```typescript interface HostsFile { version: 1; diff --git a/docs/plans/multihost/04-local-adapter.md b/docs/plans/multihost/04-local-adapter.md index db29806a..d14b8648 100644 --- a/docs/plans/multihost/04-local-adapter.md +++ b/docs/plans/multihost/04-local-adapter.md @@ -288,6 +288,7 @@ export class LocalHostConnection implements HostConnection { async gatewayRestart(): Promise { return this.exec('openclaw', ['gateway', 'restart']); } + // gatewayStart/Stop/Restart — see "Gateway Lifecycle" note below. // ── VS Code Integration ── async openExplorer(p?: string): Promise { @@ -313,6 +314,25 @@ export class LocalHostConnection implements HostConnection { } ``` +## Gateway Lifecycle + +`LocalHostConnection.gatewayStart / gatewayStop / gatewayRestart` shell to the +locally-installed `openclaw` CLI: + +``` +openclaw gateway start +openclaw gateway stop +openclaw gateway restart +``` + +These are the control-plane surface for the Status panel's Start / Stop / Restart +buttons (`apps/editor/extensions/openclaw/src/panels/statusHtml.ts:1214-1219`) on +a local-active host. They differ from the Docker adapter's equivalents, which +shell to `docker compose -f docker/docker-compose.openclaw.yml up -d / down / restart` +on the user's workstation — see [`05-docker-adapter.md`](./05-docker-adapter.md) +"Gateway Lifecycle". ticket-053 wires these methods to `openclaw.gateway.start/stop/restart` +VS Code commands. + ## What Moves From `home.ts` Into This Adapter | Current `home.ts` Function | New Location | diff --git a/docs/plans/multihost/05-docker-adapter.md b/docs/plans/multihost/05-docker-adapter.md index d741046d..62808711 100644 --- a/docs/plans/multihost/05-docker-adapter.md +++ b/docs/plans/multihost/05-docker-adapter.md @@ -313,6 +313,10 @@ export class DockerHostConnection implements HostConnection { async gatewayStart(): Promise { return this.exec('openclaw', ['gateway', 'start']); } async gatewayStop(): Promise { return this.exec('openclaw', ['gateway', 'stop']); } async gatewayRestart(): Promise { return this.exec('openclaw', ['gateway', 'restart']); } + + // See "Gateway Lifecycle" below — on the bundled OCC Docker adapter, the real + // implementation shells to `docker compose -f docker/docker-compose.openclaw.yml` + // against the user's workstation, not `openclaw` inside the container. // ── VS Code Integration ── async openExplorer(p?: string): Promise { @@ -374,6 +378,48 @@ export class DockerHostConnection implements HostConnection { } ``` +## Gateway Lifecycle + +`DockerHostConnection.gatewayStart / gatewayStop / gatewayRestart` are the +control-plane surface for the Status panel's Start / Stop / Restart buttons +(see `apps/editor/extensions/openclaw/src/panels/statusHtml.ts:1214-1219` and +ticket-053). On the bundled OCC setup — where the gateway is a Docker Compose +stack launched by the setup wizard — these methods shell out on the user's +workstation to the canonical compose file: + +``` +docker compose -f docker/docker-compose.openclaw.yml up -d # gatewayStart +docker compose -f docker/docker-compose.openclaw.yml down # gatewayStop +docker compose -f docker/docker-compose.openclaw.yml restart # gatewayRestart +``` + +This file path is load-bearing: it is the single source of truth defined in the +root `AGENTS.md` § "OpenClaw Docker Gateway" and used by the setup wizard, the +Status panel controls, and any future CLI automation. The same compose file +path is re-used in the dev override invocation (`-f docker/docker-compose.openclaw.yml -f docker/docker-compose.openclaw.override.yml`) +but the Status panel's buttons intentionally use the production-parity form +without the override. + +Requirements for the implementation: + +- **Must not race with the setup wizard.** The setup wizard runs `up -d` against + the same compose file while configuring the gateway. The Status panel's + Start/Stop/Restart buttons are only rendered after the setup wizard has + disposed (they live inside the Status panel, not the wizard), so in practice + there is no overlap — but implementations must not leak a background + long-running `docker compose` process that would clash with a later wizard run. +- **Must stream logs.** Use `execStream` / the adapter's `onLog` hook so the + Status panel can surface build output when the user hits Start from the + stopped / errored state. +- **Must respect `dockerHost` overrides** in the `DockerConnection` config (e.g. + `DOCKER_HOST=ssh://user@remote`) — see the existing `localExec` helper at + `05-docker-adapter.md` "Docker Host Connection" `localExec`. + +For ad-hoc containers created via the Add Host wizard (rather than the bundled +compose stack), adapters MAY fall back to the in-container +`openclaw gateway start/stop/restart` form shown in the sketch above, but the +bundled OCC path is always the compose form. + ## Docker Compose Support When users configure a Compose service instead of a raw container, commands route through `docker compose`: diff --git a/docs/plans/multihost/08-ui-design.md b/docs/plans/multihost/08-ui-design.md index 257c34b9..e027550e 100644 --- a/docs/plans/multihost/08-ui-design.md +++ b/docs/plans/multihost/08-ui-design.md @@ -2,40 +2,140 @@ ## 0. Startup User Flow +![Startup User Flow](./diagrams/startup-flow.svg) + +
+ASCII fallback (for terminal / code-review views) + +``` + ● + │ + ▼ + ╱─────────────────────────╲ + │ JWT in context.secrets? │ + ╲──no─────────────────yes─╱ + │ │ + ▼ │ + [ AuthGatePanel ] │ + │ │ + └────────┬────────┘ + ▼ + [ HomePanel._update() ] + [ (detection node) ] + │ + ╱─────────────────╲ + │ chosenHostType? │ + ╲──set─────absent─╱ + │ │ + ▼ ▼ + ╱───────────────╲ [ HomePanel — Host Picker ] + │gateway reach? │ [ [Local] [Docker] [SSH…] ] + ╲──yes──────no──╱ │ + │ │ ━━━━━━━━━━┻━━━━━━━━━━ + ▼ ▼ │ │ + [Status panel] [Status panel] ▼ ▼ + [ — online ] [ — offline ] [LocalSetupPanel] [DockerSetupPanel] + │ │ │ │ + └───┬───┘ ▼ ▼ + ▼ [markActiveHost- [markActiveHost- + [ user action ] Chosen('local')] Chosen('docker')] + [ Disconnect/] │ │ + [ Reconfigure] └─────────┬─────────┘ + │ │ + └────────────┬────────────┘ + ━━━━━━┻━━━━━━ + │ + ▼ + [ executeCommand ] + [ ('openclaw.home.refresh') ] + │ + ▼ + ⊗ +``` + +Legend: `●` start · `⊗` end · `╱ ╲` decision · `[ ]` action · `━━━` fork/join bar. + +
+ +**Key callouts** (moved out of the diagram to keep it notation-minimal, UML activity style): + +- **AuthGatePanel** (`authGate.ts`) runs when no JWT is in `context.secrets`. It opens the browser to `occode:///auth?token=...`, stores the returned JWT, and fires `onAuthCompleted` to release the gate. +- **`HomePanel._update()` is the detection node.** Per **ticket-053**, it routes on the persisted `chosenHostType` marker in `hosts.json` — *not* on a live gateway probe. Reachability only chooses between the Status-online and Status-offline render; it never flips the view back to the host-picker once setup has completed. +- **`executeCommand('openclaw.home.refresh')`** at the bottom closes one iteration of the flow. Every subsequent user action that needs to re-evaluate state (Disconnect, Reconfigure, setup completion) fires this command, which re-enters `HomePanel._update()` — the ⊗ is the end of one pass, not the end of the session. + +*Diagram source: [`diagrams/startup-flow.puml`](./diagrams/startup-flow.puml). Re-render with `./diagrams/render.sh [svg|png]` (requires Docker — uses `plantuml/plantuml:latest`).* + +### Flow rules + +1. **Auth gate comes first.** App activation calls `initAuthGate(context, extensionUri)` before `routeHome()`. If no JWT is in `context.secrets`, **`AuthGatePanel`** (from `apps/editor/extensions/openclaw/src/authGate.ts`) opens. The deep-link handler in `extension.ts` stores the JWT and fires `onAuthCompleted`, which closes `AuthGatePanel` and lets `routeHome()` proceed. **`HomePanel` never opens before auth completes.** +2. **HomePanel is the detection node.** There is no separate "Detect Gateway" component — `HomePanel._update()` (`panels/home.ts`) **is** the detection node. It probes the gateway, then conditionally renders either the Status view (via `StatusPanelController`) or the host-picker HTML. Because `HomePanel` is the entrypoint that launches the host-setup panels in the first place, setup panels can always assume `HomePanel` exists. +3. **Setup completion re-enters `HomePanel`, not Status directly.** `LocalSetupPanel`, `DockerSetupPanel`, and (future) `SSHSetupPanel` must call `vscode.commands.executeCommand('openclaw.home.refresh')` on their last step instead of swapping HTML. The `openclaw.home.refresh` command is owned by `HomePanel` (registered in its constructor, deliberately **not** disposed with the panel so it survives setup-panel lifecycle). The command invokes `HomePanel._update()`, which decides Status vs. host-picker based on probe results — a single source of truth for "is the gateway up?". +4. **Disconnect loops to the same detection node.** Disconnecting from the Status view fires `openclaw.home.refresh`; when nothing is running, `HomePanel` renders the host-picker. +5. **No direct setup → Status jump.** Setup panels must not call `StatusPanelController.show()` or swap the webview to Status HTML on their own. The old edge from "gateway now running" directly into Status has been removed from `DockerSetupPanel._handleLaunchGateway()` and replaced with a refresh command dispatch. +6. **Persisted host choice beats gateway reachability for view routing** *(ticket-053)*. `HomePanel._update()` routes on an **explicit-choice marker** written by the setup wizards on success (`HostRegistry.markActiveHostChosen()`), NOT on a live gateway probe. Reachability only decides between a Status-online and a Status-offline render — it never flips the view back to the host-picker once the user has completed setup. The picker only appears when (a) no explicit choice has been recorded, or (b) `openclaw.host.reconfigure` has been invoked to clear the marker. See ticket-053 §2.4 for the schema mechanism and the Option 1-4 alternatives considered. + +## 0a. Status Panel — Offline & Control *(ticket-053)* + +Once `HomePanel._update()` has decided to render the Status panel (because the +explicit-choice marker is set — see Rule 6), the Status panel itself has two +modes plus a universal escape hatch: + ``` - ┌──────────────┐ - │ APP START │ - └──────┬───────┘ - │ - ┌───────────▼───────────┐ - │ Detect Gateway? │ - └───────────┬───────────┘ - YES ─────────────┴───────────── NO - │ │ - ┌──────────▼──────────┐ ┌────────────▼──────────────┐ - │ Connect to │ │ SETUP VIEW │ - │ Gateway │ │ ──────────────────────── │ - └──────────┬──────────┘ │ [💻 Local] [🐳 Docker] │ - │ │ [🌐 SSH — disabled/soon] │ - ┌──────────▼──────────┐ └───┬──────────┬────────────┘ - │ STATUS PANEL │ Local │ Docker│ SSH │ - │ ───────────────── │ │ │ │ - │ • Health / version │ ┌──────▼──────┐ ┌──▼──────────────┐ ┌──────────────┐ - │ • Start / Stop │ │ Local Setup │ │ Docker Setup │ │ SSH Setup │ - │ • AI sign-in │ └──────┬──────┘ │ Wizard │ │ * todo │ - │ • Restart │ │ └──────┬──────────┘ └──────────────┘ - └──────────┬──────────┘ └──────────────┘ - │ │ gateway now running - │◄────────────────────────────┘ - │ - ┌──────────▼──────────┐ - │ [Disconnect] │ - └──────────┬──────────┘ - │ - ▼ - SETUP VIEW (loops back) +┌─────────────────────────────────────────────────────────────┐ +│ Status Panel — online │ +│ │ +│ ● Gateway Running v2026.3 │ +│ [ Stop ] [ Restart ] │ +│ │ +│ Host: Docker — occ-openclaw │ +│ (activity / agents / channels panels) │ +│ │ +│ [ Pick Different Host ] │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ Status Panel — offline │ +│ │ +│ ○ Gateway Stopped │ +│ [ Start ] │ +│ │ +│ Host: Docker — occ-openclaw (last seen 2m ago) │ +│ │ +│ [ Pick Different Host ] │ +└─────────────────────────────────────────────────────────────┘ ``` +**Start / Stop / Restart buttons.** The button template already exists at +`apps/editor/extensions/openclaw/src/panels/statusHtml.ts:1214-1219` with the +state machine `running → Stop`, `stopped → Start`, `errored → Restart` and +intermediate `starting / stopping / restarting` spinners. ticket-053 wires the +existing `gatewayAction` postMessage (`statusHtml.ts:1256`) through to three new +VS Code commands — `openclaw.gateway.start`, `openclaw.gateway.stop`, +`openclaw.gateway.restart` — which each resolve the active +`HostConnection` and call the matching `gatewayStart / gatewayStop / gatewayRestart` +method (`hosts/types.ts:256-258`). Per-adapter: + +- **Local adapter** shells to `openclaw gateway start/stop/restart` — see + [`04-local-adapter.md` "Gateway Lifecycle"](./04-local-adapter.md). +- **Docker adapter** shells to + `docker compose -f docker/docker-compose.openclaw.yml up -d / down / restart` + on the user's workstation — see + [`05-docker-adapter.md` "Gateway Lifecycle"](./05-docker-adapter.md) and the + root `AGENTS.md` § "OpenClaw Docker Gateway". + +**Reconfigure escape hatch.** The "Pick Different Host" button dispatches a new +`openclaw.host.reconfigure` command that: + +1. Clears the explicit-choice marker via `HostRegistry.markActiveHostChosen` + (inverse / unset). +2. Sets `HomePanel.currentPanel._forcePicker = true`. +3. Dispatches `openclaw.home.refresh`. + +This is the only way a user with a persisted host choice can return to the +picker — it un-jails users whose adapter has broken (Docker uninstalled, +compose file deleted, corrupted `hosts.json`) and is also exposed as a palette +command so it can be invoked even if the Status page is unresponsive. + ## 1. Status Bar Host Picker The most-used UI element. Shows the active host in the VS Code status bar. diff --git a/docs/plans/multihost/12-testing.md b/docs/plans/multihost/12-testing.md index 4bd9fcef..09db2abd 100644 --- a/docs/plans/multihost/12-testing.md +++ b/docs/plans/multihost/12-testing.md @@ -297,6 +297,51 @@ describe('MultiHost E2E', () => { }); ``` +### ticket-053 — Persisted Host Choice & Gateway Control + +These extend `tests/e2e/docker-to-ide-flow.spec.ts` (and/or `onboarding-auth.spec.ts`) +past the ticket-052 no-flicker coverage. Tag `@slow` consistent with the +existing Docker flow specs. + +```typescript +// e2e/docker-to-ide-flow.spec.ts — appended scenarios +describe('ticket-053: persisted host choice + gateway control', () => { + it('setup completion persists the explicit-choice marker', async () => { + // Complete Docker setup end-to-end + // Read ~/.occ/hosts.json and assert the explicit-choice marker is set + // (Option 2: HostsFile.explicitChoice === true, OR + // Option 3: the docker host entry has setupCompletedAt) + }); + + it('persisted choice survives gateway-down on reload', async () => { + // Given the explicit-choice marker is set + // Stop the occ-openclaw container + // Reload the Home panel (or the editor window) + // Assert the Status page (offline variant) is rendered + // Assert the host-picker DOM is absent + // Assert the Start Gateway button is visible + }); + + it('Start / Stop / Restart buttons drive the gateway lifecycle', async () => { + // From the stopped state: click Start → expect transition to running + // and `docker compose -f docker/docker-compose.openclaw.yml up -d` (for Docker) OR + // `openclaw gateway start` (for Local) as the shell + // From running: click Stop → expect transition to stopped + // From errored: click Restart → expect transition to running + }); + + it('Reconfigure returns to the host-picker', async () => { + // Click "Pick Different Host" on the Status page + // Assert the host-picker is rendered + // Assert the explicit-choice marker is cleared on disk + }); + + it('ticket-052 no-flicker assertion still passes', async () => { + // Regression: complete setup, toggle visibility, assert Status page stable + }); +}); +``` + ## CI Pipeline ```yaml diff --git a/docs/plans/multihost/diagrams/render.sh b/docs/plans/multihost/diagrams/render.sh new file mode 100755 index 00000000..14eb3b18 --- /dev/null +++ b/docs/plans/multihost/diagrams/render.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Render all *.puml sources in this directory to SVG via the plantuml/plantuml +# docker image. Output ownership is chown'd back to uid 1000 (the repo's +# canonical dev user per root AGENTS.md § Permission Management). +# +# Usage: ./render.sh [format] +# format defaults to svg. Override with png, pdf, eps, etc. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FORMAT="${1:-svg}" + +docker run --rm -v "${DIR}:/data" plantuml/plantuml:latest \ + "-t${FORMAT}" "/data/*.puml" + +docker run --rm -v "${DIR}:/data" --user root alpine \ + chown -R 1000:1000 /data diff --git a/docs/plans/multihost/diagrams/startup-flow.png b/docs/plans/multihost/diagrams/startup-flow.png new file mode 100644 index 00000000..4c2980da Binary files /dev/null and b/docs/plans/multihost/diagrams/startup-flow.png differ diff --git a/docs/plans/multihost/diagrams/startup-flow.puml b/docs/plans/multihost/diagrams/startup-flow.puml new file mode 100644 index 00000000..1100eb6e --- /dev/null +++ b/docs/plans/multihost/diagrams/startup-flow.puml @@ -0,0 +1,69 @@ +@startuml startup-flow +title OCC — Startup User Flow + +skinparam backgroundColor #FFFFFF +skinparam defaultFontName Helvetica +skinparam shadowing false +skinparam activity { + BackgroundColor #FFFFFF + BorderColor #000000 + BorderThickness 1 + BarColor #000000 + DiamondBackgroundColor #FFFFFF + DiamondBorderColor #000000 + DiamondFontColor #000000 + ArrowColor #000000 + ArrowFontColor #000000 + FontColor #000000 + StartColor #000000 + EndColor #000000 +} + +start + +if (JWT in context.secrets?) then (no) + :AuthGatePanel; +else (yes) +endif + +:HomePanel._update() +(detection node); + +if (chosenHostType?) then (set) + + if (gateway reachable?) then (yes) + :Status Panel — online + ● Gateway Running + [Stop] [Restart] + [Pick Different Host]; + else (no) + :Status Panel — offline + ○ Gateway Stopped + [Start] [Restart] + [Pick Different Host]; + endif + + :user action + (Disconnect / Reconfigure); + +else (absent) + + :HomePanel — Host Picker + [ Local ] [ Docker ] [ SSH — soon ]; + + fork + :LocalSetupPanel; + :markActiveHostChosen('local'); + fork again + :DockerSetupPanel; + :markActiveHostChosen('docker'); + end fork + +endif + +:executeCommand +('openclaw.home.refresh'); + +end + +@enduml diff --git a/docs/plans/multihost/diagrams/startup-flow.svg b/docs/plans/multihost/diagrams/startup-flow.svg new file mode 100644 index 00000000..59c7492a --- /dev/null +++ b/docs/plans/multihost/diagrams/startup-flow.svg @@ -0,0 +1 @@ +OCC — Startup User FlowOCC — Startup User FlowAuthGatePanelnoJWT in context.secrets?yesHomePanel._update()(detection node)chosenHostType?setabsentgateway reachable?yesnoStatus Panel — online● Gateway Running[Stop] [Restart][Pick Different Host]Status Panel — offline○ Gateway Stopped[Start] [Restart][Pick Different Host]user action(Disconnect / Reconfigure)HomePanel — Host Picker[ Local ] [ Docker ] [ SSH — soon ]LocalSetupPanelmarkActiveHostChosen('local')DockerSetupPanelmarkActiveHostChosen('docker')executeCommand('openclaw.home.refresh') \ No newline at end of file diff --git a/package.json b/package.json index 79310507..2e2d5a13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "occode", - "version": "3.6.1", + "version": "3.7.0", "private": true, "description": "OCcode — branded cross-platform IDE wrapper with OpenClaw extension", "workspaces": [ diff --git a/tests/e2e/docker-to-ide-flow.spec.ts b/tests/e2e/docker-to-ide-flow.spec.ts index 26245cfd..d0699d03 100644 --- a/tests/e2e/docker-to-ide-flow.spec.ts +++ b/tests/e2e/docker-to-ide-flow.spec.ts @@ -14,7 +14,7 @@ * ticket-042 .tickets/ticket-042-docker-to-ide-e2e-flow/prd.md */ -import { test, expect, type FrameLocator } from './fixtures'; +import { test, expect, type FrameLocator, type Page } from './fixtures'; import { waitForHomePanelTab, getInnerFrame } from './test-utils'; // ─── Test environment defaults ──────────────────────────────────────────────── @@ -255,3 +255,97 @@ test.describe('Post-provision: Connect to Gateway (ticket-031 pending) @slow', ( test.todo('Skip closes OCC Home panel (ticket-034)'); test.todo('Finish Setup closes panel and opens Void sidebar (ticket-034)'); }); + +// ─── Post-completion: Status page stability (ticket-052) ────────────────────── +// Regression coverage for the "flicker back to step 1" bug. After the gateway +// connect step: +// 1. The Status page (Gateway row, etc.) must be visible. +// 2. The host-picker (Local/Docker/SSH cards) must NOT be visible. +// 3. Toggling the webview tab away and back must not re-render the host-picker. +// +// Matches acceptance criteria in .tickets/ticket-052-docker-setup-flicker-reset/prd.md §2.3. +// Tag @slow — excluded from default CI, gated behind a healthy gateway container. + +test.describe('Post-completion: Status page stability (ticket-052) @slow', () => { + /** + * Drive the wizard to completion and wait past the detection hand-off. + * Navigator's fix replaces the old `setTimeout(_showStatusPanel, 1800)` with + * a dispatch of `openclaw.home.refresh`, which re-enters detection. The + * 1800ms delay is preserved so the "Setup complete!" message stays visible. + * We wait ~2.5s after clicking Connect to cover that delay plus the + * detection re-entry render. + */ + async function completeWizardAndWaitForDetection(page: Page): Promise { + await openOccHomePanel(page); + const frame = await clickDockerCard(page); + await advanceToStep3(frame); + await frame.locator('text=Docker environment is ready').waitFor({ timeout: 300_000 }); + await frame.locator('button:has-text("Connect to Gateway")').click(); + // 1800ms handoff + safety margin for detection re-entry. + await page.waitForTimeout(2500); + return frame; + } + + test('After gateway connect, Status page is rendered (no host-picker)', async ({ page }) => { + const frame = await completeWizardAndWaitForDetection(page); + + // The Status page renders a "Gateway" row (see statusHtml.ts:1012). Use + // text-based selectors — statusHtml does not expose `data-testid` hooks + // and this spec should not fabricate them (ticket-052 scope is narrow). + await expect( + frame.locator('text=Gateway').first(), + 'Status page Gateway row should be visible after detection hand-off', + ).toBeVisible({ timeout: 20_000 }); + + // Host-picker cards must be absent — they are the "flicker" symptom. + await expect( + frame.locator('[data-card="docker"]'), + 'Docker host-picker card must NOT appear on the Status page', + ).toHaveCount(0); + await expect( + frame.locator('[data-card="local"]'), + 'Local host-picker card must NOT appear on the Status page', + ).toHaveCount(0); + + // Step 1 config panel must also be absent — a different surface of the + // same "reset to step 1" regression the old condition caused. + await expect( + frame.locator('#docker-config-step-1'), + 'Docker config Step 1 must NOT appear on the Status page', + ).toHaveCount(0); + }); + + test('Status page stable across webview tab hide/show', async ({ page }) => { + const frame = await completeWizardAndWaitForDetection(page); + + // Confirm we start on the Status page. + await expect(frame.locator('text=Gateway').first()).toBeVisible({ timeout: 20_000 }); + + // Toggle the OCC Home tab away by opening the Welcome page / another + // editor tab, then bring OCC Home back. This exercises + // `onDidChangeViewState` — the pathway that previously triggered the + // host-picker re-render via `_update()` when `isDockerRunning` was true. + const homeTab = page.locator('[role="tab"]').filter({ hasText: /OCC Home|Home/ }).first(); + + // Open a new untitled editor to push the OCC Home tab off-screen. + await page.keyboard.press('Control+N').catch(() => null); + await page.waitForTimeout(500); + // Return focus to the OCC Home tab — triggers onDidChangeViewState(visible=true). + await homeTab.click().catch(() => null); + await page.waitForTimeout(2000); + + // Re-assert Status page still visible and host-picker still absent. + await expect( + frame.locator('text=Gateway').first(), + 'Status page Gateway row should still be visible after tab toggle', + ).toBeVisible({ timeout: 20_000 }); + await expect( + frame.locator('[data-card="docker"]'), + 'Host-picker must NOT reappear after webview visibility toggle', + ).toHaveCount(0); + await expect( + frame.locator('#docker-config-step-1'), + 'Docker config Step 1 must NOT reappear after webview visibility toggle', + ).toHaveCount(0); + }); +}); diff --git a/tests/e2e/onboarding-auth.spec.ts b/tests/e2e/onboarding-auth.spec.ts index 53d3cf5a..712589b1 100644 --- a/tests/e2e/onboarding-auth.spec.ts +++ b/tests/e2e/onboarding-auth.spec.ts @@ -96,6 +96,47 @@ test.describe('Onboarding and Authentication', () => { } }); + /** + * Scenario: Auth Gate Appears on Unauthenticated Startup + * Given: no JWT is stored in SecretStorage + * When: the editor activates + * Then: the AuthGate webview is visible with sign-in / sign-up buttons + * And: the Home panel is NOT opened yet (auth gate precedes gateway detection) + * + * Per docs/plans/multihost/08-ui-design.md §0 Rule 1: "Auth gate comes first." + * Note: In the default E2E profile a JWT may or may not be present. When no + * JWT is present we expect the AuthGate panel; when a JWT is present we + * expect the Home panel. The test asserts the rule holds whichever branch + * activation takes. + */ + test('auth gate appears on unauthenticated startup', async ({ page }) => { + // Allow activation to run (initAuthGate is scheduled 500ms after activate()). + await page.locator('.monaco-workbench').waitFor({ timeout: 60_000 }); + await page.waitForTimeout(4000); + + const authGateTab = page.locator('[role="tab"]').filter({ hasText: /Sign in to OpenClaw/ }); + const homeTab = page.locator('[role="tab"]').filter({ hasText: /OCC Home|Home/ }); + + const authGateVisible = await authGateTab.isVisible({ timeout: 10_000 }).catch(() => false); + + if (authGateVisible) { + // Unauthenticated branch — AuthGate must be up, Home must be absent. + const innerFrame = getInnerFrame(page); + const hasSignInBtn = await isButtonVisible(innerFrame, /^sign\s*in$/i, 8_000); + const hasSignUpBtn = await isButtonVisible(innerFrame, /^sign\s*up$/i, 8_000); + expect(hasSignInBtn, 'AuthGate should show a Sign In button').toBe(true); + expect(hasSignUpBtn, 'AuthGate should show a Sign Up button').toBe(true); + + const homeVisible = await homeTab.isVisible({ timeout: 1_000 }).catch(() => false); + expect(homeVisible, 'Home panel should not be open while AuthGate is active').toBe(false); + } else { + // Authenticated branch — JWT already present, gate was skipped, Home opens. + await homeTab.waitFor({ timeout: 30_000 }); + const homeVisible = await homeTab.isVisible({ timeout: 5_000 }).catch(() => false); + expect(homeVisible, 'Home panel should be visible when JWT is already stored').toBe(true); + } + }); + /** * Scenario: Successful Authentication via URI * Given: user initiated create account flow @@ -103,9 +144,13 @@ test.describe('Onboarding and Authentication', () => { * And: extension handles URI callback * Then: Home panel shows logged-in state * And: status bar displays user's balance + * And: AuthGate panel closes after the deep-link fires + * And: Home / gateway detection runs after the gate closes * * Note: Full URI callback requires mock backend setup. - * This test checks for authenticated state indicators. + * This test checks for authenticated state indicators and validates that the + * AuthGate gives way to the Home panel (no direct setup → Status jump; the + * detection node runs only after auth completes per §0 Rule 1). */ test('authenticated state shows balance in status bar', async ({ page }) => { await waitForHomePanelTab(page); @@ -123,6 +168,13 @@ test.describe('Onboarding and Authentication', () => { hasContent, 'Home panel should display content' ).toBe(true); + + // After the deep-link fires, the AuthGate panel must be closed — only the + // Home panel should remain. If the AuthGate tab is still visible, gateway + // detection has been blocked (which would be a Rule 1 regression). + const authGateTab = page.locator('[role="tab"]').filter({ hasText: /Sign in to OpenClaw/ }); + const gateStillOpen = await authGateTab.isVisible({ timeout: 1_000 }).catch(() => false); + expect(gateStillOpen, 'AuthGate should close once authentication completes').toBe(false); }); /** diff --git a/tests/e2e/open-web-control.spec.ts b/tests/e2e/open-web-control.spec.ts new file mode 100644 index 00000000..0f0c2681 --- /dev/null +++ b/tests/e2e/open-web-control.spec.ts @@ -0,0 +1,67 @@ +/** + * open-web-control.spec.ts + * + * Regression test for the "Open Web Control" button. Previously it rendered a + * ConfigPanel webview with an