Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ body:
id: version
attributes:
label: Version
description: Settings → About, or the GitHub Release tag (for example 0.18.0).
placeholder: 0.18.0
description: Settings → About, or the GitHub Release tag (for example 0.19.0).
placeholder: 0.19.0
validations:
required: true
- type: dropdown
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
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 -t locus/agent:0.18.0 images/agent
- run: docker build -t locus/agent:0.1 -t locus/agent:0.19.0 images/agent

android:
runs-on: ubuntu-latest
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,24 @@

## Unreleased

## 0.19.0 — 2026-11-11

Inbox and a resizable sidebar with pinnable rail sections, the Console theme, a UI/UX polish pass across every section, and more reliable Jobs, worker uploads, and desktop updates. Settings → About is **0.19.0**.

**Sidebar and Inbox.** The icon rail opens with Inbox: agents, boards, Brain, and failing jobs waiting on you, with Approve / Deny on waiting tools. Sections from More can be pinned to the rail, reordered, and unpinned by right-clicking a rail icon. The list panel resizes (240–480px), session lists share All / Needs you / Running / Unread filters, and Alt+1–9 jumps between Inbox and pinned sections. On narrow windows the sidebar no longer slides under the chat, and the phone Needs you tab shares the Inbox list.

**UI/UX pass.** List headers, empty states, and filter chips are consistent across the app. The chat composer is a single frame with Send inside it, the model chip and topbar always agree, and Design sessions open with the canvas visible. Automations are called Jobs throughout. Settings pages lead with a one-line summary, providers are grouped Enabled / Available, and on/off settings are switches.

**Console theme.** Settings → Appearance adds Console: amber phosphor on charcoal with a monospace font and scanlines (`locus config theme console`).

**Jobs.** Run now checks Docker first and offers to switch to Host or start Docker. Jobs whose linked repo was removed run and save again, and run errors read as one plain sentence with details on demand.

**Codebase and IDE.** Public GitHub, GitLab, Bitbucket, Gitea, and Azure repos load pull requests and issues without a token, and blocked or rate-limited reads say so in place. README badges render correctly in the IDE.

**Fixes.** Uploads to guests on a worker plane no longer answer "Not found". Deleting the open agent returns to the new-agent screen, with Undo reopening it. The desktop app picks up rebuilt UI between releases and restarts a control plane left by an older build. Kiro CLI turns no longer duplicate tool chips, recognise more test runners as verification, and use Locus's Git tools instead of reading forge tokens from the environment.

**Under the hood.** Host, Docker, and worker guests share one tool catalog and lifecycle, simplifying how VMs, containers, and remote workers are driven.

## 0.18.0 — 2026-11-09

Worker VMs with proxied displays and golden disks, Azure and GCP placement, Slack Approve, Windows guest CLIs, and Linux installers. Settings → About is **0.18.0**.
Expand Down
16 changes: 14 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,21 @@ A chat session that drives a **guest** (Docker container, VM, or host workspace)

The machine the agent tools target. Docker, VM, and host are adapters behind one **guest runtime** interface. Live guests set `liveGuest`; the in-memory mock adapter omits it.

## Guest factory

Picks the Guest runtime adapter (Docker, Host, Windows SSH+WinRM, SSH, or mock) from a Machine. Control-plane GuestHub and worker WorkerGuestHub are thin adapters on that interface.

## Runtime catalog

The shared Guest tool list Host and Docker catalogs are derived from. Host adds Board / Project / Swarm planning tools and headed Chrome; Docker adds `start_process`, `record_screen`, and headed Chromium.

## Studio continue

Turn policy after a Design / Game / Asset round: look, incomplete canvas, quality, juice, sculpt, unused assets, mutate follow-up. Model, tool, and CLI Turns share the predicates; coding / review / ops continue stays on the Turn module.

## Session

Persisted agent state: messages, queue, checkpoints, linked repo, preview.
Persisted agent state: messages, queue, checkpoints, linked repo, preview. Desktop streaming applies **Session merge** so a list-view snapshot does not wipe a fuller local transcript.

## Machine

