diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index d296be6..88df005 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -12,8 +12,8 @@ body: id: version attributes: label: Version - description: Settings → About, or the GitHub Release tag (for example 0.16.1). - placeholder: 0.16.1 + description: Settings → About, or the GitHub Release tag (for example 0.17.0). + placeholder: 0.17.0 validations: required: true - type: dropdown diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 1392198..53aa476 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,6 +12,7 @@ private advisory: https://github.com/Modsofthenation/Locus/security/advisories/n Local (CI also runs these, then builds `@locus/desktop`, `locus/agent:0.1`, and the Android debug APK): - [ ] `npm test` +- [ ] `npm run test:visual` (or N/A if the desktop UI did not change) - [ ] `npm run typecheck` - [ ] `npm run lint` - [ ] `npm run format:check` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d44e8a..ee18dcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,10 @@ jobs: ELECTRON_SKIP_BINARY_DOWNLOAD: "1" - run: sudo apt-get update && sudo apt-get install -y ripgrep - run: npm test + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + working-directory: apps/desktop + - run: npm run test:visual - run: npm run typecheck - run: npm run lint - run: npm run format:check @@ -28,6 +32,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: "22.22" + - name: Prefetch code-server + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mkdir -p images/agent/vendor + node scripts/prefetch-github-release-asset.mjs --owner coder --repo code-server --tag v4.96.4 --file code-server-4.96.4-linux-amd64.tar.gz --out images/agent/vendor/code-server.tar.gz - run: docker build -t locus/agent:0.1 images/agent android: @@ -46,5 +59,7 @@ jobs: with: packages: platform-tools - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 + with: + gradle-version: "8.9" - name: Assemble debug APK - run: ./gradlew assembleDebug --no-daemon --stacktrace + run: gradle assembleDebug --no-daemon --stacktrace diff --git a/.github/workflows/dist-linux.yml b/.github/workflows/dist-linux.yml index 48ff810..c1a84fa 100644 --- a/.github/workflows/dist-linux.yml +++ b/.github/workflows/dist-linux.yml @@ -25,7 +25,7 @@ jobs: # v0.4.1 is a Windows-only hotfix; skip Linux packaging for that tag. if: github.ref != 'refs/tags/v0.4.1' runs-on: ubuntu-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -34,8 +34,36 @@ jobs: with: node-version: 22 cache: npm + # github.com/releases/download 504s from Actions. Prefetch Electron (zip + + # SHASUMS256.txt) and 7zip / AppImage / icons through the GitHub API. + - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + id: electron-cache + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} + restore-keys: | + electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- + electron-${{ runner.os }}- - run: npm ci - - run: npm run dist --workspace=@locus/desktop + - name: Prefetch Electron and electron-builder binaries + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/prefetch-github-release-asset.mjs + - name: Package + env: + RETRY_ATTEMPTS: "3" + RETRY_DELAY_MS: "30000" + run: node scripts/retry-cmd.mjs npm run dist --workspace=@locus/desktop + - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + if: always() + continue-on-error: true + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: ${{ steps.electron-cache.outputs.cache-primary-key }} - name: Checksums run: node scripts/release-checksums.mjs apps/desktop/dist-electron SHA256SUMS-linux - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/dist-macos.yml b/.github/workflows/dist-macos.yml index 85c838a..c95a116 100644 --- a/.github/workflows/dist-macos.yml +++ b/.github/workflows/dist-macos.yml @@ -25,7 +25,7 @@ jobs: # v0.4.1 is a Windows-only hotfix; skip macOS packaging for that tag. if: github.ref != 'refs/tags/v0.4.1' runs-on: macos-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -34,11 +34,29 @@ jobs: with: node-version: 22 cache: npm + # github.com/releases/download 504s from Actions. Prefetch Electron (zip + + # SHASUMS256.txt) and 7zip / icons through the GitHub API. + - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + id: electron-cache + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} + restore-keys: | + electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- + electron-${{ runner.os }}- - run: npm ci + - name: Prefetch Electron and electron-builder binaries + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/prefetch-github-release-asset.mjs - name: Package env: CSC_LINK_RAW: ${{ secrets.CSC_LINK }} CSC_KEY_PASSWORD_RAW: ${{ secrets.CSC_KEY_PASSWORD }} + RETRY_ATTEMPTS: "3" + RETRY_DELAY_MS: "30000" run: | # GitHub interpolates missing secrets as "", and electron-builder # treats empty CSC_LINK as a certificate path (the desktop dir). @@ -49,7 +67,15 @@ jobs: else export CSC_IDENTITY_AUTO_DISCOVERY=false fi - npm run dist --workspace=@locus/desktop + node scripts/retry-cmd.mjs npm run dist --workspace=@locus/desktop + - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + if: always() + continue-on-error: true + with: + path: | + ~/Library/Caches/electron + ~/Library/Caches/electron-builder + key: ${{ steps.electron-cache.outputs.cache-primary-key }} - name: Checksums run: node scripts/release-checksums.mjs apps/desktop/dist-electron SHA256SUMS-macos - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.github/workflows/dist-windows.yml b/.github/workflows/dist-windows.yml index db9e6b5..a7391ed 100644 --- a/.github/workflows/dist-windows.yml +++ b/.github/workflows/dist-windows.yml @@ -23,7 +23,7 @@ concurrency: jobs: package: runs-on: windows-latest - timeout-minutes: 45 + timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -32,8 +32,38 @@ jobs: with: node-version: 22 cache: npm + # github.com/releases/download 504s from Actions. Prefetch Electron (zip + + # SHASUMS256.txt) and 7zip / NSIS / icons through the GitHub API so + # @electron/get does not re-fetch checksums after the zip (that 504 is + # what failed Windows after Linux/macOS already succeeded). + - uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + id: electron-cache + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ github.run_id }} + restore-keys: | + electron-${{ runner.os }}-${{ hashFiles('package-lock.json') }}- + electron-${{ runner.os }}- - run: npm ci - - run: npm run dist --workspace=@locus/desktop + - name: Prefetch Electron and electron-builder binaries + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/prefetch-github-release-asset.mjs + - name: Package + env: + RETRY_ATTEMPTS: "3" + RETRY_DELAY_MS: "30000" + run: node scripts/retry-cmd.mjs npm run dist --workspace=@locus/desktop + - uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + if: always() + continue-on-error: true + with: + path: | + ~/AppData/Local/electron/Cache + ~/AppData/Local/electron-builder/Cache + key: ${{ steps.electron-cache.outputs.cache-primary-key }} - name: Checksums run: node scripts/release-checksums.mjs apps/desktop/dist-electron SHA256SUMS-windows - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 diff --git a/.gitignore b/.gitignore index 0cd7fda..55d5a12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +images/agent/vendor/*.tar.gz dist/ build/ .turbo/ @@ -17,6 +18,9 @@ apps/server/dist/ packages/*/dist/ .idea/ .vscode/ +playwright-report/ +blob-report/ +test-results/ *.qcow2 *.iso *.img diff --git a/CHANGELOG.md b/CHANGELOG.md index b34a878..78f655d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +## 0.17.0 — 2026-11-08 + +Quieter desktop shell, more guest runtimes, extra forges, and more reliable installer builds. Settings → About is **0.17.0**. + +**Runtimes.** Dev Container files can define install, start, ports, and image when a Locus environment file is absent. Docker, Podman, and nerdctl are all valid container engines. Linux guests on Apple silicon use arm64 QEMU; Windows guests stay x86_64. Host coding CLIs can run over SSH inside a Linux VM. LAN workers can run QEMU guests (live display stays on the worker). + +**Forges and subscriptions.** Gitea, Forgejo, and Azure DevOps can be added as forges. Subscribe also understands Linear, Jira, Sentry, Grafana alerts, incident.io, and pull URLs from those extra forges. + +**Desktop.** Shared themed dialogs, empty states, shortcuts, undo toasts, and command-palette search. Failed actions surface in the notice stack with Retry. Confirm and prompt no longer use the browser’s native dialogs. The computer column stays collapsed until the agent opens a canvas. + +**Packaging.** Linux, macOS, and Windows installer jobs prefetch Electron and packaging tools through the GitHub Releases API and retry failed fetches. + ## 0.16.1 — 2026-11-08 First public release. diff --git a/CONTEXT.md b/CONTEXT.md index 7c135b9..46970dc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -24,7 +24,7 @@ The `/workspace` tree the operator Files pane and the agent file tools share. Ho ## Forge -GitHub, GitLab, or Bitbucket as the remote for clone, push, pull requests, and issues. Locus stays the local catalog. `ForgeClient` selects one forge adapter per account. +GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps as the remote for clone, push, pull requests, and issues. Locus stays the local catalog. `ForgeClient` selects one forge adapter per account. Gitea/Forgejo/Azure DevOps are PAT-only and are not auto-added as empty Settings cards. ## Design diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 39f3acd..d4b4607 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,16 @@ npm run lint npm run format:check ``` -CI on `main` and pull requests runs those plus `npm run build --workspace=@locus/desktop`, a Docker image build, and `cd apps/android && ./gradlew assembleDebug`. When Docker is available, tests include a real container write/patch/commit eval. Installers are **Package Linux / macOS / Windows**; they also publish to a GitHub Release when you push a `v*` tag. **Actions → Package from main → Run workflow** builds all three platforms from current `main` and uploads artifacts without publishing a release. Only the Linux job sets `generate_release_notes: true` so the three parallel packagers do not concatenate the changelog. +Visual regression (Chromium, Linux baselines in `apps/desktop/e2e`): + +```bash +npx playwright install chromium +npm run test:visual +``` + +After an intentional UI change, recapture screenshots with `npm run test:visual:update`. CI Linux is the source of truth. Harness-backed README shots (New agent, New automation) copy with `npm run capture:readme`. Playwright also runs axe on confirm, prompt, feedback, empty-hero, shortcuts, and chat, plus phone-sized shots of confirm / prompt / toast / feedback / shortcuts. Visual runs with `prefers-reduced-motion: reduce`. + +CI on `main` and pull requests runs those plus `npm run test:visual`, `npm run build --workspace=@locus/desktop`, a Docker image build, and `cd apps/android && ./gradlew assembleDebug`. When Docker is available, tests include a real container write/patch/commit eval. Installers are **Package Linux / macOS / Windows**; they also publish to a GitHub Release when you push a `v*` tag. **Actions → Package from main → Run workflow** builds all three platforms from current `main` and uploads artifacts without publishing a release. Only the Linux job sets `generate_release_notes: true` so the three parallel packagers do not concatenate the changelog. Please run those four checks before you open a PR. Target `main`. @@ -39,8 +48,8 @@ First agent without Docker: Settings → Providers (Ollama needs no key) then Ne After merge to `main`: ```bash -git tag v0.16.1 -git push origin v0.16.1 +git tag v0.17.0 +git push origin v0.17.0 ``` GitHub Releases are the distribution channel (`v*` tags). Pushing a `v*` tag publishes Linux, macOS, and Windows installers, then **Prune GitHub Releases** deletes older published releases so only the last three remain (git tags are kept). See [docs/public-release.md](docs/public-release.md) for listing settings this tree cannot change (visibility, vulnerability reporting, unsigned macOS/Windows). Keep the repository description as the README tagline and topics `ai-agents`, `qemu`, `docker`, `computer-use`, `electron`, `local-first`. diff --git a/README.md b/README.md index 3ebc94f..e860041 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Locus is a desktop control plane for AI coding agents that run on hardware you own — a Docker container, a QEMU virtual machine, or a folder on disk. Chat on one side, the live guest on the other. Code, keys, sessions, and disks stay under `~/.locus`. -This is **v0.16.1** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for. +This is **v0.17.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for. ## Install @@ -38,15 +38,15 @@ You describe a task. Locus runs the agent in a guest you own. Review diffs, stee The runtime is chosen per agent and stays fixed. New agents default to Docker when the engine is present, otherwise Host. -**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker or Host** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. VMs stay on this machine. Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement. +**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker, Host, or QEMU VM** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. Worker VMs resolve cloud images from `LOCUS_WORKER_HOME/media`; live VNC stays on the worker localhost in v1 (screenshots and tools still work). Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement. ## Tour -The icon rail is **Agents, Design, Games, Assets, IDE, Automations**, then Board, Projects, Codebase, Brain, Slack, Grafana, Docker. Settings is a full page. Developer / Swarm in the sidebar switches the product shell: Developer is this control plane; Swarm hides the rail and is teammate-first (Teammates, Teams, Routines). +The icon rail is **Agents, Design, IDE, Codebase, Automations**, then **More** for Games, Assets, Board, Projects, Brain, Slack, Grafana, and Docker. Settings is a full page. Developer / Swarm lives in Appearance (and the command palette) — Developer is this control plane; Swarm hides the rail and is teammate-first (Teammates, Teams, Routines). ### Setup -First-run wizard: provider key, optional Docker, optional QEMU, then Developer or Swarm. **Skip for now** leaves a one-line Finish setup hint until you Continue or Dismiss it. The command palette **Finish setup** reopens the wizard. +First-run wizard: provider key, then Developer or Swarm. Docker and QEMU sit under **Set up later**. **Skip for now** leaves a one-line Finish setup hint until you Continue or Dismiss it. The command palette **Finish setup** reopens the wizard. ![Set up Locus wizard](docs/screenshots/setup_wizard.png) @@ -58,7 +58,7 @@ Chat sits on the left. The guest sits on the right (Docker opens on **Files**; H Consecutive reads, searches, and commands collapse into one summary (`Explored 8 files, 4 searches, ran 1 command`). Expand the row for the per-call chips. Edits, questions, sub-agents, and pending approvals stay on their own rows. -Ask an agent to **subscribe** to a Slack channel or thread, a GitHub/GitLab/Bitbucket pull request, or a timer. A Listening card stays in the chat, and Agent settings lists every live subscription. The Agents rail surfaces **Needs you** when a chat is waiting on Approve / Deny. **Compare models** and **Replay** sit on those rows. Name a golden image, or export the chat as HTML or PDF, from the agent menu. Multi-file `apply_patch` / `git_diff` cards open the Change Stack walkthrough. A `Locus.md` at the repo root loads ahead of `AGENTS.md`, `CLAUDE.md`, and `.locus/rules`. +Ask an agent to **subscribe** to a Slack channel or thread, a GitHub/GitLab/Bitbucket/Gitea/Forgejo/Azure DevOps pull request, a Linear/Jira/Sentry/incident.io issue, a Grafana alert, or a timer. A Listening card stays in the chat, and Agent settings lists every live subscription. The Agents rail surfaces **Needs you** when a chat is waiting on Approve / Deny. **Compare models** and **Replay** sit on those rows. Name a golden image, or export the chat as HTML or PDF, from the agent menu. Multi-file `apply_patch` / `git_diff` cards open the Change Stack walkthrough. A `Locus.md` at the repo root loads ahead of `AGENTS.md`, `CLAUDE.md`, and `.locus/rules`. @@ -112,7 +112,7 @@ Operator chrome includes Orbit, Wireframe, clip Play (idle / walk / jump / picku ### IDE -Host workspaces live in the **IDE** rail. Open a folder on this machine, or clone a linked GitHub, GitLab, or Bitbucket repo. Chat sits on the right (collapse it to a rail). The Files editor is the main pane: markdown and SVG preview, minimap, wrap, split editors (`Ctrl/Cmd+\` or explorer **Open to the side**), `Ctrl/Cmd+Shift+K` inline AI, Go to file (`Ctrl/Cmd+P`), workspace symbols (`Ctrl/Cmd+T`), Search (`Ctrl/Cmd+Shift+F`), and a workspace terminal. The explorer includes a **Containers** section so you can inspect, stop, or restart host Docker containers. +Host workspaces live in the **IDE** rail. Open a folder on this machine, or clone a linked GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps repo. Chat sits on the right (collapse it to a rail). The Files editor is the main pane: markdown and SVG preview, minimap, wrap, split editors (`Ctrl/Cmd+\` or explorer **Open to the side**), `Ctrl/Cmd+Shift+K` inline AI, Go to file (`Ctrl/Cmd+P`), workspace symbols (`Ctrl/Cmd+T`), Search (`Ctrl/Cmd+Shift+F`), and a workspace terminal. The explorer includes a **Containers** section so you can inspect, stop, or restart host Docker containers. Type `@file` in the composer to rank that path in the repo map. TypeScript gets auto-import, Quick Fix, Go to Type Definition / Implementation, and workspace Problems (**Fix with agent**, **Run test**). Python uses `pylsp` with the project virtualenv. Completions also route to `gopls`, `rust-analyzer`, and `jdtls` when those binaries are on PATH. The status-bar Debug pane can Launch / Continue / Step / Stop for Node and Python on Host and local Docker. Worker and VM sessions can Launch against the laptop mirror. The Git tab can branch, merge, discard, stash, stage hunks, rebase, resolve conflicts, and open a PR. Codebase **Open PR in IDE** checks out the PR head and paints review comments in the gutter. Phone **More** lists those workspaces as **Open on desktop**. @@ -210,7 +210,7 @@ Git includes **Sign in with GitHub** / **Sign in with GitLab** / **Sign in with Settings → Phone mints a LAN pairing token (QR on that tab). Settings → Workers lists LAN / Cloud VM / Fargate hosts. Settings → Environments lists named boxes saved from New agent or Automations. Settings → Notifications toggles desktop alerts and quiet hours. Settings → Appearance filters themes and sets editor font size, format on save, and the minimap. Settings → Advanced **Tailscale Funnel** exposes **only** `/api/hooks` (never the whole control plane) so GitHub can reach forge webhooks; `locus config funnel enable|disable`. -Integrations: Slack, Grafana, Jira, Confluence, Linear, Miro, Figma, incident.io, AWS IAM, Portainer, Kubernetes, Sentry, Gmail, Google Calendar, Google Drive, and agent-authored HTTP plugins. Settings → About is **0.16.1**. +Integrations: Slack, Grafana, Jira, Confluence, Linear, Miro, Figma, incident.io, AWS IAM, Portainer, Kubernetes, Sentry, Gmail, Google Calendar, Google Drive, and agent-authored HTTP plugins. Settings → About is **0.17.0**.
@@ -303,7 +303,7 @@ Developer / Swarm in the sidebar switches the product shell. Developer keeps the **CLI.** `npx locus` / `npm run locus --` is a loopback client for the same control plane: agents, design, games, assets, IDE, projects, Codebase, Brain, Slack, Grafana, Docker, automations, workflows, Settings, phone remote, and Funnel. `locus serve` starts the server when the app is not running. `locus project keep` leaves that control plane up after the desktop quits. `locus workflow` imports and runs Agent Loop Builder graphs. `locus brain setup` writes personal editor hooks that send a redacted transcript tail to the local brain. Live VNC and canvas clicking stay in the desktop. Guide: [docs/cli.md](docs/cli.md). -**App.** Ninety-seven themes (Ember default). Settings → About is **0.16.1**. Electron packages (Linux AppImage, macOS dmg, Windows NSIS), or `npm run dev` in the browser. **Ctrl/Cmd+K** command palette. **Ctrl/Cmd+N** new agent, new automation, or (on Design, Games, Assets) the empty composer. **Ctrl/Cmd+F** find in chat. **Ctrl/Cmd+,** settings. +**App.** Ninety-seven themes (Ember default). Settings → About is **0.17.0**. Electron packages (Linux AppImage, macOS dmg, Windows NSIS), or `npm run dev` in the browser. **Ctrl/Cmd+K** command palette. **Ctrl/Cmd+N** new agent, new automation, or (on Design, Games, Assets) the empty composer. **Ctrl/Cmd+F** find in chat. **Ctrl/Cmd+,** settings. ## Security model @@ -373,7 +373,7 @@ Docker agents support undo, fork, workers (2–4 parallel containers from the sa ## Git -Clone a GitHub, GitLab, Bitbucket, or generic git remote into the guest on boot. Pick several linked repos on New agent to clone them side by side under `/workspace/`. Sign in with GitHub, GitLab, or Bitbucket in Settings → Git, or paste a forge token. GitHub uses device flow; GitLab and Bitbucket open a browser and return to `http://127.0.0.1:/api/oauth/callback`. Each Sign in row needs an OAuth client ID (GitLab and Bitbucket also need the application secret), or set `LOCUS_GITHUB_OAUTH_CLIENT_ID` / `LOCUS_GITLAB_OAUTH_CLIENT_ID` / `LOCUS_BITBUCKET_OAUTH_CLIENT_ID`. +Clone a GitHub, GitLab, Bitbucket, Gitea, Forgejo, Azure DevOps, or generic git remote into the guest on boot. Pick several linked repos on New agent to clone them side by side under `/workspace/`. Sign in with GitHub, GitLab, or Bitbucket in Settings → Git, or paste a PAT for Gitea, Forgejo, or Azure DevOps. `.devcontainer/devcontainer.json` maps onto `.locus/environment.json` install, start, ports, image, and env. Container engines can be Docker, Podman, or nerdctl (`LOCUS_CONTAINER_ENGINE`). Linux QEMU guests follow the host arch (`qemu-system-aarch64` + `virt` on arm64). Cursor / Claude / Codex / Kiro CLIs can run inside a Linux VM over SSH. GitHub uses device flow; GitLab and Bitbucket open a browser and return to `http://127.0.0.1:/api/oauth/callback`. Each Sign in row needs an OAuth client ID (GitLab and Bitbucket also need the application secret), or set `LOCUS_GITHUB_OAUTH_CLIENT_ID` / `LOCUS_GITLAB_OAUTH_CLIENT_ID` / `LOCUS_BITBUCKET_OAUTH_CLIENT_ID`. Commit, branch, stash, revert, push, open a PR (optional draft), post a `git_pr_review`, and list/get/comment issues (`git_issue_list` / `git_issue_get` / `git_issue_comment`). Push, PR, review, comment, merge, and issue comments wait for Approve / Deny. Protected branches (`main`/`master`) stay gated. Forge tokens stay in Settings and can be injected as `GIT_TOKEN` in the container. @@ -496,6 +496,8 @@ Index: [docs/README.md](docs/README.md). ```bash npm test npm run typecheck +npm run test:visual +npm run capture:readme ``` See [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md index 6975657..e0b9c1c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c - Bind the HTTP/WebSocket control plane to loopback (`127.0.0.1` / `::1` / `localhost` only) unless Settings → Phone is enabled. Saving a non-loopback bind host in Advanced is still rejected. Phone remote binds `0.0.0.0` and requires a vaulted pairing token for every non-loopback client; public internet peers are rejected even with a token. The same port multiplexes loopback HTTP with LAN HTTPS (self-signed cert in `~/.locus/tls/`, fingerprint on the pairing QR). Minting, rotating, and revealing that token is loopback-only. Restart after disabling phone remote so 7420 leaves `0.0.0.0`. The `locus` CLI (`apps/cli`) is another loopback client of that API — same bind, redaction, and approval gates as the desktop. - Reject browser requests whose `Origin` is not loopback. - Keep QEMU display, SSH, RDP, WinRM, QMP, and serial forwards on loopback. VNC has no password and SPICE uses `disable-ticketing`; that is accepted for a single-operator loopback display, not as a network service. -- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only. +- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. Worker QEMU guests keep VNC/SPICE on the **worker** loopback in v1 (not forwarded to the laptop). Cursor / Claude / Codex / Kiro on a Linux VM spawn over SSH with `-R` of the control-plane MCP port into the guest; Windows VMs stay Docker/Host-only for those CLIs. The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only. - Redact provider keys, forge tokens, guest passwords, and matching vault secrets in API responses, traces, session JSON, and tool output sent to the model. Tool-argument **values** are pattern-scanned, not only secret-named keys. GitLab `glpat-`, phone `ph_`, Chrome extension `cx_`, and Atlassian `ATATT` formats are included. `GET /api/automations` masks `webhookSecret` / `slackSigningSecret`; create/rotate still returns the secret once. - Store those secrets in `~/.locus/secrets.enc` (AES-256-GCM). The packaged app wraps the master key with Electron `safeStorage` when the OS keychain is available, then deletes plaintext `master.key`. - MCP stdio servers inherit a minimal host env (`PATH`, locale, temp). They do not receive `LOCUS_MASTER_KEY`. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 3fcd822..253459a 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -10,8 +10,8 @@ android { applicationId = "app.locus.android" minSdk = 26 targetSdk = 35 - versionCode = 30 - versionName = "0.16.1" + versionCode = 31 + versionName = "0.17.0" } buildTypes { release { diff --git a/apps/android/gradle/wrapper/gradle-wrapper.properties b/apps/android/gradle/wrapper/gradle-wrapper.properties index 09523c0..d22acce 100644 --- a/apps/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/android/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -networkTimeout=10000 +networkTimeout=60000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/apps/android/gradlew b/apps/android/gradlew old mode 100644 new mode 100755 diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json index f838a66..3bb19f5 100644 --- a/apps/chrome-extension/manifest.json +++ b/apps/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Locus", - "version": "0.16.1", + "version": "0.17.0", "description": "Let Locus host agents use your Chrome tabs — navigate, click, type, and screenshot the pages you already have open.", "action": { "default_title": "Locus", diff --git a/apps/cli/bin/locus.mjs b/apps/cli/bin/locus.mjs old mode 100644 new mode 100755 diff --git a/apps/cli/package.json b/apps/cli/package.json index e0f3b9e..350f08d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@locus/cli", - "version": "0.16.1", + "version": "0.17.0", "private": true, "description": "Locus CLI — control plane client for agents, Slack, Codebase, Docker, and automations", "type": "module", diff --git a/apps/cli/src/cli.test.ts b/apps/cli/src/cli.test.ts index 455e8d2..e4d558c 100644 --- a/apps/cli/src/cli.test.ts +++ b/apps/cli/src/cli.test.ts @@ -880,12 +880,7 @@ describe("locus CLI", () => { checkout: { url: (body as { url: string }).url }, }), }); - const result = await cli([ - "repo", - "link", - "--url", - "https://github.com/Modsofthenation/Locus", - ]); + const result = await cli(["repo", "link", "--url", "https://github.com/Modsofthenation/Locus"]); expect(result.code).toBe(0); expect(result.stdout).toContain("Locus"); }); diff --git a/apps/desktop/e2e/visual.spec.ts b/apps/desktop/e2e/visual.spec.ts new file mode 100644 index 0000000..eceb1cc --- /dev/null +++ b/apps/desktop/e2e/visual.spec.ts @@ -0,0 +1,173 @@ +import { expect, test, type Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +const THEMES = ["ember", "paper"] as const; +const SCREENS = [ + "new-agent", + "new-automation", + "empty-hero", + "rail", + "confirm", + "prompt", + "toast", + "appearance", + "design-empty", + "ide-empty", + "settings-providers", + "feedback", + "shortcuts", + "chat", + "empty-hint", + "splitter", +] as const; +const EXTRA_THEMES = ["midnight", "contrast"] as const; +const EXTRA_SCREENS = ["confirm", "appearance"] as const; + +function shotTarget(page: Page, screen: (typeof SCREENS)[number]) { + if (screen === "rail") return page.locator(".side-rail"); + if (screen === "empty-hero" || screen === "design-empty" || screen === "ide-empty") { + return page.locator(".empty-hero"); + } + if (screen === "toast") return page.locator(".app-notice"); + if (screen === "appearance") return page.locator(".appearance-layout"); + if (screen === "settings-providers") return page.locator(".settings-page"); + if (screen === "feedback") return page.locator("[data-testid='visual-feedback']"); + if (screen === "shortcuts") return page.locator("[data-testid='palette-shortcuts']"); + if (screen === "chat") return page.locator("[data-testid='visual-chat']"); + if (screen === "empty-hint") return page.locator("[data-testid='visual-empty-hint']"); + if (screen === "splitter") return page.locator("[data-testid='visual-splitter']"); + return page.locator(".modal"); +} + +async function openScreen(page: Page, screen: string, theme: string) { + await page.clock.setFixedTime(new Date("2026-03-01T08:00:00.000Z")); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(`/visual.html?screen=${screen}&theme=${theme}`); + await page.locator("html[data-visual-ready]").waitFor(); + await page.evaluate(async () => { + await document.fonts.ready; + }); + await expect.poll(() => page.evaluate(() => document.fonts.check("16px Outfit"))).toBe(true); +} + +test.describe("visual regression", () => { + for (const theme of THEMES) { + for (const screen of SCREENS) { + test(`${screen} ${theme}`, async ({ page }) => { + await openScreen(page, screen, theme); + await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); + }); + } + + test(`new-automation schedule menu ${theme}`, async ({ page }) => { + await openScreen(page, "new-automation", theme); + await page.locator('[aria-label="Schedule"]').click(); + await page.locator(".menu-pop.placed").waitFor(); + await expect(page).toHaveScreenshot(`new-automation-schedule-${theme}.png`); + }); + } + + for (const theme of EXTRA_THEMES) { + for (const screen of EXTRA_SCREENS) { + test(`${screen} ${theme}`, async ({ page }) => { + await openScreen(page, screen, theme); + await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); + }); + } + } + + test("see-all themes group by family", async ({ page }) => { + await openScreen(page, "appearance", "ember"); + await page.getByRole("button", { name: /See all \d+ themes/ }).click(); + await expect(page.locator("[data-testid='theme-family-dark']")).toBeVisible(); + await expect(page.locator("[data-testid='theme-family-light']")).toBeVisible(); + await expect(page.locator("[data-testid='theme-family-contrast']")).toBeVisible(); + await expect(page.locator("[data-testid='theme-family-gradient']")).toBeVisible(); + }); + + test("named buttons on feedback and shell screens", async ({ page }) => { + for (const screen of [ + "confirm", + "prompt", + "toast", + "new-agent", + "empty-hero", + "rail", + "feedback", + "shortcuts", + "chat", + "empty-hint", + "splitter", + ] as const) { + await openScreen(page, screen, "ember"); + const unnamed = await page.locator("button").evaluateAll((buttons) => + buttons + .filter((btn) => { + const name = ( + btn.getAttribute("aria-label") || + btn.getAttribute("title") || + btn.textContent || + "" + ).trim(); + return !name; + }) + .map((btn) => btn.outerHTML.slice(0, 160)), + ); + expect(unnamed, screen).toEqual([]); + } + }); + + test("axe on confirm, prompt, feedback, empty hero, shortcuts, chat, empty hint, and splitter", async ({ + page, + }) => { + for (const screen of [ + "confirm", + "prompt", + "feedback", + "empty-hero", + "shortcuts", + "chat", + "empty-hint", + "splitter", + ] as const) { + await openScreen(page, screen, "ember"); + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa"]) + .disableRules(["region", "page-has-heading-one", "landmark-one-main", "color-contrast"]) + .analyze(); + expect( + results.violations, + `${screen}: ${results.violations.map((row) => row.id).join(", ")}`, + ).toEqual([]); + } + }); + + test("busy-dot is near-still with reduced motion", async ({ page }) => { + await openScreen(page, "feedback", "ember"); + const duration = await page + .locator(".ui-busy-dot") + .evaluate((el) => getComputedStyle(el).animationDuration); + const ms = duration.endsWith("ms") + ? Number.parseFloat(duration) + : Number.parseFloat(duration) * 1000; + expect(ms).toBeLessThan(20); + }); +}); + +test.describe("visual phone", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + for (const screen of [ + "confirm", + "prompt", + "toast", + "feedback", + "shortcuts", + "empty-hint", + ] as const) { + test(`${screen} ember phone`, async ({ page }) => { + await openScreen(page, screen, "ember"); + await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-ember-phone.png`); + }); + } +}); diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png new file mode 100644 index 0000000..28821a0 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png new file mode 100644 index 0000000..5f43e7a Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png new file mode 100644 index 0000000..955ed38 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png new file mode 100644 index 0000000..dd477ad Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/chat-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/chat-ember.png new file mode 100644 index 0000000..5caf836 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/chat-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/chat-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/chat-paper.png new file mode 100644 index 0000000..b348826 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/chat-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-contrast.png b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-contrast.png new file mode 100644 index 0000000..6ee4b39 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-contrast.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember-phone.png new file mode 100644 index 0000000..58efc3f Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember.png new file mode 100644 index 0000000..7f51e84 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-midnight.png b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-midnight.png new file mode 100644 index 0000000..c952fa3 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-midnight.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-paper.png new file mode 100644 index 0000000..96c379e Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/confirm-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-ember.png new file mode 100644 index 0000000..269fd20 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-paper.png new file mode 100644 index 0000000..f99d91f Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/design-empty-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-ember.png new file mode 100644 index 0000000..395c488 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-paper.png new file mode 100644 index 0000000..d77b18a Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hero-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember-phone.png new file mode 100644 index 0000000..b30ad82 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember.png new file mode 100644 index 0000000..3c7842f Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-paper.png new file mode 100644 index 0000000..1ef5a2d Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/empty-hint-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember-phone.png new file mode 100644 index 0000000..c29bef2 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember.png new file mode 100644 index 0000000..7b3e7e2 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-paper.png new file mode 100644 index 0000000..f717102 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/feedback-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png new file mode 100644 index 0000000..2151aa8 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png new file mode 100644 index 0000000..86fb899 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-ember.png new file mode 100644 index 0000000..a1b1d6d Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-paper.png new file mode 100644 index 0000000..269a984 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-agent-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-ember.png new file mode 100644 index 0000000..27c1b17 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-paper.png new file mode 100644 index 0000000..be11409 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-ember.png new file mode 100644 index 0000000..59f497a Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-paper.png new file mode 100644 index 0000000..05d0265 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember-phone.png new file mode 100644 index 0000000..a7f268f Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember.png new file mode 100644 index 0000000..9260366 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-paper.png new file mode 100644 index 0000000..e72d363 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/prompt-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png new file mode 100644 index 0000000..ee6f6a5 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/rail-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/rail-paper.png new file mode 100644 index 0000000..c5088c7 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/rail-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-ember.png new file mode 100644 index 0000000..1e41efb Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-paper.png new file mode 100644 index 0000000..545afd9 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember-phone.png new file mode 100644 index 0000000..55e8203 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember.png new file mode 100644 index 0000000..5497e11 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png new file mode 100644 index 0000000..bd80a14 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-ember.png new file mode 100644 index 0000000..dd04906 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-paper.png new file mode 100644 index 0000000..fd8a695 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/splitter-paper.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember-phone.png b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember-phone.png new file mode 100644 index 0000000..3847c75 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember-phone.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember.png new file mode 100644 index 0000000..7524dec Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-ember.png differ diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/toast-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-paper.png new file mode 100644 index 0000000..726e7a4 Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/toast-paper.png differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d4fbcc2..1bb223d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@locus/desktop", - "version": "0.16.1", + "version": "0.17.0", "private": true, "author": "Locus contributors", "license": "MIT", @@ -15,6 +15,9 @@ "typecheck": "tsc --noEmit", "lint": "eslint .", "test": "vitest run", + "test:visual": "playwright test", + "test:visual:update": "playwright test --update-snapshots", + "capture:readme": "node ./scripts/capture-readme-screens.mjs", "electron": "electron .", "dist": "npm run build && electron-builder --publish never" }, @@ -28,6 +31,8 @@ "uqr": "^0.1.3" }, "devDependencies": { + "@axe-core/playwright": "^4.14.0", + "@playwright/test": "^1.65.0", "@types/node": "^26.6.5", "@types/react": "^19.1.4", "@types/react-dom": "^19.2.7", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..444ed22 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = 4179; +const host = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [["github"], ["list"]] : "list", + timeout: 30_000, + snapshotPathTemplate: "{testDir}/{testFilePath}-snapshots/{arg}{ext}", + expect: { + toHaveScreenshot: { + animations: "disabled", + caret: "hide", + maxDiffPixelRatio: 0.01, + }, + }, + use: { + ...devices["Desktop Chrome"], + baseURL: host, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 1, + locale: "en-US", + timezoneId: "UTC", + colorScheme: "dark", + reducedMotion: "reduce", + trace: "off", + }, + webServer: { + command: `npx vite --port ${port} --strictPort --host 127.0.0.1`, + url: `${host}/visual.html`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/apps/desktop/scripts/capture-readme-screens.mjs b/apps/desktop/scripts/capture-readme-screens.mjs new file mode 100644 index 0000000..35a3303 --- /dev/null +++ b/apps/desktop/scripts/capture-readme-screens.mjs @@ -0,0 +1,23 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = dirname(fileURLToPath(import.meta.url)); +const snapshots = join(root, "../e2e/visual.spec.ts-snapshots"); +const dest = join(root, "../../../docs/screenshots"); + +const copies = [ + ["new-agent-ember.png", "new_agent.png"], + ["new-automation-ember.png", "new_automation.png"], +]; + +mkdirSync(dest, { recursive: true }); +for (const [from, to] of copies) { + const src = join(snapshots, from); + if (!existsSync(src)) { + console.error(`missing ${src} — run npm run test:visual:update first`); + process.exit(1); + } + copyFileSync(src, join(dest, to)); + console.log(`copied ${from} -> ${to}`); +} diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 83a9940..43276d5 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -47,7 +47,7 @@ vi.mock("@/lib/api", () => ({ connected: false, tabs: [], })), - checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.16.1" })), + checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.17.0" })), workersStatus: vi.fn(async () => []), tailscale: vi.fn(async () => ({ installed: false, @@ -172,7 +172,7 @@ describe("App boot screen", () => { expect(host.textContent).not.toContain("What should this agent do?"); expect(document.documentElement.dataset.theme).toBe("nord"); await vi.waitFor(() => { - expect(host.textContent).toMatch(/EXPLORER/); + expect(host.querySelector(".vm-rail-label")?.textContent).toBe("Files"); }); }); @@ -327,8 +327,13 @@ describe("App mobile shell", () => { }); expect(host.querySelector(".skip-links")?.textContent).toContain("Skip to chat"); await act(async () => { - const docker = host.querySelector( - ".side-rail-nav button[aria-label='Docker']", + host + .querySelector(".rail-more summary") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await act(async () => { + const docker = [...host.querySelectorAll(".rail-more [role='menuitem']")].find((el) => + el.textContent?.includes("Docker"), ) as HTMLButtonElement; docker.click(); }); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7a1f3f1..27b32b5 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -35,12 +35,14 @@ import { EmptyState } from "@/components/layout/EmptyState"; import { SessionSwitcher } from "@/components/layout/SessionSwitcher"; import { FeatureChecklist } from "@/components/layout/FeatureChecklist"; import { NoticeBanner } from "@/components/layout/NoticeBanner"; +import { DialogHost } from "@/components/layout/DialogHost"; import { Sidebar } from "@/components/layout/Sidebar"; import { Topbar } from "@/components/layout/Topbar"; import { WorkspaceBody } from "@/components/workspace/WorkspaceBody"; import { WorkspaceStage } from "@/components/workspace/WorkspaceStage"; import { SwarmComputerPanel } from "@/components/layout/SwarmComputerPanel"; import { api, connectEvents, retryEvents } from "@/lib/api"; +import { matchModKey } from "@/lib/shortcuts"; import { locusWindowTitle } from "@/lib/windowTitle"; import { sleep } from "@/lib/net"; import { @@ -694,8 +696,7 @@ export function App() { useEffect(() => { function onKey(e: KeyboardEvent) { - const meta = e.metaKey || e.ctrlKey; - if (meta && e.key.toLowerCase() === "n") { + if (matchModKey(e, "n")) { e.preventDefault(); if (state.section === "settings") return; if (state.section === "automations") dispatch({ type: "newAutomation", open: true }); @@ -723,7 +724,7 @@ export function App() { } } else dispatch({ type: "select", id: null }); } - if (meta && e.key === ",") { + if (matchModKey(e, ",")) { e.preventDefault(); dispatch({ type: "settings", open: true }); } @@ -820,6 +821,8 @@ export function App() { return ( + + {booting ? retryBootRef.current()} /> : null} {state.productModeTransition ? ( @@ -988,7 +991,6 @@ export function App() { {state.setupOpen ? : null} - ({ api: { @@ -95,10 +96,9 @@ describe("AssetEmpty", () => { expect(host.textContent).toContain("Printability"); expect(host.querySelector("[aria-label='Asset kit']")).toBeTruthy(); expect(host.textContent).toContain("Sprite pack"); - const kit = host.querySelector("[aria-label='Asset kit']") as HTMLSelectElement; + expect(host.querySelector("[aria-label='Asset kit']")).toBeTruthy(); act(() => { - kit.value = "rig-kit"; - kit.dispatchEvent(new Event("change", { bubbles: true })); + setSelectValue(host, "Asset kit", "rig-kit"); }); const starter = [...host.querySelectorAll("button")].find((btn) => btn.textContent?.includes("Image to 3D"), @@ -193,13 +193,12 @@ describe("AssetEmpty", () => { , ); }); - const picker = host.querySelector("[aria-label='Asset style']") as HTMLSelectElement; - expect(picker).toBeTruthy(); - expect(picker.textContent).toContain("Clay hero"); + expect(host.querySelector("[aria-label='Asset style']")).toBeTruthy(); + expect(host.textContent).toContain("Clay hero"); act(() => { - picker.value = "st1"; - picker.dispatchEvent(new Event("change", { bubbles: true })); + setSelectValue(host, "Asset style", "st1"); }); + expect(selectValue(host, "Asset style")).toBe("st1"); const starter = [...host.querySelectorAll("button")].find((btn) => btn.textContent?.includes("Image to 3D"), ); diff --git a/apps/desktop/src/components/assets/AssetExportModal.tsx b/apps/desktop/src/components/assets/AssetExportModal.tsx index d57cb8f..1a88bd5 100644 --- a/apps/desktop/src/components/assets/AssetExportModal.tsx +++ b/apps/desktop/src/components/assets/AssetExportModal.tsx @@ -4,9 +4,9 @@ import { formatAssetExportTitle, type AssetExportJob, } from "@locus/shared"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -import { useDialogFocus } from "@/lib/useDialogFocus"; +import { Modal } from "@/components/layout/Modal"; import { HostFolderPicker } from "@/components/workspace/HostFolderField"; export const ASSET_EXPORT_DIR_KEY = "locus.assetExportDir"; @@ -41,7 +41,6 @@ export function AssetExportModal(props: { onExport?: (next: { filename: string; folder: string }) => void; }) { const { progress, onClose, onExport } = props; - const dialogRef = useRef(null); const saveAs = progress.phase === "save"; const busy = !saveAs && progress.phase !== "done" && progress.phase !== "error"; const determinate = progress.phase !== "start"; @@ -49,7 +48,6 @@ export function AssetExportModal(props: { const [filename, setFilename] = useState(progress.filename ?? ""); const [folder, setFolder] = useState(progress.folder ?? ""); const [browseOpen, setBrowseOpen] = useState(false); - useDialogFocus(true, onClose, dialogRef); useEffect(() => { if (progress.phase !== "done") return; @@ -65,15 +63,13 @@ export function AssetExportModal(props: { } const dialog = ( -
-
event.stopPropagation()} + <> +

