diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml
index d8d64bf..6048042 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.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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ceae59..ab7822b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1e680f6..467d28b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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**.
diff --git a/CONTEXT.md b/CONTEXT.md
index 46970dc..373dfc0 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -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
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 356da67..cdc2608 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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`.
diff --git a/README.md b/README.md
index 44741a2..775eafb 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.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
@@ -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
@@ -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**.
@@ -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
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index be19755..b5e609d 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 = 32
- versionName = "0.18.0"
+ versionCode = 33
+ versionName = "0.19.0"
}
buildTypes {
release {
diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json
index c15610b..45b6b50 100644
--- a/apps/chrome-extension/manifest.json
+++ b/apps/chrome-extension/manifest.json
@@ -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",
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 8a7db4d..e0e3683 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -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",
diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts
index b9f968c..026dc04 100644
--- a/apps/cli/src/help.ts
+++ b/apps/cli/src/help.ts
@@ -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:
diff --git a/apps/desktop/e2e/visual.spec.ts b/apps/desktop/e2e/visual.spec.ts
index eceb1cc..a507a39 100644
--- a/apps/desktop/e2e/visual.spec.ts
+++ b/apps/desktop/e2e/visual.spec.ts
@@ -7,6 +7,7 @@ const SCREENS = [
"new-automation",
"empty-hero",
"rail",
+ "sidebar",
"confirm",
"prompt",
"toast",
@@ -25,6 +26,7 @@ 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 === "sidebar") return page.locator(".sidebar");
if (screen === "empty-hero" || screen === "design-empty" || screen === "ide-empty") {
return page.locator(".empty-hero");
}
@@ -93,6 +95,7 @@ test.describe("visual regression", () => {
"new-agent",
"empty-hero",
"rail",
+ "sidebar",
"feedback",
"shortcuts",
"chat",
@@ -152,6 +155,27 @@ test.describe("visual regression", () => {
: 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", () => {
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png
index 28821a0..76f7ad0 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-contrast.png 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
index 5f43e7a..3d75ba6 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-ember.png 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
index 955ed38..03a747b 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-midnight.png 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
index dd477ad..f19357f 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-paper.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/appearance-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
index 2151aa8..5f2414b 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-ember.png 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
index 86fb899..23d243d 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/ide-empty-paper.png 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-automation-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-ember.png
index 27c1b17..2afd3c6 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-ember.png 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
index be11409..2dac47f 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-paper.png 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
index 59f497a..84c7bd1 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-ember.png 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
index 05d0265..1505c8c 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/new-automation-schedule-paper.png 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/rail-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png
index ee6f6a5..ca25b94 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/rail-ember.png 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
index c5088c7..0d0e178 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/rail-paper.png 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
index 1e41efb..102c8ec 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-ember.png 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
index 545afd9..04027f6 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/settings-providers-paper.png 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
index 55e8203..e8ed540 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember-phone.png 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
index 5497e11..04a1cab 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-ember.png 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
index bd80a14..e379e61 100644
Binary files a/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png and b/apps/desktop/e2e/visual.spec.ts-snapshots/shortcuts-paper.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-ember.png b/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-ember.png
new file mode 100644
index 0000000..1380353
Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-ember.png differ
diff --git a/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-paper.png b/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-paper.png
new file mode 100644
index 0000000..e3d6be9
Binary files /dev/null and b/apps/desktop/e2e/visual.spec.ts-snapshots/sidebar-paper.png differ
diff --git a/apps/desktop/electron/control-plane.cjs b/apps/desktop/electron/control-plane.cjs
index 1340a53..d63b70a 100644
--- a/apps/desktop/electron/control-plane.cjs
+++ b/apps/desktop/electron/control-plane.cjs
@@ -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";
diff --git a/apps/desktop/electron/control-plane.test.ts b/apps/desktop/electron/control-plane.test.ts
index 084790c..d954069 100644
--- a/apps/desktop/electron/control-plane.test.ts
+++ b/apps/desktop/electron/control-plane.test.ts
@@ -21,6 +21,7 @@ const {
healthUp?: boolean;
uiUp?: boolean;
owned?: boolean;
+ stale?: boolean;
}) => "start" | "adopt" | "restart" | "leave";
HEALTH_FAILS_BEFORE_KILL: number;
};
@@ -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",
+ );
+ });
});
diff --git a/apps/desktop/electron/keep-alive.cjs b/apps/desktop/electron/keep-alive.cjs
index f0e74cc..177484c 100644
--- a/apps/desktop/electron/keep-alive.cjs
+++ b/apps/desktop/electron/keep-alive.cjs
@@ -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) {
@@ -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) {
@@ -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;
}
diff --git a/apps/desktop/electron/keep-alive.test.ts b/apps/desktop/electron/keep-alive.test.ts
index fd81825..5414d64 100644
--- a/apps/desktop/electron/keep-alive.test.ts
+++ b/apps/desktop/electron/keep-alive.test.ts
@@ -30,8 +30,13 @@ const {
probeControlPlane: (fetchImpl: typeof fetch, url: string, timeoutMs?: number) => Promise;
controlPlaneKeepAlive: (fetchImpl: typeof fetch, url: string) => Promise;
desktopServerRecordPath: (home?: string) => string;
- rememberDesktopServer: (path: string, record: { pid?: number; owner?: string }) => Promise;
- readDesktopServer: (path: string) => Promise<{ pid: number; owner: string } | undefined>;
+ rememberDesktopServer: (
+ path: string,
+ record: { pid?: number; owner?: string; build?: string },
+ ) => Promise;
+ readDesktopServer: (
+ path: string,
+ ) => Promise<{ pid: number; owner: string; build?: string } | undefined>;
forgetDesktopServer: (path: string) => Promise;
pidAlive: (pid: number) => boolean;
environHasOwner: (raw: Buffer | string, owner: string) => boolean;
@@ -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) {
diff --git a/apps/desktop/electron/launch.cjs b/apps/desktop/electron/launch.cjs
index 2056ec5..3ddb1dd 100644
--- a/apps/desktop/electron/launch.cjs
+++ b/apps/desktop/electron/launch.cjs
@@ -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");
@@ -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");
@@ -107,4 +132,5 @@ module.exports = {
resolveLaunch,
spawnIo,
syncPackagedUi,
+ uiBuildStamp,
};
diff --git a/apps/desktop/electron/launch.test.ts b/apps/desktop/electron/launch.test.ts
index 3997630..91edc7c 100644
--- a/apps/desktop/electron/launch.test.ts
+++ b/apps/desktop/electron/launch.test.ts
@@ -1,18 +1,18 @@
// @vitest-environment node
-import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
-const { desktopUiCacheDir, packagedUiDir, resolveLaunch, syncPackagedUi } = createRequire(
- import.meta.url,
-)("./launch.cjs") as {
- desktopUiCacheDir: (home?: string) => string;
- packagedUiDir: (resourcesPath: string, home?: string) => string;
- resolveLaunch: (opts: Record) => { env: Record } | null;
- syncPackagedUi: (opts: { resourcesPath: string; home?: string; version?: string }) => string;
-};
+const { desktopUiCacheDir, packagedUiDir, resolveLaunch, syncPackagedUi, uiBuildStamp } =
+ createRequire(import.meta.url)("./launch.cjs") as {
+ desktopUiCacheDir: (home?: string) => string;
+ packagedUiDir: (resourcesPath: string, home?: string) => string;
+ resolveLaunch: (opts: Record) => { env: Record } | null;
+ syncPackagedUi: (opts: { resourcesPath: string; home?: string; version?: string }) => string;
+ uiBuildStamp: (version: string | undefined, dir: string) => string;
+ };
const dirs: string[] = [];
afterEach(() => {
@@ -20,7 +20,7 @@ afterEach(() => {
});
describe("packaged UI cache", () => {
- it("copies the bundled UI onto a host path and reuses it for the same version", () => {
+ it("copies the bundled UI onto a host path and reuses it for the same build", () => {
const root = mkdtempSync(join(tmpdir(), "locus-ui-cache-"));
dirs.push(root);
const bundled = join(root, "resources", "ui");
@@ -34,22 +34,64 @@ describe("packaged UI cache", () => {
});
expect(dest).toBe(desktopUiCacheDir(home));
expect(readFileSync(join(dest, "index.html"), "utf8")).toBe("bundle");
- expect(readFileSync(join(dest, ".version"), "utf8")).toBe("0.15.0");
- writeFileSync(join(bundled, "index.html"), "newer");
+ expect(readFileSync(join(dest, ".version"), "utf8")).toMatch(/^0\.15\.0\+[0-9a-f]{16}$/);
+ writeFileSync(join(dest, "marker"), "kept");
const again = syncPackagedUi({
resourcesPath: join(root, "resources"),
home,
version: "0.15.0",
});
expect(again).toBe(dest);
- expect(readFileSync(join(dest, "index.html"), "utf8")).toBe("bundle");
+ expect(readFileSync(join(dest, "marker"), "utf8")).toBe("kept");
const upgraded = syncPackagedUi({
resourcesPath: join(root, "resources"),
home,
version: "0.15.1",
});
expect(upgraded).toBe(dest);
- expect(readFileSync(join(dest, "index.html"), "utf8")).toBe("newer");
+ expect(existsSync(join(dest, "marker"))).toBe(false);
+ });
+
+ it("refreshes the cached UI when a same-version rebuild changes the bundle", () => {
+ const root = mkdtempSync(join(tmpdir(), "locus-ui-cache-"));
+ dirs.push(root);
+ const bundled = join(root, "resources", "ui");
+ mkdirSync(bundled, { recursive: true });
+ writeFileSync(join(bundled, "index.html"), '');
+ const home = join(root, "home");
+ const opts = { resourcesPath: join(root, "resources"), home, version: "0.18.0" };
+ const dest = syncPackagedUi(opts);
+ const firstStamp = readFileSync(join(dest, ".version"), "utf8");
+ writeFileSync(join(bundled, "index.html"), '');
+ expect(syncPackagedUi(opts)).toBe(dest);
+ expect(readFileSync(join(dest, "index.html"), "utf8")).toContain("index-new.js");
+ expect(readFileSync(join(dest, ".version"), "utf8")).not.toBe(firstStamp);
+ });
+
+ it("refreshes a cache stamped by an older desktop with only the version", () => {
+ const root = mkdtempSync(join(tmpdir(), "locus-ui-cache-"));
+ dirs.push(root);
+ const bundled = join(root, "resources", "ui");
+ mkdirSync(bundled, { recursive: true });
+ writeFileSync(join(bundled, "index.html"), "new sidebar");
+ const home = join(root, "home");
+ const cache = desktopUiCacheDir(home);
+ mkdirSync(cache, { recursive: true });
+ writeFileSync(join(cache, "index.html"), "old sidebar");
+ writeFileSync(join(cache, ".version"), "0.18.0");
+ syncPackagedUi({ resourcesPath: join(root, "resources"), home, version: "0.18.0" });
+ expect(readFileSync(join(cache, "index.html"), "utf8")).toBe("new sidebar");
+ });
+
+ it("stamps builds by version and UI content", () => {
+ const root = mkdtempSync(join(tmpdir(), "locus-ui-stamp-"));
+ dirs.push(root);
+ expect(uiBuildStamp("0.18.0", join(root, "missing"))).toBe("0.18.0");
+ writeFileSync(join(root, "index.html"), "a");
+ const a = uiBuildStamp("0.18.0", root);
+ writeFileSync(join(root, "index.html"), "b");
+ expect(uiBuildStamp("0.18.0", root)).not.toBe(a);
+ expect(a.startsWith("0.18.0+")).toBe(true);
});
it("points packaged LOCUS_UI at the host cache when it has index.html", () => {
diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index a6fdd68..0e4eddc 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -31,7 +31,7 @@ const {
restartDelayMs,
} = require("./control-plane.cjs");
const { desktopCliInvocation } = require("./cli-argv.cjs");
-const { resolveLaunch, syncPackagedUi } = require("./launch.cjs");
+const { resolveLaunch, syncPackagedUi, uiBuildStamp } = require("./launch.cjs");
const { loadUrlWithRetry, waitForUrl, waitUntilUrlDown } = require("./net-wait.cjs");
const { wrapMasterKey } = require("./vault-key.cjs");
const { dropWindowsApplicationMenu, installWindowsInputGuard } = require("./windows-input.cjs");
@@ -84,6 +84,8 @@ let child;
let ownsServer = false;
let adoptedPid;
let adoptedOwner;
+/** Version + UI fingerprint of this install; unset in --dev (Vite serves the UI). */
+let desktopBuild;
let quitPhase = "idle";
let mainWindow;
let restartTimer;
@@ -105,7 +107,10 @@ async function ensureServer() {
const uiUp = up ? await probeControlPlane(fetch, uiUrl, 400) : false;
const record = await readDesktopServer(recordPath);
const owned = Boolean(record && processHasOwner(record.pid, record.owner));
- const action = controlPlaneStartAction({ healthUp: up, uiUp, owned });
+ // A kept-alive plane from an older build would keep serving its old server
+ // code (and, packaged, the old UI); restart it when the build changed.
+ const stale = Boolean(desktopBuild && record && record.build !== desktopBuild);
+ const action = controlPlaneStartAction({ healthUp: up, uiUp, owned, stale });
if (action === "adopt") {
if (owned) {
adoptedPid = record.pid;
@@ -257,7 +262,11 @@ async function releaseServer() {
const action = serverQuitAction({ ownsChild: ownsServer, keepAlive, adoptedPid });
if (action === "detach") {
if (child && child.pid) {
- await rememberDesktopServer(recordPath, { pid: child.pid, owner: desktopOwner });
+ await rememberDesktopServer(recordPath, {
+ pid: child.pid,
+ owner: desktopOwner,
+ build: desktopBuild,
+ });
}
detachChild(child);
ownsServer = false;
@@ -302,6 +311,12 @@ app
console.error("Locus UI cache copy failed:", err);
}
}
+ if (!dev) {
+ const builtUi = app.isPackaged
+ ? join(process.resourcesPath, "ui")
+ : join(__dirname, "..", "dist");
+ desktopBuild = uiBuildStamp(app.getVersion(), builtUi);
+ }
await ensureServer();
if (!dev) startHealthWatch();
await waitForUrl(uiUrl);
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index f3e397c..b2cca2d 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@locus/desktop",
- "version": "0.18.0",
+ "version": "0.19.0",
"private": true,
"author": "Locus contributors",
"license": "MIT",
diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx
index f1e900c..e1cc771 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.18.0" })),
+ checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.19.0" })),
workersStatus: vi.fn(async () => []),
tailscale: vi.fn(async () => ({
installed: false,
@@ -347,6 +347,33 @@ describe("App mobile shell", () => {
});
expect(host.querySelector(".topbar")).toBeNull();
});
+
+ it("gives the rail Inbox its own main pane instead of the Agents screen", async () => {
+ setUiShell("desktop");
+ vi.mocked(api.config).mockResolvedValue(config);
+ vi.mocked(api.sessions).mockResolvedValue([session]);
+ host = document.createElement("div");
+ document.body.appendChild(host);
+ root = createRoot(host);
+ await act(async () => {
+ root.render();
+ });
+ await vi.waitFor(() => {
+ expect(host.querySelector("[data-testid='boot-screen']")).toBeNull();
+ });
+ await act(async () => {
+ (host.querySelector("[data-testid='sidebar-inbox']") as HTMLButtonElement).click();
+ });
+ await vi.waitFor(() => {
+ expect(host.querySelector("[data-testid='inbox-pane']")).toBeTruthy();
+ });
+ expect(host.querySelector(".topbar")).toBeNull();
+ expect(host.querySelector("[data-testid='inbox-pane'] h2")?.textContent).toBe("All caught up");
+ await act(async () => {
+ (host.querySelector(".side-rail-nav button[aria-label='Agents']") as HTMLElement).click();
+ });
+ expect(host.querySelector("[data-testid='inbox-pane']")).toBeNull();
+ });
});
describe("App Slack activity", () => {
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 27b32b5..be1e3e0 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -36,6 +36,7 @@ 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 { InboxPane } from "@/components/layout/InboxPane";
import { Sidebar } from "@/components/layout/Sidebar";
import { Topbar } from "@/components/layout/Topbar";
import { WorkspaceBody } from "@/components/workspace/WorkspaceBody";
@@ -554,7 +555,7 @@ export function App() {
)
) {
const name =
- automationsRef.current.find((a) => a.id === automationId)?.name ?? "Automation";
+ automationsRef.current.find((a) => a.id === automationId)?.name ?? "Job";
notifyTurnDone(
name,
`Run ${event.run.status}`,
@@ -866,14 +867,16 @@ export function App() {
- {state.section === "settings" ? null : }
+ {state.section === "settings" || state.inboxOpen ? null : }
- {state.section === "settings" ? (
+ {state.inboxOpen ? (
+
+ ) : state.section === "settings" ? (
) : state.section === "automations" ? (
@@ -897,6 +900,7 @@ export function App() {
}
preview={}
/>
@@ -911,6 +915,7 @@ export function App() {
}
preview={}
/>
@@ -925,6 +930,7 @@ export function App() {
}
preview={}
/>
@@ -952,6 +958,7 @@ export function App() {
}
preview={
productMode === "swarm" ? (
diff --git a/apps/desktop/src/components/assets/AssetDropWell.tsx b/apps/desktop/src/components/assets/AssetDropWell.tsx
index 582a02c..3f771ea 100644
--- a/apps/desktop/src/components/assets/AssetDropWell.tsx
+++ b/apps/desktop/src/components/assets/AssetDropWell.tsx
@@ -1,5 +1,4 @@
import {
- ASSET_VIEW_FILES,
ASSET_VIEW_SLOTS,
formatAssetViewSlotLabel,
namedAssetViewFile,
@@ -78,9 +77,6 @@ export function AssetDropWell(props: {
takeFiles(incoming);
}}
>
- {props.compact ? null : (
-
Drop a photo to reconstruct — front, side, and back.
- )}
{ASSET_VIEW_SLOTS.map((slot) => {
const file = files[slot];
@@ -110,7 +106,7 @@ export function AssetDropWell(props: {
)}
{formatAssetViewSlotLabel(slot)}
- {ASSET_VIEW_FILES[slot]}
+ {slot === "back" ? optional : null}
{
+ (document.querySelector(`[data-testid='composer-add-${id}']`) as HTMLButtonElement).click();
+ });
+}
+
describe("AssetEmpty", () => {
let host: HTMLElement;
let root: Root;
@@ -75,11 +82,23 @@ describe("AssetEmpty", () => {
expect(host.querySelector("[aria-label='Front']")).toBeTruthy();
expect(host.querySelector("[aria-label='Side']")).toBeTruthy();
expect(host.querySelector("[aria-label='Back']")).toBeTruthy();
+ // One heading; wells read Front / Side / Back (optional), not file names.
+ expect(host.textContent?.match(/Drop a photo to reconstruct/g)).toHaveLength(1);
+ expect(host.textContent).not.toMatch(/source-side\.png/);
+ expect(host.querySelector("[aria-label='Back'] .asset-drop-slot-label")?.textContent).toBe(
+ "Backoptional",
+ );
expect(host.querySelector("[aria-label='New asset']")).toBeTruthy();
expect(host.textContent).not.toMatch(/Choose Host or Docker/);
expect(host.querySelector("[data-testid='launch-workspace-toggle']")).toBeNull();
expect(host.querySelector("[aria-label='Look']")).toBeNull();
expect(host.querySelector("[aria-label='Color palette']")).toBeNull();
+ // Style and Build fold into the "+" menu until picked.
+ expect(host.querySelector("[aria-label='Style']")).toBeNull();
+ expect(host.querySelector("[aria-label='Build']")).toBeNull();
+ expect(host.querySelector("[aria-label='Asset kit']")).toBeNull();
+ reveal("style");
+ reveal("build");
expect(host.querySelector("[aria-label='Style']")).toBeTruthy();
expect(host.querySelector("[aria-label='Asset style']")).toBeTruthy();
expect(host.textContent).toContain("Image to 3D");
@@ -94,6 +113,7 @@ describe("AssetEmpty", () => {
expect(host.textContent).toContain("Ultra 4x");
expect(host.textContent).toContain("A-Pose");
expect(host.textContent).toContain("Printability");
+ reveal("kit");
expect(host.querySelector("[aria-label='Asset kit']")).toBeTruthy();
expect(host.textContent).toContain("Sprite pack");
expect(host.querySelector("[aria-label='Asset kit']")).toBeTruthy();
@@ -161,6 +181,7 @@ describe("AssetEmpty", () => {
act(() => {
cutout!.click();
});
+ reveal("build");
expect(host.textContent).toContain("Smart topology");
expect(host.textContent).toContain("Texture");
expect(host.textContent).not.toContain("A-Pose");
@@ -193,6 +214,7 @@ describe("AssetEmpty", () => {
,
);
});
+ reveal("style");
expect(host.querySelector("[aria-label='Asset style']")).toBeTruthy();
expect(host.textContent).toContain("Clay hero");
act(() => {
diff --git a/apps/desktop/src/components/automations/Automations.test.tsx b/apps/desktop/src/components/automations/Automations.test.tsx
index db63c53..241b70a 100644
--- a/apps/desktop/src/components/automations/Automations.test.tsx
+++ b/apps/desktop/src/components/automations/Automations.test.tsx
@@ -429,35 +429,50 @@ describe("AutomationsPane memory editor", () => {
expect(dispatch).toHaveBeenCalledWith({ type: "compare", open: true });
});
- it("keeps Duplicate, Settings, Delete, and Run now in the pane head", async () => {
- vi.mocked(api.runAutomation).mockResolvedValue({
- id: "run-now",
- automationId: item.id,
- status: "running",
- trigger: "manual",
- startedAt: "2026-08-29T02:00:00.000Z",
- });
+ it("keeps Duplicate, Settings, Delete in the pane head and leaves Run now to the topbar", () => {
render();
const headLabels = [...host.querySelectorAll(".auto-head button")].map((el) =>
el.textContent?.trim(),
);
- expect(headLabels).toEqual(
- expect.arrayContaining(["Run now", "Duplicate", "Settings", "Delete"]),
- );
- expect(host.querySelector("[data-testid='auto-pane-run-now']")).toBeTruthy();
- expect(host.querySelector("[data-testid='auto-runs-empty-cta']")?.textContent).toMatch(
- /Run now/,
- );
- await act(async () => {
- (host.querySelector("[data-testid='auto-pane-run-now']") as HTMLButtonElement).click();
- await Promise.resolve();
- await Promise.resolve();
- });
- expect(api.runAutomation).toHaveBeenCalledWith("auto-1");
+ expect(headLabels).toEqual(expect.arrayContaining(["Duplicate", "Settings", "Delete"]));
+ expect(headLabels).not.toContain("Run now");
+ expect(host.querySelector("[data-testid='auto-pane-run-now']")).toBeNull();
+ const empty = host.querySelector("[data-testid='auto-runs-empty-cta']");
+ expect(empty?.textContent).toMatch(/No runs yet/);
+ expect(empty?.querySelector("button")).toBeNull();
expect(host.querySelector("[data-testid='auto-chips']")?.textContent).toMatch(/Enabled/);
expect(host.querySelector("h2")).toBeNull();
});
+ it("shows a Docker socket failure as one plain sentence with Show details", () => {
+ const raw =
+ "failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory";
+ render(
+ { ...item, runtime: "docker", dockerImage: "ubuntu:24.04" },
+ {
+ runs: [
+ {
+ id: "run-docker",
+ automationId: item.id,
+ status: "failed",
+ trigger: "manual",
+ error: raw,
+ startedAt: "2026-08-29T02:00:00.000Z",
+ finishedAt: "2026-08-29T02:00:01.000Z",
+ },
+ ],
+ },
+ );
+ const plain = host.querySelector("[data-testid='run-error-plain']");
+ expect(plain?.querySelector("p")?.textContent).toBe(
+ "Docker isn't running — switch to Host or start Docker",
+ );
+ const details = plain?.querySelector("details");
+ expect(details?.querySelector("summary")?.textContent).toBe("Show details");
+ expect(details?.querySelector("pre")?.textContent).toBe(raw);
+ expect(details?.open).toBe(false);
+ });
+
it("shows starter pills when there are no jobs", () => {
render(item, { automations: [], selectedAutomationId: null });
expect(host.querySelector("[data-testid='auto-starters']")?.textContent).toContain(
diff --git a/apps/desktop/src/components/automations/Automations.tsx b/apps/desktop/src/components/automations/Automations.tsx
index 7ca842f..ab911a9 100644
--- a/apps/desktop/src/components/automations/Automations.tsx
+++ b/apps/desktop/src/components/automations/Automations.tsx
@@ -42,6 +42,7 @@ import { automationDetailPrefFor, saveAutomationDetailPref } from "@/lib/automat
import {
automationFunnelHint,
automationSpendLabel,
+ plainJobError,
promptNeedsCollapse,
runMatchesHistoryFilter,
runOutcomeLabel,
@@ -59,6 +60,7 @@ import { ConfirmModal } from "@/components/layout/ConfirmModal";
import { TrustActions } from "@/components/chat/TrustActions";
import { IconCopy, IconPlay, IconStop } from "@/components/layout/Icons";
import { ModelPicker } from "@/components/settings/ModelPicker";
+import { JobRunNotice, reportJobError, setJobNotice, startJobRun } from "./jobRun";
const RUN_HISTORY_PAGE = 25;
const RUN_HISTORY_FETCH = 200;
@@ -133,7 +135,6 @@ export function AutomationsPane() {
const [nameBusy, setNameBusy] = useState(false);
const [nameError, setNameError] = useState(null);
const [paneError, setPaneError] = useState(null);
- const [runActionError, setRunActionError] = useState(null);
const [runBusy, setRunBusy] = useState(false);
const [runFilter, setRunFilter] = useState("all");
const hookHighlighted = state.highlightAutomationWebhookId === item?.id;
@@ -144,7 +145,7 @@ export function AutomationsPane() {
setConfirmDelete(false);
setPromptError(null);
setPaneError(null);
- setRunActionError(null);
+ setJobNotice(null);
setRunBusy(false);
setEditingName(false);
setNameDraft(item?.name ?? "");
@@ -224,35 +225,16 @@ export function AutomationsPane() {
async function replayRun(run: AutomationRun) {
if (!item) return;
setRunBusy(true);
- setRunActionError(null);
setPaneError(null);
try {
- const next = await api.runAutomation(item.id, { runId: run.id });
- dispatch({ type: "run", run: next });
- } catch (err) {
- setRunActionError(err instanceof Error ? err.message : "Could not start a run");
- } finally {
- setRunBusy(false);
- }
- }
-
- async function startManualRun() {
- if (!item) return;
- setRunBusy(true);
- setRunActionError(null);
- setPaneError(null);
- try {
- const run = await api.runAutomation(item.id);
- dispatch({ type: "run", run });
- } catch (err) {
- setRunActionError(err instanceof Error ? err.message : "Could not start a run");
+ await startJobRun(item, dispatch, { runId: run.id });
} finally {
setRunBusy(false);
}
}
async function stopLiveRun(run: AutomationRun) {
- setRunActionError(null);
+ setJobNotice(null);
const previous = run;
dispatch({
type: "run",
@@ -268,7 +250,7 @@ export function AutomationsPane() {
if (result.run) dispatch({ type: "run", run: result.run });
} catch (err) {
dispatch({ type: "run", run: previous });
- setRunActionError(err instanceof Error ? err.message : "Could not stop the run");
+ reportJobError(item!.id, err, "Could not stop the run");
}
}
@@ -287,22 +269,16 @@ export function AutomationsPane() {
- No runs yet. Wait for the schedule, or start one now
+ No runs yet. Wait for the schedule, or use Run now at the top
{isWebhookSchedule ? ", or POST the webhook." : "."}
{isWebhookSchedule ? (
Use the Webhook card to copy the URL or simulate a trigger.
{backendId
@@ -945,8 +963,8 @@ export function NewAutomationModal() {
diskSizeGb={diskSizeGb}
hint={
repoProfile
- ? "Using a repo hardware profile. Edit to override for this automation."
- : "Defaults from Settings → Machines. Override for this automation if you want."
+ ? "Using a repo hardware profile. Edit to override for this job."
+ : "Defaults from Settings → Machines. Override for this job if you want."
}
onChange={(next) => {
setHwDirty(true);
@@ -1022,8 +1040,8 @@ export function NewAutomationModal() {
onChange={(e) => setReuseSession(e.target.checked)}
/>
{runtime === "host"
- ? "Reuse the last chat for this automation"
- : "Reuse the last machine for this automation"}
+ ? "Reuse the last chat for this job"
+ : "Reuse the last machine for this job"}
{parseProductMode(state.config?.productMode) === "swarm" ? (
Without reuse, each run opens a new chat. Locus keeps the newest {MAX_AUTOMATION_CHATS}{" "}
- chats for this automation and deletes older ones. Pinned chats stay.
+ chats for this job and deletes older ones. Pinned chats stay.
diff --git a/apps/desktop/src/components/layout/EmptyHero.tsx b/apps/desktop/src/components/layout/EmptyHero.tsx
index ee8a431..989f970 100644
--- a/apps/desktop/src/components/layout/EmptyHero.tsx
+++ b/apps/desktop/src/components/layout/EmptyHero.tsx
@@ -48,11 +48,10 @@ export function EmptyHint(props: {
);
}
-/** Sidebar / catalog empty row with the rest mascot. */
+/** Sidebar list empty row: one line of muted text. The main pane carries the empty state. */
export function EmptyListNote({ children }: { children: ReactNode }) {
return (
-
- {mockDocker
- ? "Docker is not installed. Agents cannot run on a mock engine."
- : "QEMU is not installed. VM agents cannot run on a mock hypervisor."}{" "}
+ dispatch({ type: "setupOpen", open: true })}
>
{mockDocker ? "Install Docker" : "Install QEMU"}
- dispatch({ type: "setupOpen", open: true })}
- >
- Finish setup
-
-
+
Use Host instead
-
+
)}
{showImageCallout && (
-
- A Linux cloud image is not set.{" "}
+ dispatch({ type: "setupOpen", open: true })}
>
- Finish setup
+ Continue
-
+
)}
{showGitCallout ? (
-
- Sign in or add a GitHub token in Settings → Git to clone and review pull requests.{" "}
- openSettings(dispatch, "git")}>
+
+ openSettings(dispatch, "git")}
+ >
Add a GitHub token
-