Expand Down Expand Up @@ -76,7 +88,7 @@ A scheduled or webhook-triggered prompt run that reuses an agent or boots a gold

## Config save

The Settings write path: merge secrets, validate providers/forges/templates/MCP/skills/plugins/workers, then persist.
The Settings write path: merge secrets, lock managed policy, validate providers/forges/templates/MCP/skills/plugins/workers, then persist. `prepareSavedConfig` is the merge+normalize interface; the route persists and notifies.

## Agent canvas

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ First agent without Docker: Settings → Providers (Ollama needs no key) then Ne
After merge to `main`:

```bash
git tag v0.18.0
git push origin v0.18.0
git tag v0.19.0
git push origin v0.19.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`.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.18.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.
This is **v0.19.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for.

## Install

Expand Down Expand Up @@ -42,7 +42,7 @@ The runtime is chosen per agent and stays fixed. New agents default to Docker wh

## Tour

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).
The icon rail starts with **Inbox** (everything waiting on you), then **Agents, Design, IDE, Codebase, Jobs** (Automations), then **More** for Games, Assets, Board, Projects, Brain, Slack, Grafana, and Docker. Pin a More surface onto the rail from that menu, then drag it or use Move up / down. Hover a collapsed rail icon to peek that list. Alt+1–9 jumps Inbox then pinned sections. Drag the sidebar edge to resize. Session lists share All / Needs you / Running / Unread chips and can group by day, repo, or status; Docker uses All / Running chips. Inbox rows are tagged and can Approve / Deny waiting tools. 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

Expand Down Expand Up @@ -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.18.0**.
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.19.0**.

<table>
<tr>
Expand Down Expand Up @@ -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.18.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.
**App.** Ninety-eight themes (Ember default). Settings → About is **0.19.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