{formatAssetExportTitle(progress.format)}

@@ -151,7 +147,7 @@ export function AssetExportModal(props: { ) : null}

-
+ setBrowseOpen(false)} onPick={setFolder} /> - + ); return createPortal(dialog, document.body); diff --git a/apps/desktop/src/components/assets/AssetStylePicker.tsx b/apps/desktop/src/components/assets/AssetStylePicker.tsx index 6be1c4b..87729b4 100644 --- a/apps/desktop/src/components/assets/AssetStylePicker.tsx +++ b/apps/desktop/src/components/assets/AssetStylePicker.tsx @@ -1,5 +1,6 @@ import { resolveAssetStyle, type AssetStyle } from "@locus/shared"; import { useStore } from "@/state/store"; +import { Select } from "@/components/layout/Select"; export function AssetStylePicker(props: { selectedId?: string; @@ -16,22 +17,15 @@ export function AssetStylePicker(props: {
{!props.compact ?
Asset style
: null}
- + options={[ + { value: "", label: "No style" }, + ...stored.map((row) => ({ value: row.id, label: row.name })), + ]} + onChange={(id) => props.onChange(id ? resolveAssetStyle(id, stored) : undefined)} + /> {props.manage !== false ? ( + +
+ + +
+ + +
+
+
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="Clay hero" + /> +
+
+ + setDraft({ ...draft, summary: e.target.value })} + placeholder="Chunky terracotta, cream grout" + /> +
-
+
+ +