diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 1fd90c71..d26aa024 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -56,6 +56,9 @@ jobs: timeout-minutes: 120 permissions: contents: write + env: + P12_CHECK: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} + API_KEY_CHECK: ${{ secrets.APPLE_API_KEY_P8 }} steps: - name: Checkout @@ -68,7 +71,26 @@ jobs: with: node-version: '20.18.2' cache: 'npm' - cache-dependency-path: apps/editor/package-lock.json + cache-dependency-path: | + apps/editor/package-lock.json + apps/editor/build/package-lock.json + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + apps/editor/node_modules + apps/editor/build/node_modules + key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/editor/package-lock.json', 'apps/editor/build/package-lock.json') }} + restore-keys: | + node-modules-${{ runner.os }}-${{ runner.arch }}- + + - name: Cache Electron gyp headers + uses: actions/cache@v4 + with: + path: ~/.electron-gyp + key: electron-gyp-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/editor/package.json') }} - name: Stamp release version in product.json if: startsWith(github.ref, 'refs/tags/') @@ -92,8 +114,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_OPTIONS: --max-old-space-size=7168 + CI_DEPS_READY: ${{ steps.cache-node-modules.outputs.cache-hit }} - name: Import certificate to keychain + if: ${{ env.P12_CHECK != '' }} env: P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }} P12_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -112,6 +136,7 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "keychain-password" "$KEYCHAIN_PATH" - name: Sign app + if: ${{ env.P12_CHECK != '' }} env: CODESIGN_IDENTITY: "Developer ID Application: HITL, Inc (SQZ9VHYXJ3)" AGENT_BUILDDIRECTORY: ${{ github.workspace }}/apps @@ -126,6 +151,7 @@ jobs: zip -Xry "$RUNNER_TEMP/OCcode-${{ matrix.artifact_name }}-${GITHUB_REF_NAME}.zip" "OCcode.app" - name: Notarize + if: ${{ env.P12_CHECK != '' && env.API_KEY_CHECK != '' }} env: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} @@ -144,32 +170,40 @@ jobs: --timeout 30m - name: Staple notarization ticket + if: ${{ env.P12_CHECK != '' && env.API_KEY_CHECK != '' }} run: | xcrun stapler staple "$GITHUB_WORKSPACE/apps/${{ matrix.output_dir }}/OCcode.app" - name: Re-zip stapled app + if: ${{ env.P12_CHECK != '' && env.API_KEY_CHECK != '' }} run: | cd "$GITHUB_WORKSPACE/apps/${{ matrix.output_dir }}" zip -Xry "$RUNNER_TEMP/OCcode-${{ matrix.artifact_name }}-${GITHUB_REF_NAME}-signed.zip" "OCcode.app" - name: Verify signature + if: ${{ env.P12_CHECK != '' }} run: | codesign -dv --deep --verbose=4 "$GITHUB_WORKSPACE/apps/${{ matrix.output_dir }}/OCcode.app" 2>&1 spctl -a -vvv -t install "$GITHUB_WORKSPACE/apps/${{ matrix.output_dir }}/OCcode.app" 2>&1 - - name: Upload signed app artifact + - name: Upload app artifact (signed or unsigned) uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 continue-on-error: true with: - name: OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}-signed - path: ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}-signed.zip + name: OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }} + path: | + ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}-signed.zip + ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}.zip retention-days: 30 - name: Create GitHub Release (on tag) if: startsWith(github.ref, 'refs/tags/') uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b + continue-on-error: true with: - files: ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}-signed.zip + files: | + ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}-signed.zip + ${{ runner.temp }}/OCcode-${{ matrix.artifact_name }}-${{ github.ref_name }}.zip name: OCcode ${{ github.ref_name }} body_path: CHANGELOG.md draft: false @@ -197,7 +231,26 @@ jobs: with: node-version: '20.18.2' cache: 'npm' - cache-dependency-path: apps/editor/package-lock.json + cache-dependency-path: | + apps/editor/package-lock.json + apps/editor/build/package-lock.json + + - name: Cache node_modules + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + apps/editor/node_modules + apps/editor/build/node_modules + key: node-modules-${{ runner.os }}-x64-${{ hashFiles('apps/editor/package-lock.json', 'apps/editor/build/package-lock.json') }} + restore-keys: | + node-modules-${{ runner.os }}-x64- + + - name: Cache Electron gyp headers + uses: actions/cache@v4 + with: + path: ~/AppData/Roaming/electron-gyp + key: electron-gyp-${{ runner.os }}-x64-${{ hashFiles('apps/editor/package.json') }} - name: Stamp release version in product.json if: startsWith(github.ref, 'refs/tags/') @@ -220,6 +273,7 @@ jobs: run: make build-windows env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CI_DEPS_READY: ${{ steps.cache-node-modules.outputs.cache-hit }} - name: Sign Windows installers (Azure Trusted Signing) if: ${{ env.AZURE_CLIENT_ID_CHECK != '' }} diff --git a/.tickets/ticket-050-ci-build-caching/prd.md b/.tickets/ticket-050-ci-build-caching/prd.md new file mode 100644 index 00000000..6d548d83 --- /dev/null +++ b/.tickets/ticket-050-ci-build-caching/prd.md @@ -0,0 +1,169 @@ +# Ticket 050 — CI build caching for Windows & macOS + +## 2.1 Problem Statement + +GitHub Actions builds for Windows and macOS take significantly longer than necessary. Each workflow run performs identical work: +- Re-downloads all npm tarballs (even when `package-lock.json` hasn't changed) +- Re-downloads Electron gyp headers (~100 MB per architecture per run) +- Recompiles all native modules from source (Electron 34.3.2, `build_from_source=true` in `.npmrc`) + +The `setup-node` action currently caches only `~/.npm` for `apps/editor/package-lock.json`, missing `apps/editor/build/package-lock.json` entirely and providing no cache for compiled `node_modules/` or Electron headers. + +## 2.2 Proposed Solution + +Implement per-platform caching across the CI workflow: + +1. **Extend npm tarball cache** — Fix `cache-dependency-path` in both `build-macos` and `build-windows` jobs to cover both `apps/editor/package-lock.json` AND `apps/editor/build/package-lock.json`. This caches tarballs for the build toolchain, eliminating re-downloads. + +2. **Cache compiled node_modules** — Add `actions/cache` step to cache `apps/editor/node_modules/` and `apps/editor/build/node_modules/` keyed on OS + architecture + combined lock file hash. Pass cache-hit status (`CI_DEPS_READY` env var) to the build target. + +3. **Cache Electron gyp headers** — Add `actions/cache` step for `~/.electron-gyp` (macOS) and `~/AppData/Roaming/electron-gyp` (Windows), keyed on OS + architecture + Electron version hash. + +4. **Conditional npm ci** — Modify Makefile `build-core` to check `CI_DEPS_READY=true` and skip `npm ci` when `node_modules/` are already cached, using a shell conditional: + ```makefile + if [ "$$CI_DEPS_READY" = "true" ]; then \ + echo "==> Skipping npm ci (node_modules cache hit)"; \ + else \ + ( npm ci --ignore-scripts & (cd build && npm ci --ignore-scripts) & wait ); \ + fi && \ + ``` + +### Architecture + +Cache keys guarantee platform isolation: +- `node-modules-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles(...) }}` + - macOS arm64: `node-modules-macOS-arm64-` + - macOS x64: `node-modules-macOS-x64-` + - Windows x64: `node-modules-Windows-x64-` + +- `electron-gyp-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('apps/editor/package.json') }}` + - Ensures Electron version changes invalidate the cache + +Fallback via `restore-keys` allows partial hits on `runner.os-runner.arch-` when lock files change slightly. + +## 2.3 Acceptance Criteria + +- [ ] Makefile `build-core` respects `CI_DEPS_READY` env var and skips `npm ci` when set to `true` +- [ ] `build-macos` job caches both `package-lock.json` files in npm tarball cache +- [ ] `build-macos` job caches `node_modules/` directories with per-arch key +- [ ] `build-macos` job caches `~/.electron-gyp` with Electron version key +- [ ] `build-windows` job has identical cache setup (different header path) +- [ ] First run on `ci-test` branch: creates cache entries +- [ ] Second run on `ci-test` branch: cache hits and `npm ci` is skipped (confirmed in logs) +- [ ] Windows build total time is measurably lower on warm cache (target: >30% reduction) +- [ ] No regression: build succeeds identically on cold cache (no cache entry) + +## 2.4 Technical Considerations + +- **Actions/cache v4**: Uses GitHub-provided cache storage (5 GB per repo, LRU eviction). Paths and keys must exactly match across runs for hits. +- **Shell variable escaping**: Makefile uses `$$CI_DEPS_READY` to ensure shell-level variable expansion, not Make-level. +- **node_modules deletion by npm ci**: The conditional skip is necessary because `npm ci` always deletes `node_modules/` before installing. Caching `node_modules/` only helps if `npm ci` is skipped. +- **Extension node_modules**: Not cached (too granular, installed per-extension via `xargs -P8` in `postinstall.js`). Cache focuses on critical path: main editor + build toolchain. +- **Cross-platform header paths**: + - macOS: `~/.electron-gyp` + - Windows (bash shell): `~/AppData/Roaming/electron-gyp` + +## 2.5 Dependencies + +- None. All changes are in CI/CD configuration and Makefile (non-blocking for code changes). + +## 2.6 Constraints & Non-Goals + +- **Constraint**: Cache storage is limited (5 GB per repo). If cache grows beyond this, GitHub Actions LRU eviction takes effect. Monitor cache size via GHA Settings > Actions > General > Caches. +- **Non-goal**: Do not cache `~/.npm` (setup-node does this already); do not cache extension-specific node_modules (too granular). +- **Non-goal**: Do not modify package.json or Makefile scripts; only add conditional logic. + +## 2.7 Success Metrics + +- **Cache hit rate**: On repeated runs of the same branch, >90% cache hit rate for `node_modules` (confirmed via GHA cache analytics). +- **Build time savings**: + - Windows: baseline ~20-25 min (cold), target ~15-18 min (warm, -25% to -35%) + - macOS: baseline ~15-20 min per arch (cold), target ~12-16 min (warm) +- **No build failures**: All platforms continue to build successfully on cold cache (first run, no entries). + +--- + +## Tasks + +### Task 1: Modify Makefile build-core + +**Status**: TODO +**Depends on**: None + +- [ ] Subtask 1.1: Add conditional check for `CI_DEPS_READY` in `build-core` target + - **Objective**: Wrap the parallel `npm ci` block with a shell `if` statement that skips when `CI_DEPS_READY=true` + - **Test**: Run `make build-core` locally with `CI_DEPS_READY=true` and confirm `npm ci` message does not appear + - **Depends on**: None + +### Task 2: Update build-macos workflow job + +**Status**: TODO +**Depends on**: Task 1 + +- [ ] Subtask 2.1: Fix `cache-dependency-path` in Setup Node.js + - **Objective**: Add `apps/editor/build/package-lock.json` to the multi-line path list + - **Test**: Validate YAML syntax + - **Depends on**: None + +- [ ] Subtask 2.2: Add Cache node_modules step + - **Objective**: Insert after Setup Node.js, before Stamp release version. Use OS+arch-specific key with hashFiles. + - **Test**: Run on ci-test branch, confirm GHA shows cache step + - **Depends on**: Subtask 2.1 + +- [ ] Subtask 2.3: Add Cache Electron gyp headers step + - **Objective**: Insert after Cache node_modules. Cache `~/.electron-gyp` with Electron version key. + - **Test**: First run creates cache entry; inspect GHA cache storage + - **Depends on**: Subtask 2.2 + +- [ ] Subtask 2.4: Pass CI_DEPS_READY to Build macOS step + - **Objective**: Add `CI_DEPS_READY: ${{ steps.cache-node-modules.outputs.cache-hit }}` to env + - **Test**: On second run, confirm logs show "Skipping npm ci" message + - **Depends on**: Subtask 2.3 + +### Task 3: Update build-windows workflow job + +**Status**: TODO +**Depends on**: Task 2 + +- [ ] Subtask 3.1–3.4: Repeat identical changes to build-windows job + - **Objective**: Mirror build-macos changes (different Electron header path for Windows) + - **Test**: Validate YAML; run on ci-test, confirm cache behavior + - **Depends on**: Subtask 2.4 + +### Task 4: Test on ci-test branch + +**Status**: TODO +**Depends on**: Task 3 + +- [ ] Subtask 4.1: Push to ci-test and run first build + - **Objective**: Populate cache (no hits expected) + - **Test**: Workflow completes; GHA shows cache created entries + - **Depends on**: Task 3 + +- [ ] Subtask 4.2: Re-run same branch + - **Objective**: Trigger cache hits + - **Test**: Logs show "Skipping npm ci" message in build-core step; cache-hit: true for both cache steps + - **Depends on**: Subtask 4.1 + +- [ ] Subtask 4.3: Measure build time before/after + - **Objective**: Document wall-clock time for Windows job (cold vs warm cache) + - **Test**: Compare job duration from first vs second run + - **Depends on**: Subtask 4.2 + +## Notes + +- Cache keys use `hashFiles()` to detect lock file changes. If lock files change, cache is automatically invalidated. +- Extensions node_modules (installed via `postinstall.js`) are NOT cached to keep cache size manageable. These install quickly in parallel (xargs -P8). +- The `restore-keys` fallback allows partial cache hits when lock files change slightly (e.g., one dependency version bump). + +## Root Cause & Fix + +**Root Cause**: +- npm tarball cache only covered main `package-lock.json`, not the separate `build/` subdirectory. +- `node_modules/` was never cached, requiring full recompilation of native modules (Electron headers + `build_from_source=true`). +- Electron gyp headers were re-downloaded on every run (~100 MB waste). + +**Fix**: +- Extended caching to all lock files and compiled artifacts. +- Added conditional `npm ci` skip to avoid re-deleting cached `node_modules/`. +- Ensured cache keys are platform-specific (OS + arch) to prevent cross-platform binary incompatibility. 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 eed10173..158faa31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,82 @@ 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) + + +### Bug Fixes + +* **macos-ci:** publish unsigned artifacts when signing secrets missing ([1d5c5b9](https://github.com/asieduernest12/occ/commit/1d5c5b9205c06639bb01a0f26ec1df739c2042a8)) + +## [3.6.0](https://github.com/asieduernest12/occ/compare/v3.5.4...v3.6.0) (2026-04-17) + + +### Features + +* **ci:** per-platform npm and electron caching for Windows & macOS ([797f11f](https://github.com/asieduernest12/occ/commit/797f11f314b54950583545c0bc4cac34b8f41a04)) +* **ci:** skip macOS signing/notarization when secrets missing ([45afba1](https://github.com/asieduernest12/occ/commit/45afba1b282e0e2b421c5966ae83a77ae3f9989e)) + + +### Bug Fixes + +* **native-modules:** add explicit npm rebuild on Windows for vscode-policy-watcher.node ([cb79286](https://github.com/asieduernest12/occ/commit/cb792862fa5df6671973ee0f1c396437dc9e4fa7)) +* **ticket-039:** apply branded icons across all platforms ([77d7676](https://github.com/asieduernest12/occ/commit/77d7676e7549ccb3ff61fe97a75444108bdb052c)) +* **ticket-039:** remove stale branding across all platform manifests ([e0d8f06](https://github.com/asieduernest12/occ/commit/e0d8f063804a2000b0e7e1552dba71d868039edd)) +* **ticket-039:** replace web server icons and fix manifest branding ([152433e](https://github.com/asieduernest12/occ/commit/152433eac56eeb1493376ee53d8a924422124f5c)) +* **windows-build:** ensure native modules rebuild even on npm cache hit ([46f894e](https://github.com/asieduernest12/occ/commit/46f894e40bee8396b21cd8e7698a2085a9aa05fb)) + +## [3.6.0](https://github.com/asieduernest12/occ/compare/v3.5.4...v3.6.0) (2026-04-17) + + +### Features + +* **ci:** per-platform npm and electron caching for Windows & macOS ([797f11f](https://github.com/asieduernest12/occ/commit/797f11f314b54950583545c0bc4cac34b8f41a04)) +* **ci:** skip macOS signing/notarization when secrets missing ([45afba1](https://github.com/asieduernest12/occ/commit/45afba1b282e0e2b421c5966ae83a77ae3f9989e)) + + +### Bug Fixes + +* **native-modules:** add explicit npm rebuild on Windows for vscode-policy-watcher.node ([cb79286](https://github.com/asieduernest12/occ/commit/cb792862fa5df6671973ee0f1c396437dc9e4fa7)) +* **ticket-039:** apply branded icons across all platforms ([77d7676](https://github.com/asieduernest12/occ/commit/77d7676e7549ccb3ff61fe97a75444108bdb052c)) +* **ticket-039:** remove stale branding across all platform manifests ([e0d8f06](https://github.com/asieduernest12/occ/commit/e0d8f063804a2000b0e7e1552dba71d868039edd)) +* **ticket-039:** replace web server icons and fix manifest branding ([152433e](https://github.com/asieduernest12/occ/commit/152433eac56eeb1493376ee53d8a924422124f5c)) +* **windows-build:** ensure native modules rebuild even on npm cache hit ([46f894e](https://github.com/asieduernest12/occ/commit/46f894e40bee8396b21cd8e7698a2085a9aa05fb)) + +## [3.5.4](https://github.com/asieduernest12/occ/compare/v3.5.3...v3.5.4) (2026-04-16) + +## [3.5.5](https://github.com/asieduernest12/occ/compare/v3.5.4...v3.5.5) (2026-04-17) + + +### Bug Fixes + +* **native-modules:** add explicit npm rebuild on Windows for vscode-policy-watcher.node ([cb79286](https://github.com/asieduernest12/occ/commit/cb792862fa5df6671973ee0f1c396437dc9e4fa7)) +* **ticket-039:** apply branded icons across all platforms ([77d7676](https://github.com/asieduernest12/occ/commit/77d7676e7549ccb3ff61fe97a75444108bdb052c)) +* **ticket-039:** remove stale branding across all platform manifests ([e0d8f06](https://github.com/asieduernest12/occ/commit/e0d8f063804a2000b0e7e1552dba71d868039edd)) +* **ticket-039:** replace web server icons and fix manifest branding ([152433e](https://github.com/asieduernest12/occ/commit/152433eac56eeb1493376ee53d8a924422124f5c)) + +## [3.5.4](https://github.com/asieduernest12/occ/compare/v3.5.3...v3.5.4) (2026-04-16) + ## [3.5.4](https://github.com/asieduernest12/occ/compare/v3.5.3...v3.5.4) (2026-04-16) ## [3.5.3](https://github.com/asieduernest12/occ/compare/v3.4.3...v3.5.3) (2026-04-16) diff --git a/Makefile b/Makefile index 2a26c5a5..479518b4 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,13 @@ build-core: cd $(PROJECT_ROOT)/apps/editor && \ export NODE_OPTIONS="--max-old-space-size=7168" && \ echo "==> Install editor + build dependencies (parallel)" && \ - ( npm ci --ignore-scripts & (cd build && npm ci --ignore-scripts) & wait ) && \ + if [ "$$CI_DEPS_READY" != "true" ]; then \ + ( npm ci --ignore-scripts & (cd build && npm ci --ignore-scripts) & wait ); \ + else \ + echo "==> Skipping npm ci (node_modules cache hit)"; \ + fi && \ + echo "==> Rebuild native modules for Electron ($(ELECTRON_ARCH))" && \ + npx --yes @electron/rebuild -v 34.3.2 -a $(ELECTRON_ARCH) && \ echo "==> Patch compilation.js" && \ node -e " \ const fs = require('fs'); \ diff --git a/apps/editor/build/darwin/sign.ts b/apps/editor/build/darwin/sign.ts index ecf16274..fa821048 100644 --- a/apps/editor/build/darwin/sign.ts +++ b/apps/editor/build/darwin/sign.ts @@ -92,21 +92,21 @@ async function main(buildDir?: string): Promise { '-insert', 'NSAppleEventsUsageDescription', '-string', - 'An application in Visual Studio Code wants to use AppleScript.', + 'An application in OCcode wants to use AppleScript.', `${infoPlistPath}` ]); await spawn('plutil', [ '-replace', 'NSMicrophoneUsageDescription', '-string', - 'An application in Visual Studio Code wants to use the Microphone.', + 'An application in OCcode wants to use the Microphone.', `${infoPlistPath}` ]); await spawn('plutil', [ '-replace', 'NSCameraUsageDescription', '-string', - 'An application in Visual Studio Code wants to use the Camera.', + 'An application in OCcode wants to use the Camera.', `${infoPlistPath}` ]); } diff --git a/apps/editor/build/lib/electron.ts b/apps/editor/build/lib/electron.ts index 3bb047df..f09441be 100644 --- a/apps/editor/build/lib/electron.ts +++ b/apps/editor/build/lib/electron.ts @@ -105,7 +105,7 @@ export const config = { productAppName: product.nameLong, companyName: 'Microsoft Corporation', copyright: 'Copyright (C) 2024 Microsoft. All rights reserved', - darwinIcon: 'resources/darwin/code.icns', + darwinIcon: 'resources/darwin/occode.icns', darwinBundleIdentifier: product.darwinBundleIdentifier, darwinApplicationCategoryType: 'public.app-category.developer-tools', darwinHelpBookFolder: 'VS Code HelpBook', diff --git a/apps/editor/build/npm/postinstall.js b/apps/editor/build/npm/postinstall.js index 7a72694d..96dd751e 100644 --- a/apps/editor/build/npm/postinstall.js +++ b/apps/editor/build/npm/postinstall.js @@ -175,6 +175,27 @@ function removeParcelWatcherPrebuild(dir) { } } +/** + * Rebuild native modules explicitly on Windows. + * @param {string} dir + * @param {*} [opts] + */ +function npmRebuild(dir, opts) { + opts = { + env: { ...process.env }, + ...(opts ?? {}), + cwd: dir, + stdio: 'inherit', + shell: true + }; + + // Only rebuild on Windows where build_from_source might need explicit rebuild + if (process.platform === 'win32') { + log(dir, 'Rebuilding native modules...'); + run(npm, ['rebuild'], opts); + } +} + // ── Main ──────────────────────────────────────────────────────────────────── async function main() { @@ -253,6 +274,13 @@ async function main() { const elapsed = ((Date.now() - t0) / 1000).toFixed(1); log('.', `Parallel install done — ${plainDirs.length} dirs in ${elapsed}s`); + // ── Native module rebuild (Windows) ───────────────────────────────────── + // On Windows, explicitly rebuild native modules after npm install + // to ensure modules like vscode-policy-watcher.node are compiled + if (process.platform === 'win32') { + npmRebuild('', { env: { ...process.env } }); + } + // ── Git config ────────────────────────────────────────────────────────── try { cp.execSync('git config pull.rebase merges', { cwd: root }); diff --git a/apps/editor/build/win32/code.iss b/apps/editor/build/win32/code.iss index 213f38ee..10376a82 100644 --- a/apps/editor/build/win32/code.iss +++ b/apps/editor/build/win32/code.iss @@ -23,7 +23,7 @@ SetupMutex={#AppMutex}setup ; this is a // Void icon comment. Old: WizardImageFile="{#RepoDir}\resources\win32\inno-big-100.bmp,{#RepoDir}\resources\win32\inno-big-125.bmp,{#RepoDir}\resources\win32\inno-big-150.bmp,{#RepoDir}\resources\win32\inno-big-175.bmp,{#RepoDir}\resources\win32\inno-big-200.bmp,{#RepoDir}\resources\win32\inno-big-225.bmp,{#RepoDir}\resources\win32\inno-big-250.bmp" ; this is a // Void icon comment. Old: WizardSmallImageFile="{#RepoDir}\resources\win32\inno-small-100.bmp,{#RepoDir}\resources\win32\inno-small-125.bmp,{#RepoDir}\resources\win32\inno-small-150.bmp,{#RepoDir}\resources\win32\inno-small-175.bmp,{#RepoDir}\resources\win32\inno-small-200.bmp,{#RepoDir}\resources\win32\inno-small-225.bmp,{#RepoDir}\resources\win32\inno-small-250.bmp" ; COMMENTED OUT WizardImageFile="" -WizardSmallImageFile="{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp,{#RepoDir}\resources\win32\inno-void.bmp" +WizardSmallImageFile="{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp,{#RepoDir}\resources\win32\inno-occode.bmp" SetupIconFile={#RepoDir}\resources\win32\occode.ico UninstallDisplayIcon={app}\{#ExeBasename}.exe ChangesEnvironment=true @@ -38,8 +38,8 @@ ArchitecturesInstallIn64BitMode={#ArchitecturesInstallIn64BitMode} WizardStyle=modern // We've seen an uptick on broken installations from updates which were unable -// to shutdown VS Code. We rely on the fact that the update signals -// that VS Code is ready to be shutdown, so we're good to use `force` here. +// to shutdown OCcode. We rely on the fact that the update signals +// that OCcode is ready to be shutdown, so we're good to use `force` here. CloseApplications=force #ifdef Sign @@ -1325,7 +1325,7 @@ begin #if "user" == InstallTarget if not WizardSilent() and IsAdmin() then begin - if MsgBox('This User Installer is not meant to be run as an Administrator. If you would like to install VS Code for all users in this system, download the System Installer instead from https://code.visualstudio.com. Are you sure you want to continue?', mbError, MB_OKCANCEL) = IDCANCEL then begin + if MsgBox('This User Installer is not meant to be run as an Administrator. If you would like to install OCcode for all users in this system, download the System Installer instead from https://openclawcode.ai. Are you sure you want to continue?', mbError, MB_OKCANCEL) = IDCANCEL then begin Result := False; end; end; @@ -1426,7 +1426,7 @@ begin Result := ''; end; -// VS Code will create a flag file before the update starts (/update=C:\foo\bar) +// OCcode will create a flag file before the update starts (/update=C:\foo\bar) // - if the file exists at this point, the user quit Code before the update finished, so don't start Code after update // - otherwise, the user has accepted to apply the update and Code should start function LockFileExists(): Boolean; 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/apps/editor/package-lock.json b/apps/editor/package-lock.json index 99bea4dc..d19edf01 100644 --- a/apps/editor/package-lock.json +++ b/apps/editor/package-lock.json @@ -1,11 +1,11 @@ { - "name": "code-oss-dev", + "name": "occode-dev", "version": "1.99.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "code-oss-dev", + "name": "occode-dev", "version": "1.99.3", "hasInstallScript": true, "license": "MIT", diff --git a/apps/editor/package.json b/apps/editor/package.json index 80bfb90a..e4fb156b 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -1,9 +1,9 @@ { - "name": "code-oss-dev", + "name": "occode-dev", "version": "1.99.3", "distro": "21c8d8ea1e46d97c5639a7cabda6c0e063cc8dd5", "author": { - "name": "Microsoft Corporation" + "name": "OCcode Contributors" }, "license": "MIT", "main": "./out/main.js", diff --git a/apps/editor/product.json b/apps/editor/product.json index d1255312..b878b67d 100644 --- a/apps/editor/product.json +++ b/apps/editor/product.json @@ -13,8 +13,8 @@ "win32MutexName": "occodeeditor", "win32AppIcon": "resources/win32/occode.ico", "licenseName": "MIT", - "licenseUrl": "https://github.com/voideditor/void/blob/main/LICENSE.txt", - "serverLicenseUrl": "https://github.com/voideditor/void/blob/main/LICENSE.txt", + "licenseUrl": "https://github.com/asieduernest12/occ/blob/main/LICENSE", + "serverLicenseUrl": "https://github.com/asieduernest12/occ/blob/main/LICENSE", "serverGreeting": [], "serverLicense": [], "serverLicensePrompt": "", @@ -35,7 +35,7 @@ "darwinBundleIdentifier": "com.openclaw.occode", "linuxIconName": "occode", "linuxDescription": "The simplest way to set up and manage OpenClaw locally.", - "linuxDescriptionLong": "OCcode is the simplest way to set up and manage OpenClaw locally. A branded IDE built on the Void editor, it combines the simplicity of a code editor with AI-powered editing capabilities and the OpenClaw extension.", + "linuxDescriptionLong": "OCcode is the simplest way to set up and manage OpenClaw locally. A branded IDE built on open-source technology, it combines the simplicity of a code editor with AI-powered editing capabilities and the OpenClaw extension.", "linuxHomepage": "https://openclawcode.ai", "linuxMaintainer": "OCcode Contributors", "licenseFileName": "LICENSE.txt", diff --git a/apps/editor/resources/darwin/occode.icns b/apps/editor/resources/darwin/occode.icns new file mode 100644 index 00000000..e39708e9 Binary files /dev/null and b/apps/editor/resources/darwin/occode.icns differ diff --git a/apps/editor/resources/linux/code-url-handler.desktop b/apps/editor/resources/linux/code-url-handler.desktop index d31528b0..4fa611bf 100644 --- a/apps/editor/resources/linux/code-url-handler.desktop +++ b/apps/editor/resources/linux/code-url-handler.desktop @@ -9,4 +9,4 @@ NoDisplay=true StartupNotify=true Categories=Utility;TextEditor;Development;IDE; MimeType=x-scheme-handler/@@URLPROTOCOL@@; -Keywords=vscode; +Keywords=occode; diff --git a/apps/editor/resources/linux/code.desktop b/apps/editor/resources/linux/code.desktop index 906c4827..65af2b46 100755 --- a/apps/editor/resources/linux/code.desktop +++ b/apps/editor/resources/linux/code.desktop @@ -10,7 +10,7 @@ StartupWMClass=@@NAME_SHORT@@ Categories=TextEditor;Development;IDE; MimeType=application/x-@@NAME@@-workspace; Actions=new-empty-window; -Keywords=vscode; +Keywords=occode; [Desktop Action new-empty-window] Name=New Empty Window diff --git a/apps/editor/resources/linux/code.png b/apps/editor/resources/linux/code.png index 0775ecbe..7535641d 100644 Binary files a/apps/editor/resources/linux/code.png and b/apps/editor/resources/linux/code.png differ diff --git a/apps/editor/resources/server/code-192.png b/apps/editor/resources/server/code-192.png index ea1c6911..6ddc8c15 100644 Binary files a/apps/editor/resources/server/code-192.png and b/apps/editor/resources/server/code-192.png differ diff --git a/apps/editor/resources/server/code-512.png b/apps/editor/resources/server/code-512.png index d90d7ade..bec87ece 100644 Binary files a/apps/editor/resources/server/code-512.png and b/apps/editor/resources/server/code-512.png differ diff --git a/apps/editor/resources/server/favicon.ico b/apps/editor/resources/server/favicon.ico index 572acef9..5e06a87a 100644 Binary files a/apps/editor/resources/server/favicon.ico and b/apps/editor/resources/server/favicon.ico differ diff --git a/apps/editor/resources/server/manifest.json b/apps/editor/resources/server/manifest.json index 443e3fd1..d8febc40 100644 --- a/apps/editor/resources/server/manifest.json +++ b/apps/editor/resources/server/manifest.json @@ -1,6 +1,6 @@ { - "name": "Code - OSS", - "short_name": "Code- OSS", + "name": "OCcode", + "short_name": "OCcode", "start_url": "/", "lang": "en-US", "display": "standalone", diff --git a/apps/editor/resources/win32/inno-occode.bmp b/apps/editor/resources/win32/inno-occode.bmp new file mode 100644 index 00000000..1f4f5c45 Binary files /dev/null and b/apps/editor/resources/win32/inno-occode.bmp differ diff --git a/apps/editor/resources/win32/occode.ico b/apps/editor/resources/win32/occode.ico index b408ae73..5e06a87a 100644 Binary files a/apps/editor/resources/win32/occode.ico and b/apps/editor/resources/win32/occode.ico differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 572acef9..5e06a87a 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/icon.png b/apps/web/public/icon.png index d90d7ade..7535641d 100644 Binary files a/apps/web/public/icon.png and b/apps/web/public/icon.png differ diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico index 572acef9..5e06a87a 100644 Binary files a/apps/web/src/app/favicon.ico and b/apps/web/src/app/favicon.ico differ diff --git a/branding/icon.icns b/branding/icon.icns new file mode 100644 index 00000000..e39708e9 Binary files /dev/null and b/branding/icon.icns differ 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 6f7e3133..2e2d5a13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "occode", - "version": "3.5.4", + "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