Expand Down
4 changes: 2 additions & 2 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ android {
applicationId = "app.locus.android"
minSdk = 26
targetSdk = 35
versionCode = 32
versionName = "0.18.0"
versionCode = 33
versionName = "0.19.0"
}
buildTypes {
release {
Expand Down
2 changes: 1 addition & 1 deletion apps/chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Locus",
"version": "0.18.0",
"version": "0.19.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",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@locus/cli",
"version": "0.18.0",
"version": "0.19.0",
"private": true,
"description": "Locus CLI — control plane client for agents, Slack, Codebase, Docker, and automations",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Board:
Brain:
locus brain status|build|search|remember|inbox|correct|setup

Automations:
Jobs:
locus automation list|get|new|patch|enable|disable|delete|run|runs|clone|test-webhook

Workflows:
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"new-automation",
"empty-hero",
"rail",
"sidebar",
"confirm",
"prompt",
"toast",
Expand All @@ -25,6 +26,7 @@

function shotTarget(page: Page, screen: (typeof SCREENS)[number]) {
if (screen === "rail") return page.locator(".side-rail");
if (screen === "sidebar") return page.locator(".sidebar");
if (screen === "empty-hero" || screen === "design-empty" || screen === "ide-empty") {
return page.locator(".empty-hero");
}
Expand Down Expand Up @@ -55,7 +57,7 @@
for (const screen of SCREENS) {
test(`${screen} ${theme}`, async ({ page }) => {
await openScreen(page, screen, theme);
await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`);

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › ide-empty paper

4) e2e/visual.spec.ts:58:7 › visual regression › ide-empty paper ───────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.empty-hero') 2099 pixels (ratio 0.07 of all image pixels) are different. Snapshot: ide-empty-paper.png Call log: - Expect "toHaveScreenshot(ide-empty-paper.png)" locator('.empty-hero') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 2099 pixels (ratio 0.07 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 2099 pixels (ratio 0.07 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance paper

3) e2e/visual.spec.ts:58:7 › visual regression › appearance paper ──────────────────────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 15848 pixels (ratio 0.02 of all image pixels) are different. Snapshot: appearance-paper.png Call log: - Expect "toHaveScreenshot(appearance-paper.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 15848 pixels (ratio 0.02 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 15848 pixels (ratio 0.02 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance paper

3) e2e/visual.spec.ts:58:7 › visual regression › appearance paper ──────────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 15848 pixels (ratio 0.02 of all image pixels) are different. Snapshot: appearance-paper.png Call log: - Expect "toHaveScreenshot(appearance-paper.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 15848 pixels (ratio 0.02 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 15848 pixels (ratio 0.02 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance paper

3) e2e/visual.spec.ts:58:7 › visual regression › appearance paper ──────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 15848 pixels (ratio 0.02 of all image pixels) are different. Snapshot: appearance-paper.png Call log: - Expect "toHaveScreenshot(appearance-paper.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 15848 pixels (ratio 0.02 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 15848 pixels (ratio 0.02 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember

2) e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember ───────────────────────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.empty-hero') 2237 pixels (ratio 0.08 of all image pixels) are different. Snapshot: ide-empty-ember.png Call log: - Expect "toHaveScreenshot(ide-empty-ember.png)" locator('.empty-hero') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 2237 pixels (ratio 0.08 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 2237 pixels (ratio 0.08 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember

2) e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember ───────────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.empty-hero') 2223 pixels (ratio 0.08 of all image pixels) are different. Snapshot: ide-empty-ember.png Call log: - Expect "toHaveScreenshot(ide-empty-ember.png)" locator('.empty-hero') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 2223 pixels (ratio 0.08 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 2223 pixels (ratio 0.08 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember

2) e2e/visual.spec.ts:58:7 › visual regression › ide-empty ember ───────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.empty-hero') 2237 pixels (ratio 0.08 of all image pixels) are different. Snapshot: ide-empty-ember.png Call log: - Expect "toHaveScreenshot(ide-empty-ember.png)" locator('.empty-hero') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 2237 pixels (ratio 0.08 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.empty-hero') - locator resolved to <header class="empty-hero">…</header> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 2237 pixels (ratio 0.08 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance ember

1) e2e/visual.spec.ts:58:7 › visual regression › appearance ember ──────────────────────────────── Retry #2 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 17227 pixels (ratio 0.03 of all image pixels) are different. Snapshot: appearance-ember.png Call log: - Expect "toHaveScreenshot(appearance-ember.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 17227 pixels (ratio 0.03 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 17227 pixels (ratio 0.03 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance ember

1) e2e/visual.spec.ts:58:7 › visual regression › appearance ember ──────────────────────────────── Retry #1 ─────────────────────────────────────────────────────────────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 17227 pixels (ratio 0.03 of all image pixels) are different. Snapshot: appearance-ember.png Call log: - Expect "toHaveScreenshot(appearance-ember.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 17227 pixels (ratio 0.03 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 17227 pixels (ratio 0.03 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48

Check failure on line 60 in apps/desktop/e2e/visual.spec.ts

View workflow job for this annotation

GitHub Actions / test

e2e/visual.spec.ts:58:7 › visual regression › appearance ember

1) e2e/visual.spec.ts:58:7 › visual regression › appearance ember ──────────────────────────────── Error: expect(locator).toHaveScreenshot(expected) failed Locator: locator('.appearance-layout') 17227 pixels (ratio 0.03 of all image pixels) are different. Snapshot: appearance-ember.png Call log: - Expect "toHaveScreenshot(appearance-ember.png)" locator('.appearance-layout') with timeout 5000ms - verifying given screenshot expectation - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - 17227 pixels (ratio 0.03 of all image pixels) are different. - waiting 100ms before taking screenshot - waiting for locator('.appearance-layout') - locator resolved to <div class="appearance-layout">…</div> - taking element screenshot - disabled all CSS animations - waiting for fonts to load... - fonts loaded - attempting scroll into view action - waiting for element to be stable - captured a stable screenshot - 17227 pixels (ratio 0.03 of all image pixels) are different. 58 | test(`${screen} ${theme}`, async ({ page }) => { 59 | await openScreen(page, screen, theme); > 60 | await expect(shotTarget(page, screen)).toHaveScreenshot(`${screen}-${theme}.png`); | ^ 61 | }); 62 | } 63 | at /home/runner/work/Locus/Locus/apps/desktop/e2e/visual.spec.ts:60:48
});
}

Expand Down Expand Up @@ -93,6 +95,7 @@
"new-agent",
"empty-hero",
"rail",
"sidebar",
"feedback",
"shortcuts",
"chat",
Expand Down Expand Up @@ -152,6 +155,27 @@
: Number.parseFloat(duration) * 1000;
expect(ms).toBeLessThan(20);
});

for (const saved of [340, 480]) {
test(`sidebar (${saved}px saved) never runs under the workspace on narrow windows`, async ({
page,
}) => {
await page.addInitScript((width) => {
localStorage.setItem("locus.sidebarPrefs", JSON.stringify({ width }));
}, saved);
for (const width of [1440, 1094, 900, 800, 700]) {
await page.setViewportSize({ width, height: 800 });
await openScreen(page, "sidebar", "ember");
const box = await page.evaluate(() => {
const app = document.querySelector(".app");
const side = app?.children[0]?.getBoundingClientRect();
const work = app?.children[1]?.getBoundingClientRect();
return { side: side?.right ?? 0, work: work?.left ?? 0 };
});
expect(box.side, `viewport ${width}px`).toBeLessThanOrEqual(box.work + 1);
}
});
}
});

test.describe("visual phone", () => {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/rail-paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion apps/desktop/electron/control-plane.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ function controlPlaneNeedsRespawn(input) {
* What to do at desktop launch when port 7420 may already be taken.
* Health without UI is the AppImage "Locus UI is not on this port" case:
* restart a desktop-owned plane so it can pick up the bundled UI; leave a
* `locus serve` process alone.
* `locus serve` process alone. A desktop-owned plane kept alive from a
* different build is restarted too, so server and UI match the new install.
*/
function controlPlaneStartAction(input) {
if (!input) return "start";
if (input.healthUp && input.owned && input.stale) return "restart";
if (input.healthUp && input.uiUp) return "adopt";
if (input.healthUp && input.owned) return "restart";
if (input.healthUp) return "leave";
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/electron/control-plane.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
healthUp?: boolean;
uiUp?: boolean;
owned?: boolean;
stale?: boolean;
}) => "start" | "adopt" | "restart" | "leave";
HEALTH_FAILS_BEFORE_KILL: number;
};
Expand Down Expand Up @@ -58,4 +59,16 @@ describe("control plane restart", () => {
expect(controlPlaneStartAction({ healthUp: true, uiUp: false, owned: false })).toBe("leave");
expect(controlPlaneStartAction({ healthUp: false, uiUp: false, owned: false })).toBe("start");
});

it("restarts a desktop-owned plane kept alive from a different build", () => {
expect(controlPlaneStartAction({ healthUp: true, uiUp: true, owned: true, stale: true })).toBe(
"restart",
);
expect(controlPlaneStartAction({ healthUp: true, uiUp: true, owned: false, stale: true })).toBe(
"adopt",
);
expect(controlPlaneStartAction({ healthUp: true, uiUp: true, owned: true, stale: false })).toBe(
"adopt",
);
});
});
8 changes: 6 additions & 2 deletions apps/desktop/electron/keep-alive.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { homedir } = require("node:os");
const { dirname, join } = require("node:path");

const OWNER_RE = /^[A-Za-z0-9-]{8,80}$/;
const BUILD_RE = /^[A-Za-z0-9.+-]{1,128}$/;

/** What to do with the control-plane child when the desktop quits. */
function serverQuitAction(input) {
Expand Down Expand Up @@ -37,7 +38,9 @@ async function rememberDesktopServer(path, record) {
const owner = record && record.owner;
if (!Number.isInteger(pid) || pid <= 1 || !OWNER_RE.test(owner || "")) return;
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
await writeFile(path, `${JSON.stringify({ pid, owner })}\n`, { encoding: "utf8", mode: 0o600 });
const build = record && record.build;
const body = BUILD_RE.test(build || "") ? { pid, owner, build } : { pid, owner };
await writeFile(path, `${JSON.stringify(body)}\n`, { encoding: "utf8", mode: 0o600 });
}

async function readDesktopServer(path) {
Expand All @@ -46,7 +49,8 @@ async function readDesktopServer(path) {
const pid = parsed && parsed.pid;
const owner = parsed && parsed.owner;
if (!Number.isInteger(pid) || pid <= 1 || !OWNER_RE.test(owner || "")) return undefined;
return { pid, owner };
const build = parsed.build;
return BUILD_RE.test(build || "") ? { pid, owner, build } : { pid, owner };
} catch {
return undefined;
}
Expand Down
17 changes: 15 additions & 2 deletions apps/desktop/electron/keep-alive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,13 @@ const {
probeControlPlane: (fetchImpl: typeof fetch, url: string, timeoutMs?: number) => Promise<boolean>;
controlPlaneKeepAlive: (fetchImpl: typeof fetch, url: string) => Promise<boolean>;
desktopServerRecordPath: (home?: string) => string;
rememberDesktopServer: (path: string, record: { pid?: number; owner?: string }) => Promise<void>;
readDesktopServer: (path: string) => Promise<{ pid: number; owner: string } | undefined>;
rememberDesktopServer: (
path: string,
record: { pid?: number; owner?: string; build?: string },
) => Promise<void>;
readDesktopServer: (
path: string,
) => Promise<{ pid: number; owner: string; build?: string } | undefined>;
forgetDesktopServer: (path: string) => Promise<void>;
pidAlive: (pid: number) => boolean;
environHasOwner: (raw: Buffer | string, owner: string) => boolean;
Expand Down Expand Up @@ -98,6 +103,14 @@ describe("control plane keep-alive", () => {
expect(await readDesktopServer(path)).toEqual({ pid: child.pid, owner });
await rememberDesktopServer(path, { pid: 1, owner });
expect(await readDesktopServer(path)).toEqual({ pid: child.pid, owner });
await rememberDesktopServer(path, { pid: child.pid, owner, build: "0.18.0+abc123" });
expect(await readDesktopServer(path)).toEqual({
pid: child.pid,
owner,
build: "0.18.0+abc123",
});
await rememberDesktopServer(path, { pid: child.pid, owner, build: "bad build/../" });
expect(await readDesktopServer(path)).toEqual({ pid: child.pid, owner });
stopAdoptedProcess(child.pid!);
const deadline = Date.now() + 2000;
while (pidAlive(child.pid!) && Date.now() < deadline) {
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/electron/launch.cjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const { createHash } = require("node:crypto");
const { cpSync, existsSync, readFileSync, renameSync, rmSync, writeFileSync } = require("node:fs");
const { homedir } = require("node:os");
const { join } = require("node:path");
Expand All @@ -16,11 +17,35 @@ function packagedUiDir(resourcesPath, home) {
return join(resourcesPath, "ui");
}

/**
* Fingerprint of a built UI: Vite hashes every asset name into index.html, so
* its digest changes whenever any bundled JS/CSS does. Empty when there is no build.
*/
function uiBuildHash(dir) {
try {
return createHash("sha256")
.update(readFileSync(join(dir, "index.html")))
.digest("hex")
.slice(0, 16);
} catch {
return "";
}
}

/** App version plus UI fingerprint, so same-version rebuilds still count as new. */
function uiBuildStamp(version, dir) {
const v = version == null ? "" : String(version);
const hash = uiBuildHash(dir);
return hash ? `${v}+${hash}` : v;
}

/** Copy the AppImage/asar UI onto a real disk path so keep-alive servers survive FUSE unmount. */
function syncPackagedUi(opts = {}) {
const resourcesPath = opts.resourcesPath;
const version = opts.version == null ? "" : String(opts.version);
const bundled = join(resourcesPath, "ui");
// Stamp by content, not just version: rebuilds between releases (same
// package.json version) must still replace the cached UI.
const version = uiBuildStamp(opts.version, bundled);
const cache = desktopUiCacheDir(opts.home);
const bundledIndex = join(bundled, "index.html");
const cacheIndex = join(cache, "index.html");
Expand Down Expand Up @@ -107,4 +132,5 @@ module.exports = {
resolveLaunch,
spawnIo,
syncPackagedUi,
uiBuildStamp,
};
Loading
Loading