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() {
-
-

Or start from

+

Start from a template

{AUTOMATION_STARTERS.map((row) => ( @@ -327,7 +303,6 @@ export function AutomationsPane() { const runtime = sessionRuntime(item); const latestAgent = automationLatestAgentRun(runs, item.id); const running = runs.some((r) => r.status === "running" || r.status === "queued"); - const liveRun = runs.find((r) => r.status === "running" || r.status === "queued"); const jail = runtime === "host" ? undefined : (item.network ?? env?.network); const tz = isClockSchedule(item.schedule) ? localTimezoneLabel(undefined, item.timeZone) @@ -469,32 +444,11 @@ export function AutomationsPane() { className="row" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }} > - {running && liveRun ? ( - - ) : ( - - )} {editingName ? ( <> { @@ -579,7 +533,7 @@ export function AutomationsPane() {
{nameError ? : null} {paneError ? : null} - {runActionError ? : null} + {confirmDelete ? ( void openChat(run)} - onRunNow={() => void startManualRun()} onReplay={(run) => void replayRun(run)} onStop={(run) => void stopLiveRun(run)} runBusy={runBusy} @@ -849,7 +802,7 @@ function WebhookCard(props: { item: Automation; highlighted?: boolean }) {

Forge webhook setup

    -
  1. Attach a repo on this automation (required).
  2. +
  3. Attach a repo on this job (required).
  4. Enable Funnel so the forge can reach this host.
  5. Add a forge webhook that POSTs to the URL below with the secret @@ -1302,7 +1255,7 @@ function JobOptions(props: { item: Automation; onSaved: (automation: Automation) aria-label="Blackout dates" value={blackout} disabled={busy} - placeholder="2026-12-25, 2026-01-01" + placeholder="YYYY-MM-DD, comma-separated" onChange={(e) => setBlackout(e.target.value)} onBlur={() => { const dates = blackout @@ -1355,7 +1308,6 @@ function RunHistory(props: { filter: RunHistoryFilter; onFilterChange: (filter: RunHistoryFilter) => void; onOpenChat: (run: AutomationRun) => void; - onRunNow: () => void; onReplay: (run: AutomationRun) => void; onStop: (run: AutomationRun) => void; runBusy: boolean; @@ -1418,21 +1370,12 @@ function RunHistory(props: { {props.runs.length === 0 ? (

    - 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.

    ) : null} -
    ) : filtered.length === 0 ? ( @@ -1469,7 +1412,7 @@ function RunHistory(props: { } > {run.status === "failed" || run.status === "skipped" ? ( - + ) : (

    {outcome}

    )} @@ -1551,6 +1494,21 @@ function RunHistory(props: { ); } +/** Known runtime errors read as one plain sentence; the raw text sits behind Show details. */ +function RunErrorText(props: { text: string }) { + const plain = plainJobError(props.text); + if (!plain) return ; + return ( +
    +

    {plain.summary}

    +
    + Show details +
    {plain.detail}
    +
    +
    + ); +} + function AutomationMemoryEditor(props: { item: Automation; lastFact?: string; diff --git a/apps/desktop/src/components/automations/NewAutomation.test.tsx b/apps/desktop/src/components/automations/NewAutomation.test.tsx index 470481e..d6ba0c3 100644 --- a/apps/desktop/src/components/automations/NewAutomation.test.tsx +++ b/apps/desktop/src/components/automations/NewAutomation.test.tsx @@ -25,6 +25,7 @@ vi.mock("@/lib/api", () => ({ browseHost: vi.fn(), saveConfig: vi.fn(async (config) => config), workersStatus: vi.fn(async () => []), + dockerStatus: vi.fn(async () => ({ kind: "mock", available: true })), }, })); @@ -141,11 +142,11 @@ describe("NewAutomationModal runtimes", () => { expect(host.querySelector(".docker-image-field")).toBeNull(); expect(host.querySelector(".host-folder-field")).not.toBeNull(); expect(host.textContent).not.toContain("Extra allow hosts"); - expect(host.textContent).toContain("Reuse the last chat for this automation"); + expect(host.textContent).toContain("Reuse the last chat for this job"); expect(host.textContent).toContain("Remember facts between runs"); expect(host.textContent).toContain("keeps the newest 60 chats"); expect(host.querySelector("label.check")?.textContent).toContain( - "Reuse the last chat for this automation", + "Reuse the last chat for this job", ); expect( [...host.querySelectorAll("label.check")].some((el) => @@ -170,7 +171,7 @@ describe("NewAutomationModal runtimes", () => { prompt.dispatchEvent(new Event("input", { bubbles: true })); }); const create = [...host.querySelectorAll("button")].find( - (btn) => btn.textContent === "Create automation", + (btn) => btn.textContent === "Create job", ); await act(async () => { create!.click(); @@ -226,7 +227,7 @@ describe("NewAutomationModal runtimes", () => { prompt.dispatchEvent(new Event("input", { bubbles: true })); }); const create = [...host.querySelectorAll("button")].find( - (btn) => btn.textContent === "Create automation", + (btn) => btn.textContent === "Create job", ); await act(async () => { create!.click(); @@ -299,9 +300,8 @@ describe("NewAutomationModal runtimes", () => { expect(kindValues).toEqual(expect.arrayContaining(["cron", "daily", "adaptive"])); expect(kindValues).not.toContain("webhook"); expect(host.textContent).not.toContain("Git ref (optional)"); - expect(host.querySelector("[data-testid='auto-modal-starters']")?.textContent).toContain( - "Nightly tests", - ); + expect(host.querySelector("[data-testid='auto-modal-starters']")).toBeNull(); + expect(host.querySelector("h2")?.textContent).toBe("New job"); }); it("keeps the spend cap field inside More options next to autonomy", () => { @@ -360,7 +360,7 @@ describe("NewAutomationModal runtimes", () => { prompt.dispatchEvent(new Event("input", { bubbles: true })); }); const create = [...host.querySelectorAll("button")].find( - (btn) => btn.textContent === "Create automation", + (btn) => btn.textContent === "Create job", ); await act(async () => { create!.click(); @@ -478,7 +478,7 @@ describe("NewAutomationModal runtimes", () => { prompt.dispatchEvent(new Event("input", { bubbles: true })); }); const create = [...host.querySelectorAll("button")].find( - (btn) => btn.textContent === "Create automation", + (btn) => btn.textContent === "Create job", ); await act(async () => { create!.click(); @@ -620,4 +620,69 @@ describe("NewAutomationModal runtimes", () => { /IANA zone.*This machine is UTC/, ); }); + it("warns before save when Docker is selected but the daemon is down", async () => { + vi.mocked(api.dockerStatus).mockResolvedValueOnce({ kind: "docker", available: false }); + render(); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + const warn = host.querySelector("[data-testid='auto-editor-docker-down']"); + expect(warn?.textContent).toContain("Docker isn't running — switch to Host or start Docker"); + const labels = [...warn!.querySelectorAll("button")].map((el) => el.textContent); + expect(labels).toEqual(expect.arrayContaining(["Switch to Host", "Start Docker"])); + const start = [...warn!.querySelectorAll("button")].find( + (el) => el.textContent === "Start Docker", + ); + act(() => start!.click()); + expect(host.querySelector("[data-testid='auto-docker-help']")?.textContent).toContain( + "systemctl start docker", + ); + const toHost = [...warn!.querySelectorAll("button")].find( + (el) => el.textContent === "Switch to Host", + ); + act(() => toHost!.click()); + expect(host.querySelector("[data-testid='auto-editor-docker-down']")).toBeNull(); + expect(host.textContent).toContain("Reuse the last chat for this job"); + }); + + it("warns on a disabled provider and shows the price once", () => { + const off: LocusConfig = { + ...config, + providers: [{ ...config.providers[0]!, name: "OpenCode", enabled: false }], + }; + render({ ...initialState, config: off, newAutomationOpen: true }); + expect(host.querySelector("[data-testid='auto-provider-off']")?.textContent).toContain( + "OpenCode is off", + ); + expect(host.querySelector(".model-cost")).toBeNull(); + }); + + it("offers linked repos first instead of a Paste a URL label", () => { + const repo = { + id: "r1", + name: "acme/app", + checkout: { forge: "github" as const, url: "https://github.com/acme/app.git", ref: "main" }, + createdAt: "2020-01-01T00:00:00.000Z", + updatedAt: "2020-01-01T00:00:00.000Z", + }; + render({ ...initialState, config, newAutomationOpen: true, repos: [repo] }); + const labels = selectOptionLabels(host, "Repository"); + expect(labels[0]).toBe("No repo"); + expect(labels).toEqual(expect.arrayContaining(["Another URL…"])); + expect(labels.some((label) => label.startsWith("acme/app"))).toBe(true); + expect(labels).not.toContain("Paste a URL"); + expect(host.querySelector('[aria-label="Repository URL"]')).toBeNull(); + act(() => setSelectValue(host, "Repository", "r1")); + expect(selectValue(host, "Repository")).toBe("r1"); + act(() => setSelectValue(host, "Repository", "__url")); + expect(host.querySelector('[aria-label="Repository URL"]')).toBeTruthy(); + }); + + it("starts blackout dates empty with a format hint", () => { + render(); + const blackout = host.querySelector('[aria-label="Blackout dates"]') as HTMLInputElement; + expect(blackout.value).toBe(""); + expect(blackout.placeholder).toBe("YYYY-MM-DD, comma-separated"); + }); }); diff --git a/apps/desktop/src/components/automations/NewAutomation.tsx b/apps/desktop/src/components/automations/NewAutomation.tsx index e8aebaf..197fe53 100644 --- a/apps/desktop/src/components/automations/NewAutomation.tsx +++ b/apps/desktop/src/components/automations/NewAutomation.tsx @@ -6,6 +6,7 @@ import type { GoldenImage, GuestOs, HardwareOverride, + LinkedRepo, LocusConfig, NamedEnvironment, NetworkJail, @@ -14,11 +15,11 @@ import { DEFAULT_DOCKER_IMAGE, DEFAULT_WEBHOOK_PROMPT, MAX_AUTOMATION_CHATS, - AUTOMATION_STARTERS, applyNamedEnvironment, buildAutomationSchedule, automationStarterById, computeNextRunAt, + forgeLabel, formatClockTime, formatLocalTimezone, formatNextFire, @@ -49,19 +50,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { pickProvider } from "@/lib/lastAgent"; import { suggestedHardware } from "@/lib/hardware"; import { api } from "@/lib/api"; -import { useStore } from "@/state/store"; +import { openSettings, useStore } from "@/state/store"; import { AUTOMATION_TRIGGER_FAMILIES, CLOCK_SCHEDULE_KINDS, automationAllowlistOptions, automationTriggerFamily, defaultKindForTriggerFamily, + DOCKER_DOWN_MESSAGE, isClockScheduleKind, } from "@/lib/automations"; +import { DockerDownActions, dockerDownFor, hostJobsLocked } from "./jobRun"; import { HardwareFields } from "@/components/settings/HardwareFields"; import { ModelPicker } from "@/components/settings/ModelPicker"; import { OsPicker, OsSoloHint } from "@/components/settings/OsPicker"; -import { RepoField } from "@/components/codebase/RepoField"; import { DockerImageField, PlacementToggle, @@ -101,7 +103,7 @@ export function NewAutomationModal() { ); const [model, setModel] = useState(editing?.model ?? pickProvider(providers)?.defaultModel ?? ""); const [repo, setRepo] = useState(editing?.repo?.url ?? ""); - const [repoId, setRepoId] = useState(editing?.repoId ?? ""); + const [repoId, setRepoId] = useState(liveRepoId(state.repos, editing?.repoId)); const [ref, setRef] = useState(editing?.repo?.ref ?? ""); const [startVm, setStartVm] = useState(editing?.startVm ?? true); const [reuseSession, setReuseSession] = useState(editing?.reuseSession ?? false); @@ -173,8 +175,9 @@ export function NewAutomationModal() { const [backendId, setBackendId] = useState(editing?.backendId ?? ""); const [network, setNetwork] = useState(editing?.network ?? "restricted"); const [confirmHost, setConfirmHost] = useState(false); - const [confirmStarterId, setConfirmStarterId] = useState(null); const [confirmDiscard, setConfirmDiscard] = useState(false); + const [dockerDown, setDockerDown] = useState(false); + const [dockerCheck, setDockerCheck] = useState(0); const [dirty, setDirty] = useState(false); const [advancedTab, setAdvancedTab] = useState<"runtime" | "agent" | "notify">("runtime"); @@ -222,7 +225,11 @@ export function NewAutomationModal() { : ""), ); setRepoId( - current?.repoId ?? (state.section === "codebase" ? (state.selectedRepoId ?? "") : ""), + current + ? liveRepoId(state.repos, current.repoId) + : state.section === "codebase" + ? (state.selectedRepoId ?? "") + : "", ); setRef( current?.repo?.ref ?? @@ -288,6 +295,32 @@ export function NewAutomationModal() { setError(null); }, [state.newAutomationOpen, state.editingAutomationId, state.automationStarterId]); + useEffect(() => { + if (!state.newAutomationOpen || runtime !== "docker" || backendId) { + setDockerDown(false); + return; + } + let cancelled = false; + void dockerDownFor({ runtime, backendId: undefined }).then((down) => { + if (!cancelled) setDockerDown(down); + }); + return () => { + cancelled = true; + }; + }, [state.newAutomationOpen, runtime, backendId, dockerCheck]); + + useEffect(() => { + if (!state.newAutomationOpen || repoId || repo.trim()) return; + if ((kind === "forge" || kind === "pull_request") && state.repos.length === 1) { + const only = state.repos[0]!; + setRepoId(only.id); + setRepo(only.checkout.url); + if (only.checkout.ref) setRef(only.checkout.ref); + } + // Only when the trigger switches to a repo event; picking "No repo" afterwards sticks. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.newAutomationOpen, kind]); + useEffect(() => { if (!state.newAutomationOpen) return; void api @@ -327,10 +360,6 @@ export function NewAutomationModal() { setConfirmHost(false); return; } - if (confirmStarterId) { - setConfirmStarterId(null); - return; - } if (confirmDiscard) { setConfirmDiscard(false); return; @@ -466,6 +495,7 @@ export function NewAutomationModal() { ...input, hostDir: hostChat ? hostDir.trim() || null : null, repo: repoValue ?? null, + repoId: repoId || null, hardware: hardware ?? null, namedEnvironmentId: namedEnvId || null, placement: backendId ? "worker" : null, @@ -497,12 +527,25 @@ export function NewAutomationModal() { } dispatch({ type: "newAutomation", open: false }); } catch (err) { - setError(err instanceof Error ? err.message : "Could not save automation"); + setError(err instanceof Error ? err.message : "Could not save job"); } finally { setBusy(false); } } + function chooseRuntime(next: AgentRuntime) { + setDirty(true); + setRuntime(next); + const nextOs = next !== "vm" && (os === "windows-11" || os === "windows-10") ? "ubuntu" : os; + if (nextOs !== os) setOs(nextOs); + setStartVm(next !== "host" || Boolean(backendId)); + setImageId( + next === "host" + ? "" + : (latestGoldenImage(images, nextOs, next === "docker" ? "docker" : "vm")?.id ?? ""), + ); + } + function applyEnv(env?: NamedEnvironment) { setNamedEnvId(env?.id ?? ""); if (!env) return; @@ -530,42 +573,12 @@ export function NewAutomationModal() { onInput={() => setDirty(true)} onChange={() => setDirty(true)} > -

    {editing ? "Edit automation" : "New automation"}

    +

    {editing ? "Edit job" : "New job"}

    {!editing ? (

    When an event fires, run a prompt on a guest and keep the history.

    ) : null} - {!editing ? ( -
    -

    - Start from a template, or duplicate an existing job from the detail pane. -

    -
    - {AUTOMATION_STARTERS.map((row) => ( - - ))} -
    -
    - ) : null}

    When

    - { setProviderId(next.providerId); setModel(next.model); }} /> + {provider && !provider.enabled ? ( +
    +

    + {provider.name} is off. Runs fail until you turn it on. +

    +
    + +
    +
    + ) : null}
    More options

    @@ -882,19 +912,7 @@ export function NewAutomationModal() { ? ["host"] : [] } - onChange={(next) => { - setRuntime(next); - const nextOs = - next !== "vm" && (os === "windows-11" || os === "windows-10") ? "ubuntu" : os; - if (nextOs !== os) setOs(nextOs); - setStartVm(next !== "host" || Boolean(backendId)); - setImageId( - next === "host" - ? "" - : (latestGoldenImage(images, nextOs, next === "docker" ? "docker" : "vm")?.id ?? - ""), - ); - }} + onChange={chooseRuntime} />

    {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" ? (

    @@ -1051,7 +1069,7 @@ export function NewAutomationModal() { ) : null}

    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.

    @@ -1142,7 +1160,7 @@ export function NewAutomationModal() { onChange={(e) => setAllowHosts(e.target.value)} placeholder="your-domain.atlassian.net, grafana.example.com, api.incident.io" /> -

    Added to the restricted jail allowlist for this automation.

    +

    Added to the restricted jail allowlist for this job.

)}
@@ -1174,13 +1192,24 @@ export function NewAutomationModal() {
+ {dockerDown && runtime === "docker" && !backendId ? ( +
+

{DOCKER_DOWN_MESSAGE}

+

You can still save. Runs fail until Docker answers.

+ chooseRuntime("host")} + onCheckAgain={() => setDockerCheck((n) => n + 1)} + /> +
+ ) : null} {error &&

{error}

}
{confirmHost ? ( @@ -1194,20 +1223,6 @@ export function NewAutomationModal() { onConfirm={() => void submit({ hostConfirmed: true })} /> ) : null} - {confirmStarterId ? ( - setConfirmStarterId(null)} - onConfirm={() => { - const id = confirmStarterId; - setConfirmStarterId(null); - dispatch({ type: "newAutomation", open: true, starterId: id }); - }} - /> - ) : null} {confirmDiscard ? ( Empty means this job gets no MCP, Jira, Confluence, Linear, Slack, Grafana, Sentry, Figma, incident.io, AWS IAM, Portainer, Gmail, Calendar, Drive, Kubernetes, or plugin tools. Listed - names skip Approve on this automation only. + names skip Approve on this job only.

{groups.map((group) => (
@@ -1332,3 +1347,82 @@ function McpAllowlistField(props: {
); } + +const OTHER_URL = "__url"; + +/** Linked repos first; a pasted URL is the fallback, not the default label. */ +function JobRepoField(props: { + repos: LinkedRepo[]; + repoId: string; + url: string; + onRepoId: (id: string) => void; + onUrl: (url: string) => void; + onRef: (ref: string) => void; +}) { + const [urlMode, setUrlMode] = useState(!props.repoId && Boolean(props.url.trim())); + + useEffect(() => { + if (props.repoId) setUrlMode(false); + else if (props.url.trim()) setUrlMode(true); + }, [props.repoId, props.url]); + + const urlInput = ( + { + props.onUrl(e.target.value); + props.onRepoId(""); + }} + placeholder="https://github.com/acme/app, https://gitlab.com/acme/app, or https://bitbucket.org/org/repo" + /> + ); + + if (!props.repos.length) { + return ( +
+ + {urlInput} +
+ ); + } + + return ( +
+ + - - - + input.current?.click()}> + Attach files… + + + {extras.map((extra) => ( + + {extra.label} + + ))} + + ) : ( + + + + )} ); } diff --git a/apps/desktop/src/components/chat/ExpandableLog.test.tsx b/apps/desktop/src/components/chat/ExpandableLog.test.tsx index 8b8e5be..867cc48 100644 --- a/apps/desktop/src/components/chat/ExpandableLog.test.tsx +++ b/apps/desktop/src/components/chat/ExpandableLog.test.tsx @@ -60,4 +60,13 @@ describe("ExpandableLog", () => { expect(css).toMatch(/\.log-blob pre \{[\s\S]*?padding: var\(--space-3-5\) var\(--space-4\)/); expect(css).toMatch(/\.term-log \.log-blob > summary \{[\s\S]*?color:\s*var\(--term-fg\)/); }); + it("shows a Docker-down error as a plain sentence with the raw text in details", () => { + const raw = + "ERROR: 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(); + expect(host.querySelector("summary")?.textContent).toBe( + "Docker isn't running — switch to Host or start Docker", + ); + expect(host.querySelector("pre")?.textContent).toBe(raw); + }); }); diff --git a/apps/desktop/src/components/chat/ExpandableLog.tsx b/apps/desktop/src/components/chat/ExpandableLog.tsx index 0294d93..11e725f 100644 --- a/apps/desktop/src/components/chat/ExpandableLog.tsx +++ b/apps/desktop/src/components/chat/ExpandableLog.tsx @@ -1,7 +1,13 @@ import { summarizeLogBlob } from "@locus/shared"; +import { plainJobError } from "@/lib/automations"; export function ExpandableLog(props: { text: string; compact?: boolean; danger?: boolean }) { - const { summary, detail } = summarizeLogBlob(props.text); + // Known infrastructure errors (Docker down) read as one plain sentence; the + // raw text stays one click away. + const plain = plainJobError(props.text); + const { summary, detail } = plain + ? { summary: plain.summary, detail: plain.detail } + : summarizeLogBlob(props.text); if (!detail) { if (props.compact) { return ( diff --git a/apps/desktop/src/components/chat/NewSession.tsx b/apps/desktop/src/components/chat/NewSession.tsx index 6cf7314..8da3afd 100644 --- a/apps/desktop/src/components/chat/NewSession.tsx +++ b/apps/desktop/src/components/chat/NewSession.tsx @@ -386,7 +386,7 @@ export function NewSessionModal() { onChange={(e) => setPrompt(e.target.value)} placeholder={ runtime === "host" - ? "What should we talk about? Sent as the first message." + ? "Describe the task. Sent as the first message." : runtime === "docker" ? "What should this container do? Sent as the first message after the container starts." : "What should this machine do? Sent as the first message after the VM starts." diff --git a/apps/desktop/src/components/codebase/Codebase.test.tsx b/apps/desktop/src/components/codebase/Codebase.test.tsx index 480bde2..2c00528 100644 --- a/apps/desktop/src/components/codebase/Codebase.test.tsx +++ b/apps/desktop/src/components/codebase/Codebase.test.tsx @@ -285,6 +285,37 @@ if (typeof globalThis.PointerEvent !== "function") { globalThis.PointerEvent = PointerEventPolyfill as typeof PointerEvent; } +/** Config with one GitHub token, so Import from Git is the primary empty-state path. */ +const tokenedConfig = { + theme: "ember", + providers: [], + templates: [], + hardwareProfiles: [], + forges: [ + { + id: "gh1", + kind: "github" as const, + name: "GitHub", + apiBaseUrl: "https://api.github.com", + token: "ghp_test", + }, + ], + advanced: { + dataDir: "/tmp", + qemuBinary: "qemu-system-x86_64", + swtpmBinary: "swtpm", + ovmfCodePath: "", + ovmfVarsPath: "", + maxConcurrentVms: 4, + defaultAcceleratorAuto: true, + logLevel: "info" as const, + bindHost: "127.0.0.1", + bindPort: 7420, + openBrowserOnStart: true, + hostSharePath: "", + }, +} as NonNullable; + const repo: LinkedRepo = { id: "r1", name: "acme/app", @@ -447,7 +478,7 @@ describe("CodebasePane", () => { }); vi.mocked(api.linkRepo).mockResolvedValue(created); const actions: Action[] = []; - render({ ...initialState, section: "codebase" }, (action) => { + render({ ...initialState, section: "codebase", config: tokenedConfig }, (action) => { actions.push(action); }); const importBtn = [...host.querySelectorAll("button")].find( @@ -499,7 +530,7 @@ describe("CodebasePane", () => { vi.mocked(api.importRepos).mockImplementationOnce( () => new Promise(() => undefined) as Promise, ); - render(); + render({ ...initialState, section: "codebase", config: tokenedConfig }); await act(async () => { [...host.querySelectorAll("button")] .find((btn) => btn.textContent === "Import from Git")! @@ -564,7 +595,33 @@ describe("CodebasePane", () => { expect(host.textContent).not.toContain("Add a GitHub token"); }); - it("opens Settings → Git from the pull-request token notice", () => { + it("leads with the URL field when no forge is connected and Import once one is", () => { + render(); + const cards = [...host.querySelectorAll(".codebase-start-card h3")].map((h) => h.textContent); + expect(cards).toEqual(["Paste a URL", "Import from Git"]); + expect(host.querySelector(".codebase-start-card .btn.primary")?.textContent).toBe("Link"); + expect( + [...host.querySelectorAll("button")].some((btn) => btn.textContent === "Import from Git"), + ).toBe(false); + act(() => root.unmount()); + host.remove(); + render({ ...initialState, section: "codebase", config: tokenedConfig }); + const tokenedCards = [...host.querySelectorAll(".codebase-start-card h3")].map( + (h) => h.textContent, + ); + expect(tokenedCards).toEqual(["Import from Git", "Paste a URL"]); + expect(host.querySelectorAll(".codebase-start-card .btn.primary")).toHaveLength(1); + expect(host.querySelector(".codebase-start-card .btn.primary")?.textContent).toBe( + "Import from Git", + ); + }); + + it("says in place when pull requests need a token, with one banner at the top", async () => { + vi.mocked(api.repoPulls).mockRejectedValueOnce( + new Error( + "GitHub needs a token to read this repo. It may be private — add a token in Settings → Git.", + ), + ); const actions: Action[] = []; render( { @@ -577,18 +634,64 @@ describe("CodebasePane", () => { actions.push(action); }, ); - expect(host.textContent).toContain("add a GitHub token in Settings → Git"); - const open = [...host.querySelectorAll("button")].find( - (btn) => btn.textContent === "Open Git settings", + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelectorAll(".error-row")).toHaveLength(1); + expect(host.querySelector(".notice-row")).toBeNull(); + expect(host.querySelector("[data-testid='pr-inbox-blocked']")?.textContent).toContain( + "Can't load pull requests — add a token.", + ); + expect(host.textContent).not.toContain("No open pull requests"); + expect(host.querySelector(".repo-stat-note")?.textContent).toBe("Needs a token"); + const add = [...host.querySelectorAll("button")].find( + (btn) => btn.textContent === "Add a token", ); - expect(open).toBeTruthy(); act(() => { - open!.click(); + add!.click(); }); expect(actions).toContainEqual({ type: "settings", open: true }); expect(actions).toContainEqual({ type: "settingsTab", tab: "git" }); }); + it("says when the anonymous rate limit blocks pull requests", async () => { + vi.mocked(api.repoPulls).mockRejectedValueOnce( + new Error( + "GitHub rate limit reached for reads without a token. Add a token in Settings → Git, or try again later.", + ), + ); + render({ ...initialState, section: "codebase", repos: [repo], selectedRepoId: "r1" }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector("[data-testid='pr-inbox-blocked']")?.textContent).toMatch( + /limit for reads without a token is used up/, + ); + expect(host.querySelector(".repo-stat-note")?.textContent).toBe("Rate limited"); + }); + + it("links the forge page from the header and shows a fetched default branch", () => { + render({ ...initialState, section: "codebase", repos: [repo], selectedRepoId: "r1" }); + const link = host.querySelector(".codebase-head a.forge-link") as HTMLAnchorElement; + expect(link.getAttribute("href")).toBe("https://github.com/acme/app"); + expect(link.getAttribute("target")).toBe("_blank"); + expect(host.querySelector(".repo-url")?.getAttribute("title")).toBe(repo.checkout.url); + expect(host.querySelector(".meta-grid")?.textContent).toContain("Default branchmain"); + }); + + it("says the default branch is not fetched yet instead of unset", () => { + render({ + ...initialState, + section: "codebase", + repos: [{ ...repo, checkout: { ...repo.checkout, ref: undefined } }], + selectedRepoId: "r1", + }); + expect(host.querySelector(".meta-grid")?.textContent).toContain("Not fetched yet"); + expect(host.textContent).not.toContain("unset"); + }); + it("shows a forge JSON dump as an error banner with Git settings", async () => { vi.mocked(api.repoPulls).mockRejectedValueOnce( new Error( diff --git a/apps/desktop/src/components/codebase/Codebase.tsx b/apps/desktop/src/components/codebase/Codebase.tsx index dc6aceb..6a24253 100644 --- a/apps/desktop/src/components/codebase/Codebase.tsx +++ b/apps/desktop/src/components/codebase/Codebase.tsx @@ -22,7 +22,9 @@ import { shortLoadPath, shortRepoName, formatRemoteError, + forgeAccessProblem, isForgeAuthError, + toHttpsRemoteUrl, LOAD_PATH_TRACE_LIMIT, visibleReviewAssistantText, parseDiffLayout, @@ -67,7 +69,7 @@ import { PatchCard, patchFilesFromPull, type DiffLineRef } from "@/components/ch import { ArchitecturePane } from "./ArchitecturePane"; import { ChangeStackPanel } from "./ChangeStackPanel"; import { PullSummaryCell, PullTable } from "./PullTable"; -import { IconCompress, IconExpand, IconMore } from "@/components/layout/Icons"; +import { IconArrowUpRight, IconCompress, IconExpand, IconMore } from "@/components/layout/Icons"; import { EmptyHero, EmptyHint } from "@/components/layout/EmptyHero"; import { Splitter } from "@/components/layout/Splitter"; import { stepSplitValue } from "@/lib/splitter"; @@ -221,6 +223,28 @@ function IdeOpenBanner({ open, progress }: { open: IdeOpen; progress?: CloneProg ); } +type FetchProblem = "needs_token" | "rate_limited" | "error"; + +/** Classify a pull/issue list failure so the pane can say why in place. */ +function fetchProblem(error: string | null): FetchProblem | null { + if (!error) return null; + const message = formatRemoteError(error); + return forgeAccessProblem(message) ?? (isForgeAuthError(message) ? "needs_token" : "error"); +} + +function blockedText(what: "pull requests" | "issues", problem: FetchProblem): string { + if (problem === "needs_token") return `Can't load ${what} — add a token.`; + if (problem === "rate_limited") + return `Can't load ${what} — the limit for reads without a token is used up. Add a token or try again later.`; + return `Can't load ${what}.`; +} + +/** Browser URL for a clone remote (`git@…`/`….git` → https page), or undefined. */ +export function repoWebUrl(url: string): string | undefined { + const https = toHttpsRemoteUrl(url).replace(/\.git$/i, ""); + return /^https:\/\//i.test(https) ? https : undefined; +} + function ErrorBanner({ error }: { error: string }) { const { dispatch } = useStore(); const message = formatRemoteError(error); @@ -258,11 +282,14 @@ export function CodebasePane() { const [accounts, setAccounts] = useState(null); const [pulls, setPulls] = useState([]); const [pullsLoading, setPullsLoading] = useState(false); + const [pullsError, setPullsError] = useState(null); + const [pullsReady, setPullsReady] = useState(false); const [pullState, setPullState] = useState("open"); const [issues, setIssues] = useState([]); const [issuesLoading, setIssuesLoading] = useState(false); const [issueState, setIssueState] = useState("open"); const [issueLoadError, setIssueLoadError] = useState(null); + const [issuesError, setIssuesError] = useState(null); const [issueCommentBody, setIssueCommentBody] = useState(""); const [listWidth, setListWidth] = useState(loadPrListWidth); const [issueListWidth, setIssueListWidth] = useState(loadIssueListWidth); @@ -406,6 +433,8 @@ export function CodebasePane() { }, [issueFocus]); useEffect(() => { + setPullsError(null); + setPullsReady(false); if (!repo) { setPulls([]); setPullsLoading(false); @@ -414,20 +443,24 @@ export function CodebasePane() { const cached = peekPullList(repo.id, pullState); if (cached) { setPulls(cached.rows); + setPullsReady(true); setPullsLoading(false); if (cached.fresh) return; } else { + setPulls([]); setPullsLoading(true); } let cancelled = false; void api .repoPulls(repo.id, pullState) .then((rows) => { - if (!cancelled) setPulls(rows); + if (cancelled) return; + setPulls(rows); + setPullsReady(true); }) .catch((err) => { if (!cancelled) - setError(err instanceof Error ? err.message : "Could not load pull requests"); + setPullsError(err instanceof Error ? err.message : "Could not load pull requests"); }) .finally(() => { if (!cancelled) setPullsLoading(false); @@ -441,9 +474,11 @@ export function CodebasePane() { if (!repo) { setIssues([]); setIssuesLoading(false); + setIssuesError(null); return; } if (tab !== "issues") return; + setIssuesError(null); const cached = peekIssueList(repo.id, issueState); if (cached) { setIssues(cached.rows); @@ -459,7 +494,8 @@ export function CodebasePane() { if (!cancelled) setIssues(rows); }) .catch((err) => { - if (!cancelled) setError(err instanceof Error ? err.message : "Could not load issues"); + if (!cancelled) + setIssuesError(err instanceof Error ? err.message : "Could not load issues"); }) .finally(() => { if (!cancelled) setIssuesLoading(false); @@ -1109,6 +1145,74 @@ export function CodebasePane() { } if (!repo) { + const importCard = ( +
+

Import from Git

+ {tokened.length ? ( + <> +

Pick from the accounts connected in Settings → Git.

+ void importFromGit()} + > + Import from Git + + + + ) : ( + <> +

+ Connect GitHub, GitLab or Bitbucket to pick from your repos, including private ones. +

+ + + )} +
+ ); + const urlCard = ( +
+

Paste a URL

+

Any repo URL. Public repos load without a token.

+
+ + setLinkUrl(e.target.value)} + placeholder="https://github.com/acme/app" + /> +
+ {tokened.length > 1 ? ( +
+ + setLinkUrl(e.target.value)} - placeholder="https://github.com/acme/app" - /> -
- {tokened.length > 1 ? ( -
- - ({ - value: kit.id, - label: kit.title, - })), - ]} - onChange={(id) => { - setSystemTouched(true); - setDesignSystemId(id); - }} - /> - {surface === "game" ? ( + {showKit ? ( + setRunningOnly(e.target.checked)} - /> - Running only - + {state.dockerView === "containers" && containers.length ? ( + setRunningOnly(value === "running")} + /> ) : null} ) : null} {state.section === "automations" && state.automations.length ? ( <>
-
- {( - [ - ["all", "All"], - ["failing", "Failing"], - ["needs-you", "Needs you"], - ["running", "Running"], - ["paused", "Paused"], - ["over-cap", "Over cap"], - ] as const - ).map(([value, label]) => ( - - ))} -
-
- {( - [ - ["priority", "Priority"], - ["schedule", "By trigger"], - ] as const - ).map(([value, label]) => ( - - ))} -
+ setValue(e.target.value)} onBlur={() => void commit()} diff --git a/apps/desktop/src/components/layout/sidebar/FilterChips.tsx b/apps/desktop/src/components/layout/sidebar/FilterChips.tsx new file mode 100644 index 0000000..d54741f --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/FilterChips.tsx @@ -0,0 +1,30 @@ +type FilterOption = { value: T; label: string; count?: number }; + +/** "Needs you 2" — the count rides on the label so the chip still reads as one word group. */ +export function chipLabel(option: { label: string; count?: number }): string { + return option.count ? `${option.label} ${option.count}` : option.label; +} + +export function FilterChips(props: { + label: string; + value: T; + options: readonly FilterOption[]; + onChange: (value: T) => void; + className?: string; +}) { + return ( +
+ {props.options.map((option) => ( + + ))} +
+ ); +} diff --git a/apps/desktop/src/components/layout/sidebar/InboxList.tsx b/apps/desktop/src/components/layout/sidebar/InboxList.tsx new file mode 100644 index 0000000..9260ee1 --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/InboxList.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import type { AgentSession, ProductMode } from "@locus/shared"; +import { api } from "@/lib/api"; +import { inboxKindLabel, type InboxRow } from "@/lib/inboxList"; +import { isBatchableApproval } from "@/lib/needsYou"; +import { showErrorNotice } from "@/lib/notice"; +import { EmptyListNote } from "../EmptyHero"; +import { useStore } from "@/state/store"; + +function pendingCall(session: AgentSession | undefined, callId?: string) { + if (!session || !callId) return undefined; + for (const message of session.messages) { + for (const call of message.toolCalls ?? []) { + if (call.id === callId && call.approval === "needed" && !call.finishedAt) return call; + } + } + return undefined; +} + +export function InboxList(props: { + rows: InboxRow[]; + sessions: AgentSession[]; + query: string; + mode: ProductMode; + onOpen: (row: InboxRow) => void; +}) { + const { dispatch } = useStore(); + const [busyKey, setBusyKey] = useState(null); + + async function decide(row: InboxRow, decision: "approved" | "denied") { + if (!row.sessionId || !row.callId || busyKey) return; + setBusyKey(row.key); + try { + const session = await api.resolveApproval(row.sessionId, row.callId, decision); + dispatch({ type: "session", session }); + } catch (err) { + showErrorNotice(err, decision === "approved" ? "Could not approve" : "Could not deny"); + } finally { + setBusyKey(null); + } + } + + if (!props.rows.length) { + // The subtitle already says "Caught up" and the main pane shows it; only a miss needs words. + return props.query ? No matching inbox items : null; + } + + return ( +
+ {props.rows.map((row) => { + const session = props.sessions.find((item) => item.id === row.sessionId); + const call = pendingCall(session, row.callId); + const canDecide = Boolean(call && isBatchableApproval(call.name)); + const kind = inboxKindLabel(row.kind, props.mode); + return ( +
+ + {canDecide ? ( + + + + + ) : null} +
+ ); + })} +
+ ); +} diff --git a/apps/desktop/src/components/layout/sidebar/SideRail.tsx b/apps/desktop/src/components/layout/sidebar/SideRail.tsx new file mode 100644 index 0000000..85769e4 --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/SideRail.tsx @@ -0,0 +1,412 @@ +import { + useEffect, + useRef, + useState, + type DragEvent, + type MouseEvent as ReactMouseEvent, +} from "react"; +import { createPortal } from "react-dom"; +import type { AgentSession } from "@locus/shared"; +import { IconGear, IconInbox, IconMore, IconPanel, IconPin, IconWrench } from "../Icons"; +import { MenuItem, OverflowMenu } from "../OverflowMenu"; +import { sessionFitsSection } from "@/state/store"; +import type { UiSection } from "@/state/store"; +import { isLockedRailSection } from "@/lib/railNav"; +import type { SectionMeta } from "./sectionMeta"; + +export type RailPeekRow = { key: string; title: string; detail?: string }; + +export type RailSectionItem = { id: UiSection } & SectionMeta; + +export function SideRail(props: { + collapsed: boolean; + swarmMode: boolean; + connected: boolean; + connTitle: string; + connDetail: string; + inboxOpen: boolean; + inboxCount: number; + activeSection: UiSection; + primary: RailSectionItem[]; + secondary: RailSectionItem[]; + waiting: AgentSession[]; + unreadAutomation: boolean; + slackUnread: boolean; + boardWaiting: number; + shortcutHint: (target: "inbox" | UiSection) => string | undefined; + setupOpen?: boolean; + onInbox: () => void; + onSection: (section: UiSection) => void; + onPin: (section: UiSection) => void; + onReorder: (section: UiSection, target: UiSection) => void; + onMove?: (section: UiSection, delta: -1 | 1) => void; + onSettings: () => void; + onToggleCollapse: () => void; + onSetup?: () => void; + peekRows: (target: "inbox" | UiSection) => RailPeekRow[]; + onPeekOpen: (target: "inbox" | UiSection, row: RailPeekRow) => void; +}) { + const [peek, setPeek] = useState(null); + const hideTimer = useRef(null); + const dragging = useRef(null); + const [pinMenu, setPinMenu] = useState(null); + + function openPinMenu(section: UiSection, event: ReactMouseEvent) { + if (isLockedRailSection(section, props.swarmMode ? "swarm" : "developer")) return; + event.preventDefault(); + event.stopPropagation(); + // Keyboard context-menu keys report 0,0; anchor to the button instead. + let { clientX: x, clientY: y } = event; + if (!x && !y) { + const box = event.currentTarget.getBoundingClientRect(); + x = box.right; + y = box.top; + } + setPeek(null); + setPinMenu({ section, x, y }); + } + + useEffect(() => { + if (!pinMenu) return; + function onKey(event: KeyboardEvent) { + if (event.key === "Escape") setPinMenu(null); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [pinMenu]); + + function clearHide() { + if (hideTimer.current != null) { + window.clearTimeout(hideTimer.current); + hideTimer.current = null; + } + } + + function showPeek(target: "inbox" | UiSection, el: HTMLElement) { + if (!props.collapsed || props.swarmMode) return; + clearHide(); + const rail = el.closest(".side-rail"); + const top = el.getBoundingClientRect().top - (rail?.getBoundingClientRect().top ?? 0); + setPeek({ target, top }); + } + + function scheduleHide() { + clearHide(); + hideTimer.current = window.setTimeout(() => setPeek(null), 180); + } + + useEffect(() => () => clearHide(), []); + + function dragStart(section: UiSection, event: DragEvent) { + if (isLockedRailSection(section, props.swarmMode ? "swarm" : "developer")) { + event.preventDefault(); + return; + } + dragging.current = section; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", section); + } + + function dropOn(target: UiSection, event: DragEvent) { + event.preventDefault(); + const from = dragging.current ?? (event.dataTransfer.getData("text/plain") as UiSection); + dragging.current = null; + if (from) props.onReorder(from, target); + } + + const mode = props.swarmMode ? "swarm" : "developer"; + + function canMove(section: UiSection, delta: -1 | 1): boolean { + const ids = props.primary.map((row) => row.id); + const next = ids.indexOf(section) + delta; + return next >= 0 && next < ids.length && !isLockedRailSection(ids[next], mode); + } + const peekItems = peek ? props.peekRows(peek.target) : []; + const peekLabel = + peek?.target === "inbox" + ? "Inbox" + : (props.primary.find((row) => row.id === peek?.target)?.label ?? "Section"); + + return ( +
+ + , + document.body, + ) + : null} + {peek ? ( +
+

{peekLabel}

+ {peekItems.length ? ( + peekItems.slice(0, 8).map((row) => ( + + )) + ) : ( +

Nothing here yet

+ )} +
+ ) : null} +
+ ); +} + +export function shortcutCaption(index: number): string | undefined { + if (index < 0 || index > 8) return undefined; + return `Alt+${index + 1}`; +} diff --git a/apps/desktop/src/components/layout/sidebar/automationList.tsx b/apps/desktop/src/components/layout/sidebar/automationList.tsx new file mode 100644 index 0000000..b4eda60 --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/automationList.tsx @@ -0,0 +1,124 @@ +import { + automationLastFact, + automationLatestRun, + clipFact, + formatNextFire, + formatSchedule, + type Automation, + type AutomationRun, +} from "@locus/shared"; +import { api } from "@/lib/api"; +import { formatWhen } from "@/components/automations/Automations"; +import { MenuItem, MenuSep, OverflowMenu } from "../OverflowMenu"; + +export function automationCardMeta( + item: Automation, + runs: AutomationRun[], +): { last: string | undefined; next: string } { + const latest = automationLatestRun(runs, item.id); + const fact = automationLastFact(item); + const lastAt = latest?.startedAt ?? item.lastRunAt; + const last = [lastAt ? `Last ${formatWhen(lastAt)}` : null, fact ? clipFact(fact, 48) : null] + .filter(Boolean) + .join(" · "); + const next = formatNextFire(item); + const nextLine = !item.enabled + ? "Paused" + : next === "On webhook" + ? "On webhook" + : next + ? `Next ${formatWhen(next)}` + : formatSchedule(item.schedule); + return { last: last || undefined, next: nextLine }; +} + +export function AutomationListItem(props: { + item: Automation; + runs: AutomationRun[]; + selected: boolean; + unread: boolean; + onSelect: (id: string) => void; + onPauseToggle: (item: Automation) => void; + onDuplicate: (item: Automation) => void; + onSettings: (item: Automation) => void; + onDelete: (item: Automation) => void; +}) { + const latest = automationLatestRun(props.runs, props.item.id); + const card = automationCardMeta(props.item, props.runs); + const live = latest?.status === "running" || latest?.status === "queued"; + const dot = live + ? "running" + : latest?.status === "failed" + ? "error" + : props.item.enabled + ? "idle" + : "stopped"; + const menu = ( + + props.onPauseToggle(props.item)}> + {props.item.enabled ? "Pause" : "Enable"} + + props.onDuplicate(props.item)}>Duplicate + props.onSettings(props.item)}>Settings + + props.onDelete(props.item)}> + Delete + + + ); + return ( +
+ + {menu} +
+ ); +} + +export async function patchListedAutomations( + items: Automation[], + enabled: boolean, + dispatch: (action: { type: "automation"; automation: Automation }) => void, +): Promise<{ ok: number; failed: number; error?: string }> { + const results = await Promise.allSettled( + items.map((a) => + api.patchAutomation(a.id, { enabled }).then((automation) => { + dispatch({ type: "automation", automation }); + return automation; + }), + ), + ); + let ok = 0; + let failed = 0; + let error: string | undefined; + for (const result of results) { + if (result.status === "fulfilled") ok += 1; + else { + failed += 1; + if (!error) { + error = result.reason instanceof Error ? result.reason.message : "Could not update jobs"; + } + } + } + return { ok, failed, error }; +} diff --git a/apps/desktop/src/components/layout/sidebar/sectionMeta.tsx b/apps/desktop/src/components/layout/sidebar/sectionMeta.tsx new file mode 100644 index 0000000..1c908cd --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/sectionMeta.tsx @@ -0,0 +1,102 @@ +import type { ReactNode } from "react"; +import { parseProductMode, type ForgeKind, type LinkedRepo } from "@locus/shared"; +import { + IconAgents, + IconBrain, + IconClock, + IconContainer, + IconCube, + IconFolder, + IconFrame, + IconGamepad, + IconGrafana, + IconHash, + IconPalette, + IconRepo, + IconStack, +} from "../Icons"; +import type { UiSection } from "@/state/store"; + +export type SectionMeta = { label: string; railLabel?: string; icon: ReactNode }; + +export const SECTION_META: Record, SectionMeta> = { + agents: { label: "Agents", icon: }, + design: { label: "Design", icon: }, + games: { label: "Games", icon: }, + assets: { label: "Assets", icon: }, + ide: { label: "IDE", icon: }, + board: { label: "Board", icon: }, + project: { label: "Projects", icon: }, + codebase: { label: "Codebase", icon: }, + brain: { label: "Brain", icon: }, + slack: { label: "Slack", icon: }, + grafana: { label: "Grafana", icon: }, + docker: { label: "Docker", icon: }, + // "Jobs" fits the 72px icon column; the pane, search, and More menu stay Automations. + automations: { label: "Jobs", railLabel: "Jobs", icon: }, +}; + +export function sectionMetaFor( + mode: ReturnType, +): Record, SectionMeta> { + if (mode !== "swarm") return SECTION_META; + return { + ...SECTION_META, + agents: { label: "Teammates", icon: }, + project: { label: "Teams", icon: }, + automations: { label: "Routines", icon: }, + }; +} + +export function sectionLabel( + section: UiSection, + mode: ReturnType = "developer", +): string { + if (section === "settings") return "Settings"; + return sectionMetaFor(mode)[section]?.label ?? "Agents"; +} + +export function sectionSearchPlaceholder( + section: UiSection, + mode: ReturnType = "developer", +): string { + if (section === "docker") return "Search containers or services"; + if (section === "automations") { + return mode === "swarm" ? "Search routines" : "Search jobs"; + } + if (section === "board") return "Search boards"; + if (section === "project") { + return mode === "swarm" ? "Search teams" : "Search projects"; + } + if (section === "design") return "Search designs"; + if (section === "games") return "Search games"; + if (section === "assets") return "Search assets"; + if (section === "ide") return "Search workspaces"; + if (section === "codebase") return "Search repos"; + if (section === "brain") return "Search claims"; + if (section === "slack") return "Search channels"; + if (section === "grafana") return "Search queries and dashboards"; + return mode === "swarm" ? "Search teammates" : "Search agents"; +} + +const FORGE_ORDER: ForgeKind[] = [ + "github", + "gitlab", + "bitbucket", + "gitea", + "forgejo", + "azuredevops", +]; + +export function groupRepos(repos: LinkedRepo[]): Array<{ forge: ForgeKind; repos: LinkedRepo[] }> { + const buckets = new Map(); + for (const row of repos) { + const list = buckets.get(row.checkout.forge) ?? []; + list.push(row); + buckets.set(row.checkout.forge, list); + } + return FORGE_ORDER.filter((forge) => buckets.has(forge)).map((forge) => ({ + forge, + repos: buckets.get(forge)!, + })); +} diff --git a/apps/desktop/src/components/layout/sidebar/sessionList.tsx b/apps/desktop/src/components/layout/sidebar/sessionList.tsx new file mode 100644 index 0000000..08868ee --- /dev/null +++ b/apps/desktop/src/components/layout/sidebar/sessionList.tsx @@ -0,0 +1,464 @@ +import type { ReactNode } from "react"; +import { + guestLabel, + isLocusAgentImage, + sessionAvatarAccessory, + sessionAvatarId, + sessionAvatarPose, + sessionDisplayName, + sessionGitBranch, + sessionListStamp, + sessionRuntime, + type AgentSession, + type LinkedRepo, +} from "@locus/shared"; +import { api } from "@/lib/api"; +import { + clusterSessionsByRepo, + groupSessionsByDay, + sessionRepoKey, + sessionRepoLabel, +} from "@/lib/sessionGroups"; +import type { SidebarGroupBy, SidebarListFilter } from "@/lib/sidebarPrefs"; +import { sessionHasNeedsYou } from "@/lib/needsYou"; +import { formatSwarmListWhen } from "@/lib/swarmPresence"; +import { + addSwarmRosterSection, + hideSwarmSession, + moveSwarmSessionToSection, + sectionIdForSession, + showSwarmSession, + type SwarmRosterPrefs, +} from "@/lib/swarmRosterPrefs"; +import { TrustActions } from "@/components/chat/TrustActions"; +import { MenuItem, MenuLabel, MenuSep, OverflowMenu } from "../OverflowMenu"; +import { PersonaAvatar } from "@/components/layout/PersonaAvatar"; +import { sessionStatusLabel } from "@/lib/sessionStatus"; +import { showErrorNotice, showUndoNotice } from "@/lib/notice"; +import { askConfirm, askPrompt } from "@/lib/dialog"; +import { archiveOne, deleteSessionWithUndo } from "@/lib/sessionUndo"; +import { useStore } from "@/state/store"; + +export function agentRuntimeMeta(s: AgentSession): string { + const where = + sessionRuntime(s) === "host" + ? "Host" + : s.runtime === "docker" + ? s.dockerImage?.trim() && !isLocusAgentImage(s.dockerImage.trim()) + ? s.dockerImage.trim() + : null + : guestLabel(s.os); + return [where, s.model].filter(Boolean).join(" · "); +} + +export function agentMeta(s: AgentSession, projectTitle?: string): string { + return [ + s.archived ? "Archived" : null, + s.automationId ? "Job" : null, + s.persona?.hubRole ? s.persona.hubRole : null, + s.projectId ? (projectTitle ?? "Project") : null, + agentRuntimeMeta(s), + s.pull ? `#${s.pull.number}` : null, + s.sourceDesignId ? "design" : null, + ] + .filter(Boolean) + .join(" · "); +} + +export function sessionRowTitle(session: AgentSession): string { + const branch = sessionGitBranch(session); + const name = sessionDisplayName(session); + return branch ? `${name} · ${branch}` : name; +} + +export function sessionRowClass( + session: AgentSession, + selected: boolean, + unread: boolean, + extra = "", +): string { + return [ + "session-item", + extra, + selected ? "active" : "", + session.archived ? "archived" : "", + unread ? "unread" : "", + sessionHasNeedsYou(session) ? "needs-you" : "", + ] + .filter(Boolean) + .join(" "); +} + +function SessionTitle(props: { session: AgentSession; unread: boolean }) { + return ( +
+ {props.session.pinned ? ( + + ★ + + ) : null} + {sessionDisplayName(props.session)} + {props.unread ? : null} +
+ ); +} + +export function sessionPassesListFilter( + session: AgentSession, + filter: SidebarListFilter, + unreadIds: string[], +): boolean { + if (filter === "needs") return sessionHasNeedsYou(session); + if (filter === "running") { + return session.status === "running" || session.status === "provisioning"; + } + if (filter === "unseen") return unreadIds.includes(session.id); + return true; +} + +export function sessionStatusGroup(session: AgentSession): { + id: string; + label: string; + warn?: boolean; +} { + if (sessionHasNeedsYou(session)) return { id: "needs", label: "Needs you", warn: true }; + if (session.status === "running" || session.status === "provisioning") { + return { id: "running", label: "Running" }; + } + if (session.status === "error") return { id: "error", label: "Error" }; + if (session.archived) return { id: "archived", label: "Archived" }; + return { id: "idle", label: "Idle" }; +} + +export function SessionGroupBlocks(props: { + sessions: AgentSession[]; + selectedId: string | null; + unreadIds: string[]; + repos: LinkedRepo[]; + metaFor: (session: AgentSession) => string; + previewFor?: (session: AgentSession) => ReactNode; + onSelect: (id: string) => void; + trust?: boolean; + hub?: boolean; + rosterPrefs?: SwarmRosterPrefs; + onRosterPrefs?: (next: SwarmRosterPrefs) => void; + groupBy?: SidebarGroupBy; +}) { + const groupBy = props.groupBy ?? "day"; + + function renderRows(rows: AgentSession[]) { + return clusterSessionsByRepo(rows, sessionRepoKey, (session) => + sessionRepoLabel(session, props.repos), + ).map((cluster) => ( +
+ {cluster.label && !props.hub && groupBy !== "repo" ? ( +

{cluster.label}

+ ) : null} + {cluster.items.map((s) => ( + + ))} +
+ )); + } + + if (groupBy === "repo") { + return ( + <> + {clusterSessionsByRepo(props.sessions, sessionRepoKey, (session) => + sessionRepoLabel(session, props.repos), + ).map((cluster) => ( +
+ {props.hub ? null :

{cluster.label ?? "No repo"}

} + {cluster.items.map((s) => ( + + ))} +
+ ))} + + ); + } + + if (groupBy === "status") { + const buckets = new Map(); + const order = ["needs", "running", "error", "idle", "archived"]; + for (const session of props.sessions) { + const group = sessionStatusGroup(session); + const bucket = buckets.get(group.id) ?? { label: group.label, warn: group.warn, items: [] }; + bucket.items.push(session); + buckets.set(group.id, bucket); + } + return ( + <> + {order.map((id) => { + const bucket = buckets.get(id); + if (!bucket?.items.length) return null; + return ( +
+ {props.hub ? null : ( +

{bucket.label}

+ )} + {renderRows(bucket.items)} +
+ ); + })} + + ); + } + + return ( + <> + {groupSessionsByDay(props.sessions).map((group) => ( +
+ {props.hub ? null :

{group.label}

} + {renderRows(group.items)} +
+ ))} + + ); +} + +export function SessionRow(props: { + session: AgentSession; + selected: boolean; + unread: boolean; + meta: string; + preview?: ReactNode; + onSelect: (id: string) => void; + trust?: boolean; + hub?: boolean; + rosterPrefs?: SwarmRosterPrefs; + onRosterPrefs?: (next: SwarmRosterPrefs) => void; +}) { + const s = props.session; + const { state, dispatch } = useStore(); + const branch = sessionGitBranch(s); + const rowTitle = sessionRowTitle(s); + const stamp = props.hub ? formatSwarmListWhen(sessionListStamp(s)) : ""; + const rosterPrefs = props.rosterPrefs; + const hidden = Boolean(rosterPrefs?.hiddenSessionIds.includes(s.id)); + const currentSectionId = rosterPrefs ? sectionIdForSession(rosterPrefs, s.id) : null; + + function patch(next: { pinned?: boolean; archived?: boolean; title?: string }) { + void api.patchSession(s.id, next).catch((err: unknown) => { + showErrorNotice(err, "Could not update this agent"); + }); + } + + function rename() { + void (async () => { + const next = (await askPrompt({ title: "Rename", value: s.title }))?.trim(); + if (!next || next === s.title) return; + patch({ title: next }); + })(); + } + + function remove() { + void deleteSessionWithUndo(s, dispatch, askConfirm, { open: state.selectedId === s.id }); + } + + function duplicateSession() { + void api + .fork(s.id) + .then((forked) => { + dispatch({ type: "session", session: forked }); + dispatch({ type: "select", id: forked.id }); + }) + .catch((err: unknown) => { + showErrorNotice(err, "Could not duplicate"); + }); + } + + function updateRoster(next: SwarmRosterPrefs) { + props.onRosterPrefs?.(next); + } + + function hideTeammate() { + if (!rosterPrefs) return; + const previous = rosterPrefs; + updateRoster(hideSwarmSession(rosterPrefs, s.id)); + showUndoNotice(`Hidden ${s.title}`, () => updateRoster(previous)); + } + + function restoreTeammate() { + if (!rosterPrefs) return; + updateRoster(showSwarmSession(rosterPrefs, s.id)); + } + + function newSection() { + if (!rosterPrefs) return; + void (async () => { + const name = (await askPrompt({ title: "Section name" }))?.trim(); + if (!name) return; + updateRoster(addSwarmRosterSection(rosterPrefs, name, s.id)); + })(); + } + + function moveToSection(sectionId: string | null) { + if (!rosterPrefs) return; + updateRoster(moveSwarmSessionToSection(rosterPrefs, s.id, sectionId)); + } + + const menu = ( + + patch({ pinned: !s.pinned })}>{s.pinned ? "Unpin" : "Pin"} + + {props.hub ? "Duplicate teammate" : "Duplicate"} + + {props.hub && rosterPrefs ? ( + hidden ? ( + Show + ) : ( + Hide + ) + ) : null} + {props.hub && rosterPrefs ? ( + <> + + Section + {rosterPrefs.sections.map((section) => ( + moveToSection(section.id)} + > + Move to {section.name} + + ))} + New section… + {currentSectionId ? ( + moveToSection(null)}>Remove from section + ) : null} + + ) : null} + Rename + { + if (s.archived) patch({ archived: false }); + else archiveOne(s, dispatch); + }} + > + {s.archived ? "Unarchive" : "Archive"} + + + + Delete + + + ); + const copy = ( + <> +
+ + {stamp ? {stamp} : null} +
+
+ {props.hub ? null : branch ? ( + + {branch} + + ) : null} + {!props.hub && branch && props.meta ? · : null} + {props.meta ? {props.meta} : null} +
+ + ); + if (props.preview) { + return ( +
+
props.onSelect(s.id)}> + {props.preview} + +
+ + {menu} +
+ ); + } + const trust = props.trust ? : null; + if (trust) { + return ( +
+ + {trust} + {menu} +
+ ); + } + return ( +
+ + {menu} +
+ ); +} diff --git a/apps/desktop/src/components/projects/ProjectEmpty.tsx b/apps/desktop/src/components/projects/ProjectEmpty.tsx index fe1ace4..1ff2033 100644 --- a/apps/desktop/src/components/projects/ProjectEmpty.tsx +++ b/apps/desktop/src/components/projects/ProjectEmpty.tsx @@ -17,8 +17,6 @@ import { useStore } from "@/state/store"; import { EmptyHero } from "@/components/layout/EmptyHero"; import { IconPlus } from "@/components/layout/Icons"; import { PersonaAvatar } from "@/components/layout/PersonaAvatar"; -import { FeatureHintRow } from "@/components/layout/FeatureHint"; -import { SECTION_FEATURE_HINTS } from "@/lib/discoverability"; export function ProjectEmpty() { const { state, dispatch } = useStore(); @@ -101,9 +99,6 @@ export function ProjectEmpty() { : "A body of work with a coordinator, shared context, and members. The coordinator plans and delegates — it does not write application code." } /> - {SECTION_FEATURE_HINTS.project ? ( - - ) : null} {hubMode ? (
- ))} -
-

- {PRODUCT_MODE_OPTIONS.find((row) => row.id === productMode)?.hint} -

+
+

Product mode

+
+ {PRODUCT_MODE_OPTIONS.map((row) => ( + + ))}
+
+
Chat density
-
+
+

Hover a theme to preview it live. Click to apply.

+
{FILTERS.map((row) => ( diff --git a/apps/desktop/src/components/settings/FinishSetupChecklist.tsx b/apps/desktop/src/components/settings/FinishSetupChecklist.tsx index 85010d7..48ce04a 100644 --- a/apps/desktop/src/components/settings/FinishSetupChecklist.tsx +++ b/apps/desktop/src/components/settings/FinishSetupChecklist.tsx @@ -1,6 +1,7 @@ import type { SetupStatus } from "@locus/shared"; import { setupTodos } from "@locus/shared"; import type { ReactNode } from "react"; +import { SetupBanner } from "@/components/layout/SetupBanner"; type ChecklistTodo = { id: string; @@ -52,22 +53,16 @@ export function FinishSetupChecklist({ if (todos.every((row) => row.done) && !extraActions) return null; const summary = remainingSummary(todos); return ( -
-

- Finish setup - {summary ? · {summary} : null} -

-
- {extraActions} - - {onDismiss ? ( - - ) : null} -
-
+ + {extraActions} + + ); } diff --git a/apps/desktop/src/components/settings/ModelPicker.test.tsx b/apps/desktop/src/components/settings/ModelPicker.test.tsx index 4250c2d..51b4673 100644 --- a/apps/desktop/src/components/settings/ModelPicker.test.tsx +++ b/apps/desktop/src/components/settings/ModelPicker.test.tsx @@ -2,7 +2,7 @@ import { act, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; import { BUILTIN_PROVIDERS, builtinProviderKey, type AiProvider } from "@locus/shared"; -import { CodexReasoningPicker, ModelPicker } from "./ModelPicker"; +import { CodexReasoningPicker, ModelPicker, composerModelLabel } from "./ModelPicker"; import { selectOptionLabels, selectOptionValues, @@ -57,6 +57,20 @@ describe("ModelPicker", () => { expect(host.textContent).toMatch(/Approx\. published \$\/1M tokens \(2026-08\)/i); }); + it("gives the composer chip and the topbar one model label that flags an off provider", () => { + expect(composerModelLabel(providers, "missing", "big-pickle")).toEqual({ + label: "big-pickle · No provider", + off: true, + }); + expect(composerModelLabel(providers, "", "")).toEqual({ label: "No provider", off: true }); + const on = providers.map((p) => (p.id === "openai" ? { ...p, enabled: true } : p)); + expect(composerModelLabel(on, "openai", "gpt-4.1")).toEqual({ + label: "OpenAI · gpt-4.1", + off: false, + }); + expect(composerModelLabel(providers, "openai", "gpt-4.1").label).toBe("OpenAI (off) · gpt-4.1"); + }); + it("labels empty compact pickers instead of rendering blank selects", () => { render( { ); expect( host.querySelector("[aria-label='Model'] .model-picker-combined-label")?.textContent, - ).toBe("OpenAI · gpt-4.1"); + ).toBe("OpenAI (off) · gpt-4.1"); act(() => { host .querySelector("[aria-label='Model']") @@ -405,7 +419,7 @@ describe("ModelPicker", () => { />, ); expect(host.querySelector(".model-picker-combined-label")?.textContent).toBe( - "Anthropic · claude-sonnet-4-5", + "Anthropic (off) · claude-sonnet-4-5", ); act(() => { host diff --git a/apps/desktop/src/components/settings/ModelPicker.tsx b/apps/desktop/src/components/settings/ModelPicker.tsx index d58234c..68a4668 100644 --- a/apps/desktop/src/components/settings/ModelPicker.tsx +++ b/apps/desktop/src/components/settings/ModelPicker.tsx @@ -22,6 +22,30 @@ import { modelsForProvider } from "@/lib/lastAgent"; import { MenuItem, MenuLabel, MenuSep, OverflowMenu } from "@/components/layout/OverflowMenu"; import { Select } from "@/components/layout/Select"; +/** + * One label for "what the next turn runs on", shared by the composer model chip and the + * topbar subtitle so they never disagree. Flags a provider that is off or missing. + */ +export function composerModelLabel( + providers: AiProvider[], + providerId: string, + model: string, +): { label: string; off: boolean } { + const provider = providers.find((p) => p.id === providerId); + if (!provider) { + return { label: model ? `${model} · No provider` : "No provider", off: true }; + } + const selected = isCodexCliProvider(provider) ? normalizeCodexCliModel(model) : model; + const listed = modelsForProvider(provider, selected); + const value = usesCursorModelIds(provider) + ? cursorModelSelectValue(listed, selected) + : listed.includes(selected) + ? selected + : (listed[0] ?? ""); + const name = provider.enabled ? provider.name : `${provider.name} (off)`; + return { label: `${name} · ${value || "No model"}`, off: !provider.enabled }; +} + export function ModelPicker(props: { providers: AiProvider[]; providerId: string; @@ -31,6 +55,8 @@ export function ModelPicker(props: { onReasoningEffortChange?: (value: CodexReasoningEffort | undefined) => void; compact?: boolean; disabled?: boolean; + /** Cost table under the pickers; off where the model option label already shows the rate. */ + showCosts?: boolean; }) { const provider = props.providers.find((p) => p.id === props.providerId); const selectedModel = @@ -122,11 +148,7 @@ export function ModelPicker(props: { const modelControls = cursorControls || codexControls; if (props.compact) { - const combinedLabel = !provider - ? "No provider" - : !modelValue - ? `${provider.name} · No model` - : `${provider.name} · ${modelValue}`; + const combinedLabel = composerModelLabel(props.providers, props.providerId, props.model).label; return (
@@ -219,7 +241,7 @@ export function ModelPicker(props: {
{cursorControls} - {costs.length > 0 ? : null} + {props.showCosts !== false && costs.length > 0 ? : null}
); } diff --git a/apps/desktop/src/components/settings/NotificationSettings.tsx b/apps/desktop/src/components/settings/NotificationSettings.tsx index fd3443f..b5e26f1 100644 --- a/apps/desktop/src/components/settings/NotificationSettings.tsx +++ b/apps/desktop/src/components/settings/NotificationSettings.tsx @@ -34,7 +34,7 @@ const SOURCES: { { key: "notifyAutomations", testId: "notify-automations", - title: "Automations", + title: "Jobs", hint: "When a scheduled job finishes.", }, ]; @@ -53,8 +53,8 @@ export function NotificationSettings(props: { return (

- Desktop alerts for turns, Needs you, Slack Activity, and automations. Permission is - requested the first time one of these would fire — never if every source is off. + Desktop alerts for turns, Needs you, Slack Activity, and jobs. Permission is requested the + first time one of these would fire — never if every source is off.

diff --git a/apps/desktop/src/components/settings/RecipeCards.test.tsx b/apps/desktop/src/components/settings/RecipeCards.test.tsx index 952f615..a8258cb 100644 --- a/apps/desktop/src/components/settings/RecipeCards.test.tsx +++ b/apps/desktop/src/components/settings/RecipeCards.test.tsx @@ -71,13 +71,78 @@ describe("RecipeCards", () => { it("renders pill shortcuts without compact tiles", () => { const recipes = visibleRecipes(["ubuntu"]); render( - undefined} />, + undefined} + />, ); expect(host.querySelector(".recipe-grid.pills")).toBeTruthy(); expect(host.querySelector(".recipe-tile")).toBeNull(); expect(host.querySelector("kbd")?.textContent).toBe("P"); expect(host.querySelector("[aria-pressed='true']")?.textContent).toContain("PR reviewer"); }); + + it("omits letter hints where the screen has no shortcuts", () => { + render( + undefined} + />, + ); + expect(host.querySelector("kbd")).toBeNull(); + expect(host.querySelector("[aria-keyshortcuts]")).toBeNull(); + }); + + it("shows letter hints while Alt is held", () => { + render( + undefined} + />, + ); + const grid = host.querySelector(".recipe-grid.pills")!; + expect(grid.classList.contains("show-keys")).toBe(false); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Alt" })); + }); + expect(grid.classList.contains("show-keys")).toBe(true); + act(() => { + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt" })); + }); + expect(grid.classList.contains("show-keys")).toBe(false); + }); + + it("caps pills at the limit and reveals the rest with More…", () => { + const recipes = AGENT_RECIPES.slice(0, 5); + render( + undefined} + more={

extra

} + />, + ); + // Two plus the selected one stay visible. + expect(host.querySelectorAll("button[aria-pressed]")).toHaveLength(3); + expect(host.querySelector("[data-testid='more-extra']")).toBeNull(); + const more = [...host.querySelectorAll("button")].find((btn) => btn.textContent === "More…")!; + expect(more.getAttribute("aria-expanded")).toBe("false"); + act(() => { + more.click(); + }); + expect(host.querySelectorAll("button[aria-pressed]")).toHaveLength(5); + expect(host.querySelector("[data-testid='more-extra']")).toBeTruthy(); + }); }); describe("FirstTaskPills", () => { @@ -89,7 +154,7 @@ describe("FirstTaskPills", () => { host.remove(); }); - it("renders one sample and calls onSelect", () => { + it("renders every first task and calls onSelect", () => { let picked = ""; host = document.createElement("div"); document.body.appendChild(host); @@ -109,7 +174,7 @@ describe("FirstTaskPills", () => { expect(host.querySelector(".recipe-grid.pills")).toBeTruthy(); expect(host.textContent).toContain("Try a first task"); expect(host.textContent).toContain("hello.txt"); - expect(host.querySelectorAll(".recipe-grid.pills button")).toHaveLength(1); + expect(host.querySelectorAll(".recipe-grid.pills button")).toHaveLength(FIRST_TASKS.length); act(() => { (host.querySelector(".recipe-grid.pills button") as HTMLButtonElement).click(); }); diff --git a/apps/desktop/src/components/settings/RecipeCards.tsx b/apps/desktop/src/components/settings/RecipeCards.tsx index c69811e..4dd12f4 100644 --- a/apps/desktop/src/components/settings/RecipeCards.tsx +++ b/apps/desktop/src/components/settings/RecipeCards.tsx @@ -1,8 +1,35 @@ import { recipeShortcut, type AgentRecipe, type FirstTask } from "@locus/shared"; +import { useEffect, useState, type ReactNode } from "react"; import { IconPlus } from "@/components/layout/Icons"; export { recipeShortcut }; +/** True while Alt / Option is held, so letter hints can stay hidden until asked for. */ +export function useAltHeld(enabled = true): boolean { + const [held, setHeld] = useState(false); + useEffect(() => { + if (!enabled) return; + function onDown(event: KeyboardEvent) { + if (event.key === "Alt") setHeld(true); + } + function onUp(event: KeyboardEvent) { + if (event.key === "Alt") setHeld(false); + } + function onBlur() { + setHeld(false); + } + window.addEventListener("keydown", onDown); + window.addEventListener("keyup", onUp); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", onDown); + window.removeEventListener("keyup", onUp); + window.removeEventListener("blur", onBlur); + }; + }, [enabled]); + return held; +} + export function RecipeCards(props: { recipes: AgentRecipe[]; selectedId: string | null; @@ -10,55 +37,83 @@ export function RecipeCards(props: { label?: string; compact?: boolean; pills?: boolean; + /** Pill letter hints (only where the screen handles the keys). Shown on hover, focus, or Alt. */ + shortcuts?: boolean; + /** Show this many pills, then a "More…" toggle for the rest (and `more`). */ + limit?: number; + /** Extra rows revealed with "More…". */ + more?: ReactNode; }) { + const [expanded, setExpanded] = useState(false); + const showKeys = Boolean(props.pills && props.shortcuts); + const altHeld = useAltHeld(showKeys); if (!props.recipes.length) return null; const variant = props.pills ? " pills" : props.compact ? " compact" : ""; + const limit = props.limit && props.limit > 0 ? props.limit : undefined; + const hasMore = Boolean(limit && (props.recipes.length > limit || props.more)); + const shown = + limit && !expanded + ? props.recipes.filter((row, index) => index < limit || row.id === props.selectedId) + : props.recipes; return ( -
- {props.recipes.map((recipe) => { - const shortcut = recipeShortcut(recipe); - return ( + <> +
+ {shown.map((recipe) => { + const shortcut = showKeys ? recipeShortcut(recipe) : ""; + return ( + + ); + })} + {hasMore ? ( - ); - })} -
+ ) : null} +
+ {expanded ? props.more : null} + ); } diff --git a/apps/desktop/src/components/settings/RuntimePicker.tsx b/apps/desktop/src/components/settings/RuntimePicker.tsx index e875e1f..74bc64d 100644 --- a/apps/desktop/src/components/settings/RuntimePicker.tsx +++ b/apps/desktop/src/components/settings/RuntimePicker.tsx @@ -21,31 +21,39 @@ export function RuntimeToggle({ onChange, runtimes = AGENT_RUNTIMES, disabledRuntimes = [], + unavailable = {}, }: { value: AgentRuntime; onChange: (runtime: AgentRuntime) => void; runtimes?: AgentRuntime[]; disabledRuntimes?: AgentRuntime[]; + /** Shown but not selectable, with the reason as its tooltip. */ + unavailable?: Partial>; }) { const options = runtimes.length ? runtimes : AGENT_RUNTIMES; return (
- {options.map((runtime) => ( - - ))} + {options.map((runtime) => { + const reason = unavailable[runtime]; + return ( + + ); + })}
); } diff --git a/apps/desktop/src/components/settings/Settings.test.tsx b/apps/desktop/src/components/settings/Settings.test.tsx index c9502d3..a5b6477 100644 --- a/apps/desktop/src/components/settings/Settings.test.tsx +++ b/apps/desktop/src/components/settings/Settings.test.tsx @@ -75,7 +75,7 @@ vi.mock("@/lib/api", () => ({ rotateChromeExtension: vi.fn(), checkUpdates: vi.fn(async () => ({ status: "unavailable", - current: "0.18.0", + current: "0.19.0", message: "No GitHub release found yet. Check for updates works after a v* tag is published.", })), refreshCursorCli: vi.fn(async () => ({ changed: false, models: [] })), @@ -215,6 +215,13 @@ describe("Settings", () => { }); } + async function expandFirstProvider() { + const summary = host!.querySelector(".provider-card-summary") as HTMLButtonElement; + await act(async () => { + summary.click(); + }); + } + function navButton(label: string) { return [...host!.querySelectorAll(".settings-nav button")].find( (el) => el.querySelector(".settings-nav-label")?.textContent === label, @@ -253,7 +260,19 @@ describe("Settings", () => { it("revokes every integration token from Integrations", async () => { vi.mocked(api.revokeAllIntegrations).mockResolvedValue({ ok: true, config }); const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); - await render(); + await render({ + ...config, + integrations: [ + { + id: "jira1", + kind: "jira", + name: "Jira", + enabled: true, + baseUrl: "https://acme.atlassian.net", + token: "••••••••", + }, + ], + }); const integrations = navButton("Integrations"); await act(async () => { integrations!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -321,6 +340,8 @@ describe("Settings", () => { await render(); const page = host!.querySelector(".settings-page"); expect(page).toBeTruthy(); + expect(host!.textContent).not.toContain("Base URL"); + await expandFirstProvider(); expect(host!.textContent).toContain("Base URL"); expect(host!.querySelector(".settings-nav-group")?.textContent).toBe("Models"); expect([...host!.querySelectorAll(".settings-nav-group")].map((el) => el.textContent)).toEqual([ @@ -365,11 +386,9 @@ describe("Settings", () => { "ember", ); expect(host!.querySelector("[data-testid='theme-preview']")?.textContent).toContain( - "What should we talk about?", - ); - expect(host!.querySelector("[data-testid='theme-preview']")?.textContent).toContain( - "Start chat", + "What should the agent do?", ); + expect(host!.querySelector("[data-testid='theme-preview']")?.textContent).toContain("Send"); expect(host!.textContent).toContain("Night City"); expect(host!.textContent).toContain("Syntax"); expect(host!.textContent).toContain("Linen"); @@ -409,7 +428,7 @@ describe("Settings", () => { expect(host!.textContent).toContain("Omarchy 4"); expect(host!.textContent).toContain("1 of 2 ready"); expect(host!.textContent).toContain("ubuntu.img"); - expect(host!.textContent).toContain("no image set - won't start"); + expect(host!.textContent).toContain("Needs an image"); expect(host!.textContent).toContain("Fetch"); expect(host!.textContent).toContain("Add machine"); expect(host!.querySelectorAll(".machine-row")).toHaveLength(2); @@ -460,7 +479,7 @@ describe("Settings", () => { expect(host!.textContent).toContain("golden image + jail + recipe + spend cap + Where"); expect(host!.textContent).toContain("No named environments yet"); expect(host!.textContent).toContain("Save from New agent"); - expect(host!.textContent).toContain("Open Automations"); + expect(host!.textContent).toContain("Open Jobs"); await act(async () => { navButton("MCP")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -499,8 +518,8 @@ describe("Settings", () => { } const preview = host!.querySelector("[data-testid='theme-preview']"); expect(preview?.getAttribute("data-theme")).toBe("ember"); - expect(preview?.textContent).toContain("What should we talk about?"); - expect(preview?.textContent).toContain("Start chat"); + expect(preview?.textContent).toContain("What should the agent do?"); + expect(preview?.textContent).toContain("Send"); expect(css).toMatch(/\.theme-preview \{[\s\S]*?background:\s*var\(--bg\)/); const nord = [...host!.querySelectorAll(".theme-card")].find( (el) => el.querySelector("h3")?.textContent === "Nord", @@ -682,17 +701,16 @@ describe("Settings", () => { navButton("Appearance")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); const font = host!.querySelector("#editor-font-size") as HTMLInputElement; - const format = [...host!.querySelectorAll("label")] - .find((el) => el.textContent?.includes("Format on save")) - ?.querySelector("input") as HTMLInputElement; + const format = host!.querySelector( + "[data-testid='appearance-format-on-save']", + ) as HTMLButtonElement; const prefs = host!.querySelector(".appearance-editor-prefs"); const filters = host!.querySelector(".theme-filters"); expect(font.value).toBe("12"); - expect(format.checked).toBe(true); - const minimap = [...host!.querySelectorAll("label")] - .find((el) => el.textContent?.includes("Editor minimap")) - ?.querySelector("input") as HTMLInputElement; - expect(minimap.checked).toBe(true); + expect(format.getAttribute("role")).toBe("switch"); + expect(format.getAttribute("aria-checked")).toBe("true"); + const minimap = host!.querySelector("[data-testid='appearance-minimap']") as HTMLButtonElement; + expect(minimap.getAttribute("aria-checked")).toBe("true"); expect(prefs).toBeTruthy(); expect(filters).toBeTruthy(); expect( @@ -739,7 +757,9 @@ describe("Settings", () => { expect(host!.textContent).toContain("Agent turns"); expect(host!.textContent).toContain("Needs you"); expect(host!.textContent).toContain("Slack Activity"); - expect(host!.textContent).toContain("Automations"); + expect( + host!.querySelector("[data-testid='notify-automations']")?.getAttribute("aria-label"), + ).toBe("Jobs"); expect(host!.textContent).toContain("Quiet hours"); expect(host!.querySelector("[data-testid='notify-quiet-start']")).toBeNull(); const agents = host!.querySelector("[data-testid='notify-agents']") as HTMLButtonElement; @@ -1110,6 +1130,158 @@ describe("Settings", () => { open.mockRestore(); }); + it("leads dense pages with one sentence and a closed How this works disclosure", async () => { + await render(); + for (const [label, id] of [ + ["MCP", "mcp-how"], + ["Workers", "workers-how"], + ["Git", "git-how"], + ["Integrations", "integrations-how"], + ["Phone", "phone-how"], + ] as const) { + await act(async () => { + navButton(label)!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const how = host!.querySelector(`[data-testid='${id}']`); + expect(how, label).toBeTruthy(); + expect(how!.querySelector(".settings-how-lead")?.textContent?.length).toBeGreaterThan(0); + const details = how!.querySelector("details") as HTMLDetailsElement; + expect(details.querySelector("summary")?.textContent).toBe("How this works"); + expect(details.open).toBe(false); + } + }); + + it("names Work tools, hides Revoke until a token exists, and marks connected kinds", async () => { + await render({ + ...config, + integrations: [ + { + id: "jira1", + kind: "jira", + name: "Jira", + enabled: true, + baseUrl: "https://acme.atlassian.net", + }, + ], + }); + await act(async () => { + navButton("Integrations")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect([...host!.querySelectorAll("h3")].map((el) => el.textContent)).toContain("Work tools"); + expect(host!.querySelector("[data-testid='revoke-all-tokens']")).toBeNull(); + expect(host!.querySelector(".integration-catalog-card.connected")).toBeNull(); + host!.remove(); + act(() => root!.unmount()); + + await render({ + ...config, + integrations: [ + { + id: "jira1", + kind: "jira", + name: "Jira", + enabled: true, + baseUrl: "https://acme.atlassian.net", + token: "••••••••", + }, + ], + }); + await act(async () => { + navButton("Integrations")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(host!.querySelector("[data-testid='revoke-all-tokens']")).toBeTruthy(); + const connected = host!.querySelector(".integration-catalog-card.connected"); + expect(connected?.textContent).toContain("Jira"); + expect(connected?.textContent).toContain("Connected"); + }); + + it("shows only Sign in and Use a token for a signed-out GitHub account", async () => { + await render({ + ...config, + forges: [{ id: "gh1", kind: "github", name: "GitHub", apiBaseUrl: "https://api.github.com" }], + }); + await act(async () => { + navButton("Git")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const card = host!.querySelector(".git-account.open")!; + const actions = [...card.querySelectorAll(".git-account-actions button")].map( + (el) => el.textContent, + ); + expect(actions).toEqual(["Sign in with GitHub", "Use a token"]); + expect(card.querySelector("input[type='password'][placeholder='Personal access token']")).toBe( + null, + ); + const advanced = card.querySelector("details.git-account-advanced") as HTMLDetailsElement; + expect(advanced.open).toBe(false); + expect(advanced.textContent).toContain("OAuth client ID"); + expect(advanced.textContent).toContain("SSH key path"); + expect(advanced.querySelector(".git-account-remove")).toBeTruthy(); + await act(async () => { + [...card.querySelectorAll("button")].find((el) => el.textContent === "Use a token")!.click(); + }); + expect(card.querySelector("input[placeholder='Personal access token']")).toBeTruthy(); + }); + + it("groups providers into Enabled and Available with a filter", async () => { + const extra = Array.from({ length: 7 }, (_, i) => ({ + id: `x${i}`, + name: i === 0 ? "Anthropic" : `Vendor ${i}`, + kind: "openai-compatible" as const, + baseUrl: "https://example.com/v1", + models: [], + defaultModel: "", + enabled: false, + extraHeaders: {}, + })); + await render({ ...config, providers: [config.providers[0]!, ...extra] }); + expect(host!.querySelectorAll(".provider-card.open")).toHaveLength(0); + const enabled = host!.querySelector("[data-testid='provider-group-enabled']"); + const available = host!.querySelector("[data-testid='provider-group-available']"); + expect(enabled?.querySelectorAll(".provider-card")).toHaveLength(1); + expect(available?.querySelectorAll(".provider-card")).toHaveLength(7); + const filter = host!.querySelector("input[aria-label='Filter providers']") as HTMLInputElement; + expect(filter).toBeTruthy(); + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setter?.call(filter, "anthr"); + filter.dispatchEvent(new Event("input", { bubbles: true })); + }); + expect(host!.querySelectorAll(".provider-card")).toHaveLength(1); + expect(host!.querySelector(".provider-card")?.textContent).toContain("Anthropic"); + expect(host!.querySelector("[data-testid='provider-group-enabled']")).toBeNull(); + }); + + it("shows a neutral Needs an image state with one Set up action on Machines", async () => { + await render(); + await act(async () => { + navButton("Machines")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(host!.querySelector(".machine-row-status.missing")).toBeNull(); + const notReady = [...host!.querySelectorAll(".machine-row")].filter((row) => + row.textContent?.includes("Needs an image"), + ); + expect(notReady.length).toBeGreaterThan(0); + for (const row of notReady) { + expect(row.querySelectorAll(".machine-row-setup")).toHaveLength(1); + expect(row.querySelector(".machine-dot.pending")).toBeTruthy(); + } + }); + + it("uses switches for Brain options under Advanced", async () => { + await render(); + await act(async () => { + navButton("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + const brain = host!.querySelector("#settings-advanced-brain")!; + expect(brain.querySelector("input[type='checkbox']")).toBeNull(); + const auto = brain.querySelector("[data-testid='brain-autoIndex']"); + expect(auto?.getAttribute("role")).toBe("switch"); + expect(auto?.getAttribute("aria-checked")).toBe("true"); + expect( + brain.querySelector("[data-testid='brain-slackCorpus']")?.getAttribute("aria-checked"), + ).toBe("false"); + }); + it("offers GitHub, GitLab, and Bitbucket SSO sign-in on the Git tab", async () => { const open = vi.spyOn(window, "open").mockReturnValue(null); vi.mocked(api.startForgeOauth).mockResolvedValue({ @@ -1282,7 +1454,7 @@ describe("Settings", () => { await act(async () => { phone!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(host!.textContent).toContain("Enable phone remote"); + expect(host!.textContent).toContain("Phone remote"); expect(host!.textContent).toContain("Android"); expect(host!.textContent).toContain("Do not Funnel port 7420"); expect(host!.textContent).toContain("Pairing applies immediately"); @@ -1362,9 +1534,10 @@ describe("Settings", () => { await act(async () => { phone!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(host!.textContent).toContain("Enable phone remote"); - const toggle = host!.querySelector('[data-testid="phone-enabled"]') as HTMLInputElement | null; - expect(toggle?.checked).toBe(true); + expect(host!.textContent).toContain("Phone remote"); + const toggle = host!.querySelector('[data-testid="phone-enabled"]') as HTMLButtonElement | null; + expect(toggle?.getAttribute("role")).toBe("switch"); + expect(toggle?.getAttribute("aria-checked")).toBe("true"); await act(async () => { toggle!.click(); await Promise.resolve(); @@ -1393,7 +1566,7 @@ describe("Settings", () => { await act(async () => { chrome!.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - expect(host!.textContent).toContain("Enable Chrome extension"); + expect(host!.textContent).toContain("Chrome extension"); expect(host!.textContent).toContain("Load unpacked"); expect(host!.querySelector('[data-testid="chrome-token"]')?.textContent).toContain("AABB"); expect(host!.querySelector('[data-testid="chrome-install-dir"]')?.textContent).toContain( @@ -1559,7 +1732,7 @@ describe("Settings", () => { expect(host!.textContent).toContain("Guest boot timeout (ms)"); expect(host!.textContent).toContain("Approval timeout (ms)"); expect(host!.textContent).toContain("Prompt queue depth"); - expect(host!.textContent).toContain("Automation chats to keep"); + expect(host!.textContent).toContain("Job chats to keep"); expect(host!.textContent).toContain("Danger zone"); expect(host!.textContent).toContain("/tmp"); expect(host!.textContent).toContain("Autonomy"); @@ -1585,7 +1758,7 @@ describe("Settings", () => { expect(host!.textContent).toContain("Guest boot timeout (ms)"); expect(host!.textContent).toContain("Approval timeout (ms)"); expect(host!.textContent).toContain("Prompt queue depth"); - expect(host!.textContent).toContain("Automation chats to keep"); + expect(host!.textContent).toContain("Job chats to keep"); }); it("shows the QEMU install command as a copyable block beside a secondary button", async () => { @@ -1705,7 +1878,8 @@ describe("Settings", () => { expect(host!.querySelector(".settings-nav-count")?.textContent).toBe("2"); const cards = host!.querySelectorAll(".provider-card"); expect(cards).toHaveLength(2); - expect(cards[0]!.classList.contains("open")).toBe(true); + // Cards stay collapsed until clicked. + expect(cards[0]!.classList.contains("open")).toBe(false); expect(cards[1]!.classList.contains("open")).toBe(false); expect(cards[1]!.querySelector("input")).toBeNull(); await act(async () => { @@ -1717,10 +1891,19 @@ describe("Settings", () => { (el) => (el as HTMLInputElement).value === "https://api.anthropic.com", ), ).toBe(true); + expect( + host! + .querySelector("[data-testid='provider-group-enabled']") + ?.querySelectorAll(".provider-card"), + ).toHaveLength(2); + expect(host!.querySelector("[data-testid='provider-group-available']")).toBeNull(); await act(async () => { (cards[1]!.querySelector('[role="switch"]') as HTMLButtonElement).click(); }); - expect(cards[1]!.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); + // Turning a provider off moves it to the Available group. + const available = host!.querySelector("[data-testid='provider-group-available']"); + expect(available?.textContent).toContain("Anthropic"); + expect(available?.querySelector('[role="switch"]')?.getAttribute("aria-checked")).toBe("false"); expect(host!.textContent).toContain("1 of 2 providers enabled"); await act(async () => { [...host!.querySelectorAll("button")] @@ -1763,7 +1946,7 @@ describe("Settings", () => { expect(rows[0]!.querySelector("input")).toBeTruthy(); expect(rows[1]!.querySelector("input")).toBeNull(); expect(host!.textContent).toContain("4 c · 4096 MB · 40 GB"); - expect(host!.textContent).toContain("licensed ISO required"); + expect(host!.textContent).toContain("Needs a licensed ISO"); expect(host!.textContent).toContain("Host mock"); expect(host!.textContent).toContain("OVMF"); expect(host!.textContent).toContain("swtpm"); @@ -1819,6 +2002,7 @@ describe("Settings", () => { }, ], }); + await expandFirstProvider(); expect(host!.textContent).toContain("openrouter.ai/keys"); expect(host!.textContent).toContain("anthropic/claude-sonnet-4.5"); }); @@ -1839,6 +2023,7 @@ describe("Settings", () => { }, ], }); + await expandFirstProvider(); expect(host!.textContent).toContain("CLI binary"); expect(host!.textContent).toContain("agent -p"); expect(host!.textContent).toContain("cursor.com/install"); @@ -1871,6 +2056,7 @@ describe("Settings", () => { }, ], }); + await expandFirstProvider(); expect(host!.textContent).toContain("CLI binary"); expect(host!.textContent).toContain("claude -p"); expect(host!.textContent).toContain("code.claude.com"); @@ -1895,6 +2081,7 @@ describe("Settings", () => { }, ], }); + await expandFirstProvider(); expect(host!.textContent).toContain("CLI binary"); expect(host!.textContent).toContain("kiro-cli chat --no-interactive"); expect(host!.textContent).toContain("cli.kiro.dev"); diff --git a/apps/desktop/src/components/settings/Settings.tsx b/apps/desktop/src/components/settings/Settings.tsx index a154ac8..3846d04 100644 --- a/apps/desktop/src/components/settings/Settings.tsx +++ b/apps/desktop/src/components/settings/Settings.tsx @@ -78,12 +78,14 @@ import { SettingsEmpty, SkillCard, WorkerCard, + integrationConnected, isBuiltinProvider, isBuiltinTemplate, } from "./SettingsCards"; import { PolicyBanner, policyControlLocked } from "./PolicyBanner"; import { AppearanceSettings } from "./AppearanceSettings"; import { NotificationSettings } from "./NotificationSettings"; +import { HowItWorks, SettingsToggleRow } from "./SettingsBits"; import { SearchField } from "@/components/layout/SearchField"; import { BusyButton } from "@/components/layout/Skeleton"; import { EmptyHint } from "@/components/layout/EmptyHero"; @@ -136,6 +138,7 @@ export function Settings() { const [funnelBusy, setFunnelBusy] = useState(false); const [resetting, setResetting] = useState(false); const [expandedProvider, setExpandedProvider] = useState(null); + const [providerFilter, setProviderFilter] = useState(""); const [expandedForge, setExpandedForge] = useState(null); const [expandedSkill, setExpandedSkill] = useState(null); const [expandedMachine, setExpandedMachine] = useState(null); @@ -224,7 +227,6 @@ export function Settings() { useEffect(() => { if (state.section === "settings" && state.config) { setDraft((current) => current ?? state.config); - setExpandedProvider((current) => current ?? state.config?.providers[0]?.id ?? null); setExpandedForge((current) => current ?? state.config?.forges[0]?.id ?? null); setExpandedSkill((current) => current ?? state.config?.skills?.[0]?.id ?? null); setExpandedMachine((current) => current ?? state.config?.templates[0]?.id ?? null); @@ -477,6 +479,9 @@ export function Settings() { if (state.section !== "settings" || !draft) return null; const current = draft; const oauthCallback = forgeOauthCallbackUrl(current.advanced.bindPort); + const connectedKinds = new Set( + (current.integrations ?? []).filter(integrationConnected).map((row) => row.kind), + ); async function startOauth(account: (typeof current.forges)[number]) { setError(null); @@ -747,7 +752,7 @@ export function Settings() { if ( !(await askConfirm({ title: "Delete all Locus data?", - body: `Delete all Locus data in ${dir}? Sessions, secrets, disks, golden images, media, and automations will be gone. QEMU and Docker on this host stay.`, + body: `Delete all Locus data in ${dir}? Sessions, secrets, disks, golden images, media, and jobs will be gone. QEMU and Docker on this host stay.`, confirmLabel: "Continue", danger: true, })) @@ -845,6 +850,7 @@ export function Settings() { extraHeaders: {}, }; setDraft({ ...current, providers: [...current.providers, next] }); + setProviderFilter(""); setExpandedProvider(next.id); } @@ -1084,7 +1090,10 @@ export function Settings() { function focusSettingsHit(hit: SettingsSearchHit) { dispatch({ type: "settingsTab", tab: hit.tab }); setFind(""); - if (hit.tab === "providers" && hit.id) setExpandedProvider(hit.id); + if (hit.tab === "providers" && hit.id) { + setProviderFilter(""); + setExpandedProvider(hit.id); + } if (hit.tab === "mcp" && hit.id) setExpandedMcp(hit.id); if (hit.tab === "skills" && hit.id) setExpandedSkill(hit.id); if (hit.tab === "machines" && hit.id) setExpandedMachine(hit.id); @@ -1174,34 +1183,56 @@ export function Settings() { Add provider
- {draft.providers - .map((p, i) => ({ p, i })) - .sort((a, b) => Number(b.p.enabled) - Number(a.p.enabled)) - .map(({ p, i }) => ( - setExpandedProvider((id) => (id === p.id ? null : p.id))} - onPatch={(patch) => patchProvider(i, patch)} - onRefresh={() => void refreshModels(i)} - onTest={() => void testConn(i)} - onRemove={ - isBuiltinProvider(p) - ? undefined - : () => { - removeListUndoable("providers", i, p.name); - setExpandedProvider((id) => (id === p.id ? null : id)); - } - } - /> - ))} + {draft.providers.length > 6 ? ( + setProviderFilter(event.target.value)} + /> + ) : null} + {providerGroups(draft.providers, providerFilter).map((group) => ( +
+

+ {group.label} {group.rows.length} +

+ {group.rows.map(({ p, i }) => ( + setExpandedProvider((id) => (id === p.id ? null : p.id))} + onPatch={(patch) => patchProvider(i, patch)} + onRefresh={() => void refreshModels(i)} + onTest={() => void testConn(i)} + onRemove={ + isBuiltinProvider(p) + ? undefined + : () => { + removeListUndoable("providers", i, p.name); + setExpandedProvider((id) => (id === p.id ? null : id)); + } + } + /> + ))} +
+ ))} + {providerFilter.trim() && !providerGroups(draft.providers, providerFilter).length ? ( +

No providers match “{providerFilter.trim()}”.

+ ) : null} )} @@ -1215,7 +1246,7 @@ export function Settings() { formatOnSave={draft.formatOnSave !== false} showMinimap={draft.showMinimap !== false} onSelect={(id: ThemeId) => setDraft({ ...draft, theme: id })} - onEditor={(patch) => setDraft({ ...draft, ...patch })} + onEditor={(patch) => setDraft((prev) => (prev ? { ...prev, ...patch } : prev))} /> )} @@ -1308,7 +1339,7 @@ export function Settings() {

Match a GitHub, GitLab, Bitbucket, Gitea, Forgejo, or Azure DevOps URL (or a substring like org/repo) to give that project a different CPU, RAM, or disk than the guest - default. Agent and automation forms still let you override per run. + default. Agent and job forms still let you override per run.

{(draft.hardwareProfiles ?? []).map((profile, i) => (
@@ -1438,16 +1469,37 @@ export function Settings() { {tab === "workers" && ( <> -
-

- Paste the add line from npm run dev:worker (or{" "} - locus config workers add). Locus does not scan the LAN. Chat, keys, and - approvals stay on this computer. Docker, Host, or QEMU VM. Cloud VMs destroy guests - on stop and after idle TTL. Set hourly USD so the spend cap meters VM time. Cloud - VMs need an image allowlist. Fargate needs a size allowlist and a task definition - that already runs locus-worker. + + Run agents on another machine: paste the add line it prints from{" "} + npm run dev:worker. + + } + > +

+ You can also use locus config workers add. Locus does not scan the LAN. + Chat, keys, and approvals stay on this computer; a worker runs Docker, Host, or QEMU + VM guests.

-
+

+ Create a VM (or use a LAN box), install Docker, run npm run dev:worker, + then paste the printed locus-worker:add line. Add tests the host, pins + TLS, and lists it. +

+

+ Mark Cloud VM so idle TTL, the concurrent cap, hourly USD, and the + image allowlist apply. Cloud VMs destroy guests on stop and after idle TTL; set + hourly USD so the spend cap meters VM time. +

+

+ Mark Fargate to RunTask an allowed size (recorded HTTP, no AWS SDK) + using a task definition you already registered that runs locus-worker — Locus does + not create VPCs. An optional ECS endpoint sends RunTask through a VPC endpoint or + API Gateway. +

+
) : ( @@ -1564,14 +1607,21 @@ export function Settings() { )} {tab === "git" && ( <> -

- Add one or more GitHub, GitLab, Bitbucket, Gitea, Forgejo, and Azure DevOps accounts. - Sign in with GitHub, GitLab, or Bitbucket so SSO-protected orgs work, or paste a token - (Gitea, Forgejo, and Azure DevOps are PAT-only). Either is enough to clone, fetch a - branch or pull request, and push over HTTPS — no SSH key required. Multiple accounts - of the same forge can be active at once; each linked repo remembers which account to - use. -

+ +

+ Sign in with GitHub, GitLab, or Bitbucket so SSO-protected orgs work, or paste a + token (Gitea, Forgejo, and Azure DevOps are PAT-only). Either is enough to clone, + fetch a branch or pull request, and push over HTTPS — no SSH key required. +

+

+ Multiple accounts of the same forge can be active at once; each linked repo + remembers which account to use. OAuth app IDs, API URLs, and SSH key paths live + under Advanced on each account. +

+
{draft.forges.map((f, i) => { const linked = state.repos.filter((row) => row.checkout.accountId === f.id); @@ -1629,20 +1679,30 @@ export function Settings() { {tab === "integrations" && ( <> -

- First-party GitHub, GitLab, Bitbucket, Gitea, Forgejo, Azure DevOps, Jira, Confluence, - Linear, Slack, Grafana, Sentry, Miro, Figma, incident.io, AWS IAM, Portainer, Gmail, - Google Calendar, Google Drive, and Kubernetes, plus plugins the Locus agent can - author. Tokens stay in the vault, never in the guest or the prompt. Sign in with Slack - so Locus can act as you by default. Sign in with Google for Gmail, Calendar, and - Drive. Composer and slack_post can also send as the Locus bot when a bot token is - present. Agents only get those tools after that account has the required values. - Mutating Jira comments, creates, transitions, Confluence pages, Linear - comments/creates/updates, Slack posts, Sentry updates, Miro exports, Figma comments, - incident.io creates/updates, Portainer start/stop, Gmail sends, Calendar creates, - Drive uploads, Kubernetes apply/delete/scale, and plugin writes wait for Approve / - Deny. Ask an agent to add a plugin for a bespoke HTTP API. -

+ +

+ First-party GitHub, GitLab, Bitbucket, Gitea, Forgejo, Azure DevOps, Jira, + Confluence, Linear, Slack, Grafana, Sentry, Miro, Figma, incident.io, AWS IAM, + Portainer, Gmail, Google Calendar, Google Drive, and Kubernetes, plus plugins the + Locus agent can author. Tokens never reach the guest or the prompt. +

+

+ Sign in with Slack so Locus can act as you by default. Sign in with Google for + Gmail, Calendar, and Drive. Composer and slack_post can also send as the Locus bot + when a bot token is present. Agents only get those tools after that account has the + required values. +

+

+ Mutating Jira comments, creates, transitions, Confluence pages, Linear + comments/creates/updates, Slack posts, Sentry updates, Miro exports, Figma comments, + incident.io creates/updates, Portainer start/stop, Gmail sends, Calendar creates, + Drive uploads, Kubernetes apply/delete/scale, and plugin writes wait for Approve / + Deny. Ask an agent to add a plugin for a bespoke HTTP API. +

+

Git forges

Same accounts as Settings → Git. Restricted jail allowlists those hosts. @@ -1675,39 +1735,38 @@ export function Settings() { in Git.

)} -

- Jira, Confluence, Linear, Slack, Grafana, Sentry, Miro, Figma, incident.io, AWS IAM, - Portainer, Google, Kubernetes -

+

Work tools

{draft.managedPolicy?.policy.tokenFreeMode ? (

Token-free mode: integrations go through a managed MCP proxy. Personal tokens are not stored in the vault.

) : null} - + {integrationsHaveToken(draft.integrations) ? ( + + ) : null} {(draft.integrations ?? []).map((row, i) => ( { const next: IntegrationAccount = { id: crypto.randomUUID(), @@ -1759,6 +1818,9 @@ export function Settings() { > Add {integrationLabel(kind)} {integrationBlurb(kind)} + {connectedKinds.has(kind) ? ( + Connected + ) : null} ))}
@@ -1791,13 +1853,19 @@ export function Settings() { )} {tab === "mcp" && ( <> -

- MCP stdio can run on this host (security exception) or inside the Docker guest. - HTTP/SSE stays on the host. For an AWS MCP / API Gateway URL, set AWS_ACCESS_KEY_ID - and AWS_SECRET_ACCESS_KEY in env (optional AWS_SIGV4 service, default execute-api). - Tool calls wait for Approve / Deny in chat. Command must be a single executable (no - shell). -

+ +

+ MCP stdio can run on this host (security exception) or inside the Docker guest. + HTTP/SSE stays on the host. Command must be a single executable (no shell). +

+

+ For an AWS MCP / API Gateway URL, set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in + env (optional AWS_SIGV4 service, default execute-api). +

+
{(draft.mcpServers ?? []).length === 0 ? ( -

- Control this Locus from an Android phone on the same LAN or Tailscale network. The - control plane stays off the public internet. Pairing uses HTTPS with a self-signed - cert and a token; LAN clients without the token are rejected. The phone loads chats, - Slack, approvals, Design comments and promote, Codebase PR review and merge, workspace - files, and Docker Browser takeover. VNC stays on the desktop. Do not Funnel port 7420. -

+ +

+ The control plane stays off the public internet. Pairing uses HTTPS with a + self-signed cert and a token; LAN clients without the token are rejected. Do not + Funnel port 7420. +

+

+ The phone loads chats, Slack, approvals, Design comments and promote, Codebase PR + review and merge, workspace files, and Docker Browser takeover. VNC stays on the + desktop. +

+
@@ -1870,18 +1946,13 @@ export function Settings() {

Pairing applies immediately. Other settings auto-save.

-
- -
+ void setPhoneEnabled(next)} + /> {phone?.enabled && phone.token ? ( <>
@@ -1962,18 +2033,13 @@ export function Settings() {
-
- -
+ void setChromeEnabled(next)} + /> {chromeExt?.enabled && chromeExt.token ? ( <>
@@ -2014,65 +2080,59 @@ export function Settings() { {tab === "advanced" && ( <> -
+
Brain

Shared knowledge graph + distilled memory. Coding agents orient on this automatically. Workspace .locus/memories.json stays a manual override via CLI.

- - - - + + setDraft({ + ...draft, + brain: { ...draft.brain, autoIndex: next }, + }) + } + /> + + setDraft({ + ...draft, + brain: { ...draft.brain, ingestPaused: next }, + }) + } + /> + + setDraft({ + ...draft, + brain: { ...draft.brain, slackCorpus: next }, + }) + } + /> + + setDraft({ + ...draft, + brain: { ...draft.brain, jiraCorpus: next }, + }) + } + />
@@ -2368,7 +2428,7 @@ export function Settings() {

- +

- POST JSON when an automation finishes. Desktop notify already fires in-app. Slack - incoming webhook URLs get a text summary. + POST JSON when a job finishes. Desktop notify already fires in-app. Slack incoming + webhook URLs get a text summary.

@@ -2683,8 +2743,7 @@ export function Settings() {

Wipes {state.config?.advanced.dataDir ?? draft.advanced.dataDir}: config, encrypted secrets, sessions, VM disks, Docker workspaces, golden images, media, - and automations. Host QEMU/Docker installs are not touched. First-run setup runs - again. + and jobs. Host QEMU/Docker installs are not touched. First-run setup runs again.

({ p, i })) + .filter(({ p }) => + q + ? [p.name, p.kind, p.defaultModel ?? ""].some((text) => text.toLowerCase().includes(q)) + : true, + ); + const groups: ProviderGroup[] = [ + { id: "enabled", label: "Enabled", rows: rows.filter(({ p }) => p.enabled) }, + { id: "available", label: "Available", rows: rows.filter(({ p }) => !p.enabled) }, + ]; + return groups.filter((group) => group.rows.length); +} + +/** True when any integration account holds a credential worth revoking. */ +export function integrationsHaveToken(rows: IntegrationAccount[] | undefined): boolean { + return (rows ?? []).some((row) => + Boolean( + row.token?.trim() || + row.userToken?.trim() || + row.appToken?.trim() || + row.refreshToken?.trim() || + row.sessionToken?.trim(), + ), + ); +} diff --git a/apps/desktop/src/components/settings/SettingsBits.tsx b/apps/desktop/src/components/settings/SettingsBits.tsx new file mode 100644 index 0000000..0f87420 --- /dev/null +++ b/apps/desktop/src/components/settings/SettingsBits.tsx @@ -0,0 +1,65 @@ +import type { ReactNode } from "react"; + +/** + * One plain lead sentence, with the longer explanation behind a native + * "How this works" disclosure. Used at the top of dense Settings pages. + */ +export function HowItWorks(props: { lead: ReactNode; children: ReactNode; testId?: string }) { + return ( +
+

{props.lead}

+
+ How this works +
{props.children}
+
+
+ ); +} + +/** The on/off switch used across Settings (same look as Notifications). */ +export function SettingsSwitch(props: { + on: boolean; + label: string; + disabled?: boolean; + testId?: string; + onChange: (next: boolean) => void; +}) { + return ( + ) : null} - + {sso ? ( + + ) : null} {sso && f.authMethod === "oauth" && f.token ? (
-
-
- - { - onPatch({ name: e.target.value }); - }} - /> -
-
- - { - onPatch({ apiBaseUrl: e.target.value }); - }} - /> -
- {sso ? ( - <> -
- - { - onPatch({ oauthClientId: e.target.value }); - }} - /> -
-
- - { - onPatch({ oauthClientSecret: e.target.value }); - }} - /> -
- - ) : null} -
- + {showToken ? ( +
+ { onPatch({ token: e.target.value }); }} />
-
- - { - onPatch({ sshKeyPath: e.target.value }); - }} - /> -
-
- {sso ? ( -

- {f.kind === "github" - ? `Create a GitHub OAuth App with Device Flow enabled. Callback ${oauthCallback}.` - : f.kind === "gitlab" - ? `Create a GitLab OAuth application (confidential) with callback ${oauthCallback} and the api scope.` - : `Create a Bitbucket OAuth consumer with callback ${oauthCallback}.`} -

) : null} +
+ Advanced +
+
+ + { + onPatch({ name: e.target.value }); + }} + /> +
+
+ + { + onPatch({ apiBaseUrl: e.target.value }); + }} + /> +
+ {sso ? ( + <> +
+ + { + onPatch({ oauthClientId: e.target.value }); + }} + /> +
+
+ + { + onPatch({ oauthClientSecret: e.target.value }); + }} + /> +
+ + ) : null} +
+ + { + onPatch({ sshKeyPath: e.target.value }); + }} + /> +
+
+ {sso ? ( +

+ {f.kind === "github" + ? `Create a GitHub OAuth App with Device Flow enabled. Callback ${oauthCallback}.` + : f.kind === "gitlab" + ? `Create a GitLab OAuth application (confidential) with callback ${oauthCallback} and the api scope.` + : `Create a Bitbucket OAuth consumer with callback ${oauthCallback}.`} +

+ ) : null} + +
{oauthPending && oauthUserCode && !oauthError ? (

Enter {oauthUserCode} at{" "} @@ -753,8 +772,8 @@ export function machineReady(template: { isoPath: string }): boolean { export function machineStatusLabel(template: { os: GuestOs; isoPath: string }): string { if (machineReady(template)) return machineImageName(template.isoPath); - if (isWindows(template.os)) return "licensed ISO required"; - return "no image set - won't start"; + if (isWindows(template.os)) return "Needs a licensed ISO"; + return "Needs an image"; } function machineHint(template: VmTemplate): string { @@ -932,25 +951,36 @@ export function MachineCard({ const { cpuCores, memoryMb, diskSizeGb } = template.hardware; return (

- + + {cpuCores} c · {memoryMb} MB · {diskSizeGb} GB + + + {!ready ? ( + + ) : null} +
{expanded ? (
{!isBuiltinTemplate(template.id) ? ( @@ -1263,6 +1293,11 @@ export function integrationStatusLabel(row: IntegrationAccount): string { return row.token?.trim() ? "Connected" : "Not connected"; } +export function integrationConnected(row: IntegrationAccount): boolean { + const status = integrationStatusLabel(row); + return status !== "Off" && status !== "Not connected"; +} + export function McpServerCard({ server, expanded, @@ -2074,7 +2109,7 @@ export function IntegrationCard({ onRemove: () => void; }) { const status = integrationStatusLabel(account); - const connected = status !== "Off" && status !== "Not connected"; + const connected = integrationConnected(account); return (
diff --git a/apps/desktop/src/components/settings/ThemePreview.test.tsx b/apps/desktop/src/components/settings/ThemePreview.test.tsx index aef487c..f93b854 100644 --- a/apps/desktop/src/components/settings/ThemePreview.test.tsx +++ b/apps/desktop/src/components/settings/ThemePreview.test.tsx @@ -2,6 +2,7 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; import { ThemePreview } from "./ThemePreview"; +import { NEW_AGENT_COPY } from "@/lib/launchCopy"; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -27,9 +28,14 @@ describe("ThemePreview", () => { expect(preview?.hasAttribute("inert")).toBe(true); expect(host.textContent).toContain("Preview"); expect(host.textContent).toContain("Paper"); - expect(host.textContent).toContain("What should we talk about?"); - expect(host.textContent).toContain("Start chat"); - expect(host.querySelector("textarea")?.getAttribute("placeholder")).toBe("Ask a question…"); + // Same copy constants as the real new-agent screen (EmptyState). + expect(host.textContent).toContain(NEW_AGENT_COPY.heading); + expect(host.textContent).toContain("What should the agent do?"); + expect(host.querySelector(".composer-send")?.textContent).toContain(NEW_AGENT_COPY.send); + expect(host.querySelector(".empty-composer .composer-send")).toBeTruthy(); + expect(host.querySelector("textarea")?.getAttribute("placeholder")).toBe( + NEW_AGENT_COPY.placeholder, + ); }); it("marks gradient themes on the preview surface", () => { @@ -44,4 +50,17 @@ describe("ThemePreview", () => { expect(preview?.hasAttribute("data-theme-gradient")).toBe(true); expect(host.textContent).toContain("Horizon"); }); + + it("renders console as a dark terminal-window preview", () => { + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + act(() => { + root.render(); + }); + const preview = host.querySelector("[data-testid='theme-preview']"); + expect(preview?.getAttribute("data-theme")).toBe("console"); + expect(preview?.hasAttribute("data-theme-gradient")).toBe(false); + expect(host.textContent).toContain("Console"); + }); }); diff --git a/apps/desktop/src/components/settings/ThemePreview.tsx b/apps/desktop/src/components/settings/ThemePreview.tsx index a25754a..d839330 100644 --- a/apps/desktop/src/components/settings/ThemePreview.tsx +++ b/apps/desktop/src/components/settings/ThemePreview.tsx @@ -1,5 +1,7 @@ import { resolveTheme, themeOption } from "@locus/shared"; import { EmptyHero } from "@/components/layout/EmptyHero"; +import { IconReturn } from "@/components/layout/Icons"; +import { NEW_AGENT_COPY } from "@/lib/launchCopy"; export function ThemePreview({ theme, @@ -37,8 +39,8 @@ export function ThemePreview({
Docker @@ -46,11 +48,18 @@ export function ThemePreview({ Host
-