diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 88df005..d8d64bf 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.17.0). - placeholder: 0.17.0 + description: Settings → About, or the GitHub Release tag (for example 0.18.0). + placeholder: 0.18.0 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee18dcb..9ceae59 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 images/agent + - run: docker build -t locus/agent:0.1 -t locus/agent:0.18.0 images/agent android: runs-on: ubuntu-latest diff --git a/.github/workflows/dist-linux.yml b/.github/workflows/dist-linux.yml index c1a84fa..1ab84ba 100644 --- a/.github/workflows/dist-linux.yml +++ b/.github/workflows/dist-linux.yml @@ -68,9 +68,11 @@ jobs: run: node scripts/release-checksums.mjs apps/desktop/dist-electron SHA256SUMS-linux - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: locus-linux-appimage + name: locus-linux path: | apps/desktop/dist-electron/*.AppImage + apps/desktop/dist-electron/*.deb + apps/desktop/dist-electron/*.rpm apps/desktop/dist-electron/SHA256SUMS-linux if-no-files-found: error retention-days: 30 @@ -80,6 +82,8 @@ jobs: with: files: | apps/desktop/dist-electron/*.AppImage + apps/desktop/dist-electron/*.deb + apps/desktop/dist-electron/*.rpm apps/desktop/dist-electron/SHA256SUMS-linux generate_release_notes: true # Only this job generates notes. macOS/Windows attach files with diff --git a/CHANGELOG.md b/CHANGELOG.md index 78f655d..1e680f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ ## Unreleased +## 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**. + +**Workers.** QEMU guests on a LAN or cloud worker stream the display to the laptop over a pinned WebSocket instead of treating worker loopback as local. Phone threads get a view-only pane; control stays on the desktop. Golden VM disks upload once to the worker and reuse. Azure Container Instances and Google Compute Engine sit next to Fargate for placement. Worker planes namespace guests under one token. + +**Slack.** Posting and uploading from an agent can be approved with Slack action buttons. Forge writes still confirm in Locus. + +**Guests.** Cursor, Claude, Codex, and Kiro spawn over SSH into Windows VMs via PowerShell. Linux guests stay POSIX. + +**Desktop.** The composer Explore / Review chip matches neighboring controls. Opening the model menu no longer flashes above the trigger. A usage hint shows combined LLM and VM spend. Setup offers a first Host task from the empty Agents state. On the phone, More opens Settings, New automation, and the command palette; threads gain Find in chat. + +**Coding CLIs.** Kiro CLI stream-json envelopes map into the chat thread instead of finishing with no reply, and duplicate tool chips are gone. A turn that already ran the project's tests is not flagged Needs you for a scratch write or a trailing delete-only cleanup. Codex keeps file-change details in the thread. + +**Packaging.** Linux ships AppImage, deb, and rpm. The agent image is tagged with both a stable alias and this version. About → Download and verify checks SHA256SUMS before opening the installer. + ## 0.17.0 — 2026-11-08 Quieter desktop shell, more guest runtimes, extra forges, and more reliable installer builds. Settings → About is **0.17.0**. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4b4607..356da67 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.17.0 -git push origin v0.17.0 +git tag v0.18.0 +git push origin v0.18.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 e860041..44741a2 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,13 @@ 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.17.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for. +This is **v0.18.0** (MIT). The API binds to loopback. Linux, macOS, and Windows. Bring any LLM provider you have a key for. ## Install Download the latest [GitHub Release](https://github.com/Modsofthenation/Locus/releases). Each `v*` tag attaches: -- Linux AppImage +- Linux AppImage, deb, and rpm - macOS dmg (signed when `CSC_LINK` is set on the repo; otherwise unsigned — right-click → Open the first time) - Windows NSIS installer (unsigned builds show SmartScreen) - Per-platform checksums (`SHA256SUMS-linux`, `SHA256SUMS-macos`, `SHA256SUMS-windows`) @@ -38,7 +38,7 @@ You describe a task. Locus runs the agent in a guest you own. Review diffs, stee The runtime is chosen per agent and stays fixed. New agents default to Docker when the engine is present, otherwise Host. -**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker, Host, or QEMU VM** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. Worker VMs resolve cloud images from `LOCUS_WORKER_HOME/media`; live VNC stays on the worker localhost in v1 (screenshots and tools still work). Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement. +**Where** defaults to this machine. Docker, VM, and Host are shapes of a guest; placement is which computer runs it. Other LAN machines running `npm run dev:worker` can host **Docker, Host, or QEMU VM** agents — paste the printed `locus-worker:add` line in Settings → Workers (or `locus config workers add`), then New agent → Where. Worker VMs resolve cloud images from `LOCUS_WORKER_HOME/media`; live VNC is proxied to the laptop over pinned WSS (the worker still binds VNC to localhost). Golden VM disks upload to the worker once. Azure and GCP workers use the same recorded-HTTP placement as Fargate. Locus does not scan the LAN. Each app only lists workers it registered. Two apps can share one worker, or run isolated workers on the same host with different `LOCUS_WORKER_PORT` and `LOCUS_WORKER_HOME` — [docs/agent-backends.md](docs/agent-backends.md#multiple-locus-apps-one-worker-host). Cursor Cloud is a provider that skips the guest, not a placement. ## Tour @@ -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.17.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.18.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.17.0**. Electron packages (Linux AppImage, macOS dmg, Windows NSIS), or `npm run dev` in the browser. **Ctrl/Cmd+K** command palette. **Ctrl/Cmd+N** new agent, new automation, or (on Design, Games, Assets) the empty composer. **Ctrl/Cmd+F** find in chat. **Ctrl/Cmd+,** settings. +**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. ## Security model diff --git a/SECURITY.md b/SECURITY.md index e0b9c1c..278afab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,7 +11,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c - Bind the HTTP/WebSocket control plane to loopback (`127.0.0.1` / `::1` / `localhost` only) unless Settings → Phone is enabled. Saving a non-loopback bind host in Advanced is still rejected. Phone remote binds `0.0.0.0` and requires a vaulted pairing token for every non-loopback client; public internet peers are rejected even with a token. The same port multiplexes loopback HTTP with LAN HTTPS (self-signed cert in `~/.locus/tls/`, fingerprint on the pairing QR). Minting, rotating, and revealing that token is loopback-only. Restart after disabling phone remote so 7420 leaves `0.0.0.0`. The `locus` CLI (`apps/cli`) is another loopback client of that API — same bind, redaction, and approval gates as the desktop. - Reject browser requests whose `Origin` is not loopback. - Keep QEMU display, SSH, RDP, WinRM, QMP, and serial forwards on loopback. VNC has no password and SPICE uses `disable-ticketing`; that is accepted for a single-operator loopback display, not as a network service. -- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. Worker QEMU guests keep VNC/SPICE on the **worker** loopback in v1 (not forwarded to the laptop). Cursor / Claude / Codex / Kiro on a Linux VM spawn over SSH with `-R` of the control-plane MCP port into the guest; Windows VMs stay Docker/Host-only for those CLIs. The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only. +- Talk to LAN `locus-worker` hosts over HTTPS and pin the worker certificate fingerprint. Test connection **observes** the fingerprint; paste it into TLS fingerprint after matching the worker printout, then Save. Non-loopback workers require that pin on first save (no silent TOFU). WebSockets refuse to connect until a pin is saved. Cleartext worker URLs are rejected except loopback. Worker `runtime:"host"` (tools as `sh -lc` on the worker box) is off until `LOCUS_WORKER_ALLOW_HOST=1`. Worker QEMU guests keep VNC/SPICE on the **worker** loopback; the laptop reaches VNC through a pinned WSS proxy (`/v1/guests/:id/display`), never by publishing VNC. Cursor / Claude / Codex / Kiro on Linux or Windows VMs spawn over SSH with `-R` of the control-plane MCP port into the guest (PowerShell on Windows). The worker token is printed only on a TTY the first time it is created. Unauthenticated `GET /health` returns `{ok,service,protocol}` only. - Redact provider keys, forge tokens, guest passwords, and matching vault secrets in API responses, traces, session JSON, and tool output sent to the model. Tool-argument **values** are pattern-scanned, not only secret-named keys. GitLab `glpat-`, phone `ph_`, Chrome extension `cx_`, and Atlassian `ATATT` formats are included. `GET /api/automations` masks `webhookSecret` / `slackSigningSecret`; create/rotate still returns the secret once. - Store those secrets in `~/.locus/secrets.enc` (AES-256-GCM). The packaged app wraps the master key with Electron `safeStorage` when the OS keychain is available, then deletes plaintext `master.key`. - MCP stdio servers inherit a minimal host env (`PATH`, locale, temp). They do not receive `LOCUS_MASTER_KEY`. @@ -34,7 +34,7 @@ Please use [GitHub private vulnerability reporting](https://docs.github.com/en/c - Forge **webhooks** (and Slack / generic HTTP webhooks) are reachable only if something can POST to loopback or to a Tailscale Funnel that proxies **only** `/api/hooks`. An Amazon API Gateway HTTP integration may use the same `/api/hooks` path; set the webhook base URL to that origin. Locus unwraps REST/HTTP API proxy events. GitHub.com and Slack.com cannot reach `127.0.0.1`. Generic `X-Locus-Secret` or `x-api-key` requests also need `X-Locus-Timestamp` (unix seconds, ±5 minutes) as a **real HTTP header**. A timestamp only inside an API Gateway JSON envelope is ignored, so Funnel/API Gateway integrations must forward that header. GitHub `X-Hub-Signature-256` stays body-HMAC. Slack signing secrets can be set per automation (else Settings → Advanced). A leaked secret on a forwarded port would still be enough to queue a run within the timestamp window. Webhook bodies are wrapped as a nonce-tagged `` block, size-capped (bytes, streamed), and must not be followed as instructions. First-party Jira/Confluence/Linear/Slack/Grafana/Miro/Figma/incident.io/AWS IAM/Portainer/Gmail/Calendar/Drive/Kubernetes/Sentry tools are off on automations unless listed on that job's allowlist. - First-party Jira / Confluence / Linear / Slack / Grafana / Miro / Figma / incident.io / AWS IAM / Portainer / Gmail / Google Calendar / Google Drive / Sentry / Kubernetes tools run on the **host** with vaulted tokens, like forge PR tools. They are omitted from the agent catalog until Settings → Integrations has a token for that product (AWS IAM also becomes ready when instance metadata is enabled; Kubernetes becomes ready with a token or a loopback `kubectl proxy` URL). Mutating Jira comments, creates, updates, transitions, Confluence pages, Linear comments/creates/updates, Slack posts, incident.io creates/updates, Miro export, Figma comments, Portainer start/stop, Gmail sends, Calendar creates, Drive uploads, and Sentry updates, Kubernetes apply/delete/scale wait for Approve / Deny. AWS IAM tools in this slice are reads (`GetCallerIdentity`, list/get/simulate). Kubernetes `k8s_get` of Secret objects redacts `data` / `stringData`. **Cursor CLI** sessions get the same tools through a loopback MCP server (`POST /mcp`); `agent --approve-mcps` is the operator opt-in for those calls. **Claude Code** sessions get the same server via project `.mcp.json` (`type: http`) and `--mcp-config .mcp.json`. The MCP config file stores only the loopback URL, not tokens. Worker Cursor CLI and Claude Code use a loopback proxy on the worker host that tunnels JSON-RPC back to this control plane — it does not bind the laptop MCP port on the LAN. AWS IAM/STS calls use signed `fetch` on the host (no AWS SDK), same as Fargate placement. Optional IAM/STS/ECS endpoint URLs and an AWS gateway URL keep their path; `execute-api` hosts sign as `execute-api`. HTTP MCP and OpenAI-compatible Bedrock / API Gateway URLs sign the same way when AWS keys are configured. The control plane bind host stays loopback. Instance metadata is IMDSv2 or the ECS credentials URI; `AWS_CONTAINER_CREDENTIALS_FULL_URI` must be link-local or loopback. Kubernetes tools talk to the API server (or loopback kubectl proxy) from the host; they do not run kubectl in the guest. - **Integration plugins** are the same host-side HTTP exception, authored by `manage_integration_plugin` (Approve / Deny). Secrets belong in Settings → Integrations, not in the manage tool. Updating a plugin keeps vaulted secrets; changing a secret field to a non-secret type drops the value instead of writing it to `config.json`. The request host must come from a plugin URL field or a hardcoded https host; argument values cannot retarget the host. Redirects are refused. Plugin responses are nonce-fenced. GET tools with `write: false` skip Approve / Deny (they are treated as reads); non-GET tools always wait. Automations only see listed `plugin_*` names. -- **Slack identity.** Messages Locus sends default to **you** (composer or approved `slack_post`). Pass `as="bot"` or use the composer toggle to post as the Locus app when a bot token is stored. The Slack app is OAuth + Socket Mode plumbing. Socket Mode is outbound WSS; Slack.com still cannot reach loopback. Funnel stays `/api/hooks` only. Do not Approve / Deny from Slack. Thread excerpts are nonce-fenced (`kind="slack-message"`). The cache under `~/.locus/slack/` stays on this machine and is encrypted with the vault master key. Slack file GIFs and videos are fetched with the user token through `/api/slack/media` (Slack file hosts only). HTML and CSV files stay `Content-Disposition: attachment` on that route unless `preview=1`, which serves HTML with a strict CSP for a `sandbox=""` iframe (no scripts) and CSV as inline text for the table preview. Video is served inline for the in-pane player. GIF search is outbound to api.giphy.com when `LOCUS_GIPHY_API_KEY` is set and managed policy allows it; otherwise a local catalog. +- **Slack identity.** Messages Locus sends default to **you** (composer or approved `slack_post`). Pass `as="bot"` or use the composer toggle to post as the Locus app when a bot token is stored. The Slack app is OAuth + Socket Mode plumbing. Socket Mode is outbound WSS; Slack.com still cannot reach loopback. Funnel stays `/api/hooks` only. Slack action buttons may Approve / Deny **`slack_post` and `slack_upload` only** — `git_push` and other forge writes stay in Locus. Thread excerpts are nonce-fenced (`kind="slack-message"`). The cache under `~/.locus/slack/` stays on this machine and is encrypted with the vault master key. Slack file GIFs and videos are fetched with the user token through `/api/slack/media` (Slack file hosts only). HTML and CSV files stay `Content-Disposition: attachment` on that route unless `preview=1`, which serves HTML with a strict CSP for a `sandbox=""` iframe (no scripts) and CSV as inline text for the table preview. Video is served inline for the in-pane player. GIF search is outbound to api.giphy.com when `LOCUS_GIPHY_API_KEY` is set and managed policy allows it; otherwise a local catalog. - Host **web search** also runs on the operator machine. Leave it off unless you want that agent to query the public web. - **Use my Chrome** is a second Host-runtime exception: an unpacked Chrome/Edge extension on loopback can drive the operator's real tabs (cookies and logins included) for Host agents that opt in. Isolated `host-chrome` stays the default. Pairing tokens (`cx_`) are vaulted like phone `ph_`. The websocket is loopback-only; navigation is an **http(s) allowlist** (`file:`, `data:`, `chrome://`, and other schemes are refused). Workers cannot use live Chrome. - **Android pairing** loads HTTPS only (cleartext disabled). The TLS fingerprint is required; a `locus://pair` deep link prefills the form and asks for confirmation before connecting. The token is stored as a cookie for the WebView, not appended to the HTTPS URL. Third-party cookies are off. diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index 253459a..be19755 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 = 31 - versionName = "0.17.0" + versionCode = 32 + versionName = "0.18.0" } buildTypes { release { diff --git a/apps/chrome-extension/manifest.json b/apps/chrome-extension/manifest.json index 3bb19f5..c15610b 100644 --- a/apps/chrome-extension/manifest.json +++ b/apps/chrome-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Locus", - "version": "0.17.0", + "version": "0.18.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 350f08d..8a7db4d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@locus/cli", - "version": "0.17.0", + "version": "0.18.0", "private": true, "description": "Locus CLI — control plane client for agents, Slack, Codebase, Docker, and automations", "type": "module", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1bb223d..f3e397c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,10 +1,11 @@ { "name": "@locus/desktop", - "version": "0.17.0", + "version": "0.18.0", "private": true, "author": "Locus contributors", "license": "MIT", "description": "Locus — local AI coding agents in real guest VMs", + "homepage": "https://github.com/Modsofthenation/Locus#readme", "desktopName": "locus", "type": "module", "main": "electron/main.cjs", @@ -19,7 +20,8 @@ "test:visual:update": "playwright test --update-snapshots", "capture:readme": "node ./scripts/capture-readme-screens.mjs", "electron": "electron .", - "dist": "npm run build && electron-builder --publish never" + "dist": "npm run build && electron-builder --publish never", + "dist:linux:arm64": "npm run build && electron-builder --linux --arm64 --publish never" }, "dependencies": { "@locus/shared": "*", @@ -85,12 +87,22 @@ ], "linux": { "target": [ - "AppImage" + "AppImage", + "deb", + "rpm" ], "category": "Development", "maintainer": "Locus", "executableName": "locus" }, + "deb": { + "packageName": "locus", + "artifactName": "${productName}-${version}-${arch}.${ext}" + }, + "rpm": { + "packageName": "locus", + "artifactName": "${productName}-${version}-${arch}.${ext}" + }, "mac": { "target": [ "dmg" diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 43276d5..f1e900c 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.17.0" })), + checkUpdates: vi.fn(async () => ({ status: "unavailable", current: "0.18.0" })), workersStatus: vi.fn(async () => []), tailscale: vi.fn(async () => ({ installed: false, diff --git a/apps/desktop/src/components/chat/ChatPane.test.tsx b/apps/desktop/src/components/chat/ChatPane.test.tsx index 73718e0..ae0e407 100644 --- a/apps/desktop/src/components/chat/ChatPane.test.tsx +++ b/apps/desktop/src/components/chat/ChatPane.test.tsx @@ -2974,7 +2974,11 @@ describe("ChatPane", () => { expect(css).toMatch(/\[data-chat-density="comfortable"\][\s\S]*?--chat-font: 14\.5px;/); expect(css).toMatch(/\[data-chat-density="dense"\][\s\S]*?--chat-font: 12px;/); expect(css).toMatch(/\[data-chat-density="dense"\][\s\S]*?--chat-msg-gap: 8px;/); - expect(css).toMatch(/\.mobile-thread \{[\s\S]*?padding: var\(--chat-mobile-thread-pad\)/); + const phone = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), "../../styles/phone.css"), + "utf8", + ); + expect(phone).toMatch(/\.mobile-thread \{[\s\S]*?padding: var\(--chat-mobile-thread-pad\)/); }); it("lets developer thinking snippets fill the message column", () => { @@ -3273,7 +3277,7 @@ describe("ChatPane", () => { { id: "c1", name: "exec", - arguments: { command: "git show develop:siq8/batch/test.py" }, + arguments: { command: "git show develop:app/batch/test.py" }, result: "ok", startedAt: "2020-01-01T00:00:00.000Z", finishedAt: "2020-01-01T00:00:01.000Z", @@ -3290,7 +3294,7 @@ describe("ChatPane", () => { { id: "c1", name: "exec", - arguments: { command: "git show develop:siq8/batch/test.py" }, + arguments: { command: "git show develop:app/batch/test.py" }, result: "ok", startedAt: "2020-01-01T00:00:00.000Z", finishedAt: "2020-01-01T00:00:01.000Z", diff --git a/apps/desktop/src/components/chat/PatchCard.test.tsx b/apps/desktop/src/components/chat/PatchCard.test.tsx index 4d1500d..a76b1f1 100644 --- a/apps/desktop/src/components/chat/PatchCard.test.tsx +++ b/apps/desktop/src/components/chat/PatchCard.test.tsx @@ -123,8 +123,8 @@ describe("PatchCard", () => { ({ const web: DockerContainer = { id: "c1", - name: "siq8-dev_webpack.abc", + name: "app-dev_webpack.abc", image: "node:22", state: "running", status: "Up 2 hours", @@ -34,7 +34,7 @@ const web: DockerContainer = { const db: DockerContainer = { id: "c2", - name: "siq8-dev_nginx.xyz", + name: "app-dev_nginx.xyz", image: "nginx:alpine", state: "exited", status: "Exited (0) 3 minutes ago", @@ -105,21 +105,21 @@ describe("DockerExplorer", () => { expect(host.querySelector('[data-testid="docker-explorer"]')).toBeTruthy(); expect(host.querySelector(".docker-explorer-head")?.getAttribute("aria-expanded")).toBe("true"); expect(host.querySelector(".docker-explorer-count")?.textContent).toBe("2"); - expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("siq8-dev_webpack.abc"); + expect(host.querySelector(".docker-explorer-name")?.textContent).toBe("app-dev_webpack.abc"); expect(host.querySelector(".dot.running")).toBeTruthy(); await act(async () => { (host.querySelector(".docker-explorer-open") as HTMLButtonElement).click(); }); expect(inspected.map((row) => row.id)).toEqual(["c1"]); await act(async () => { - (host.querySelector('[aria-label="Stop siq8-dev_webpack.abc"]') as HTMLButtonElement).click(); + (host.querySelector('[aria-label="Stop app-dev_webpack.abc"]') as HTMLButtonElement).click(); await Promise.resolve(); await Promise.resolve(); }); expect(api.dockerContainerAction).toHaveBeenCalledWith("c1", "stop"); await act(async () => { ( - host.querySelector('[aria-label="Restart siq8-dev_webpack.abc"]') as HTMLButtonElement + host.querySelector('[aria-label="Restart app-dev_webpack.abc"]') as HTMLButtonElement ).click(); await Promise.resolve(); await Promise.resolve(); @@ -130,9 +130,7 @@ describe("DockerExplorer", () => { it("starts a stopped container without a confirm", async () => { render(); await flush(); - const start = host.querySelector( - '[aria-label="Start siq8-dev_nginx.xyz"]', - ) as HTMLButtonElement; + const start = host.querySelector('[aria-label="Start app-dev_nginx.xyz"]') as HTMLButtonElement; expect(start).toBeTruthy(); await act(async () => { start.click(); @@ -157,7 +155,7 @@ describe("DockerExplorer", () => { await act(async () => { (document.querySelector('.workspace-ctx [role="menuitem"]') as HTMLButtonElement).click(); }); - expect(inspected.map((row) => row.name)).toEqual(["siq8-dev_webpack.abc"]); + expect(inspected.map((row) => row.name)).toEqual(["app-dev_webpack.abc"]); expect(document.querySelector(".workspace-ctx")).toBeNull(); }); diff --git a/apps/desktop/src/components/guest/VncPane.tsx b/apps/desktop/src/components/guest/VncPane.tsx index b2a6343..83317e2 100644 --- a/apps/desktop/src/components/guest/VncPane.tsx +++ b/apps/desktop/src/components/guest/VncPane.tsx @@ -2,7 +2,15 @@ import { useEffect, useRef } from "react"; import type RFB from "@novnc/novnc"; import { displayWsUrl } from "@/lib/api"; -export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void }) { +export function VncPane({ + vmId, + onFail, + viewOnly = false, +}: { + vmId: string; + onFail: () => void; + viewOnly?: boolean; +}) { const host = useRef(null); const fail = useRef(onFail); fail.current = onFail; @@ -27,6 +35,7 @@ export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void }) rfb = session; session.scaleViewport = true; session.clipViewport = true; + session.viewOnly = viewOnly; session.background = getComputedStyle(document.documentElement).getPropertyValue("--desktop").trim() || "#0b0d12"; @@ -50,7 +59,7 @@ export function VncPane({ vmId, onFail }: { vmId: string; onFail: () => void }) /* already torn down */ } }; - }, [vmId]); + }, [vmId, viewOnly]); return
; } diff --git a/apps/desktop/src/components/layout/EmptyState.test.tsx b/apps/desktop/src/components/layout/EmptyState.test.tsx index b398b9f..a1c6d63 100644 --- a/apps/desktop/src/components/layout/EmptyState.test.tsx +++ b/apps/desktop/src/components/layout/EmptyState.test.tsx @@ -347,6 +347,22 @@ describe("EmptyState recipes", () => { ); }); + it("shows a guided first-task card after setup until skip or fill", async () => { + render(undefined, { config: { ...config, setupFirstTask: "pending" } }); + expect(host.querySelector("[data-testid='first-task-card']")).toBeTruthy(); + const fill = [...host.querySelectorAll("button")].find((btn) => + btn.textContent?.includes("Fill the composer"), + ); + await act(async () => { + fill!.click(); + await Promise.resolve(); + }); + expect((host.querySelector("textarea") as HTMLTextAreaElement).value).toMatch(/hello\.txt/); + expect(api.saveConfig).toHaveBeenCalledWith( + expect.objectContaining({ setupFirstTask: "done" }), + ); + }); + it("hides the first-task pill after a couple of agent sessions", () => { render(undefined, { sessions: [created, { ...created, id: "s2" }] }); expect(host.querySelector("[aria-label='Try a first task']")).toBeNull(); diff --git a/apps/desktop/src/components/layout/EmptyState.tsx b/apps/desktop/src/components/layout/EmptyState.tsx index 75efa6f..d926c6a 100644 --- a/apps/desktop/src/components/layout/EmptyState.tsx +++ b/apps/desktop/src/components/layout/EmptyState.tsx @@ -17,6 +17,7 @@ import { sessionVision, setupHasRemainingTodos, showFirstTaskPills, + t, visibleRecipes, workerIsMetered, workerWhereLabel, @@ -206,6 +207,17 @@ export function EmptyState() { clearRecipeExtras(); setPrompt(task.prompt); setError(null); + if (state.config?.setupFirstTask === "pending") { + void api.saveConfig({ ...state.config, setupFirstTask: "done" }).then((saved) => { + dispatch({ type: "config", config: saved }); + }); + } + } + + async function skipFirstTask() { + if (!state.config) return; + const saved = await api.saveConfig({ ...state.config, setupFirstTask: "dismissed" }); + dispatch({ type: "config", config: saved }); } function applyRecipe(recipe: AgentRecipe) { @@ -518,6 +530,28 @@ export function EmptyState() { ) : null}
+ {state.config?.setupFirstTask === "pending" && hasProvider ? ( +
+

{t("empty.firstTask.title")}

+

{t("empty.firstTask.body")}

+
+ + +
+
+ ) : null} {setup ? ( ({ + Settings: () =>
Settings body
, +})); +vi.mock("@/components/automations/NewAutomation", () => ({ + NewAutomationModal: () =>
New automation modal
, +})); +vi.mock("@/components/layout/SessionSwitcher", () => ({ + SessionSwitcher: ({ startOpen }: { startOpen?: boolean }) => + startOpen ?
Command palette
: null, +})); +vi.mock("@/components/guest/VncPane", () => ({ + VncPane: () =>
VNC
, +})); + vi.mock("@/lib/api", () => ({ api: { chat: vi.fn(async () => ({ ok: true })), @@ -1357,7 +1371,10 @@ describe("MobileShell", () => { ).toContain("Promote"); expect( [...host.querySelectorAll(".mobile-tags.is-muted li")].map((el) => el.textContent), - ).toEqual(["Live VNC", "Settings", "New automation", "Command palette", "Find in chat"]); + ).toEqual(["Live VNC control still needs the desktop."]); + expect(host.textContent).toContain("Settings"); + expect(host.textContent).toContain("New automation"); + expect(host.textContent).toContain("Command palette"); const desktop = [...host.querySelectorAll("button")].find( (btn) => btn.textContent === "Desktop", ); @@ -2125,4 +2142,76 @@ describe("MobileShell", () => { expect(host.querySelector("h1")?.textContent).toBe("Code"); expect(host.querySelector("[data-testid='mobile-code']")).toBeTruthy(); }); + + it("opens Settings, new automation, and the command palette from More", async () => { + render(); + const more = [...host.querySelectorAll(".mobile-tab")].find((btn) => + btn.textContent?.includes("More"), + ); + act(() => { + more!.click(); + }); + const settings = [...host.querySelectorAll("button")].find((btn) => + btn.textContent?.includes("Settings"), + ); + await act(async () => { + settings!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector("[data-testid='mobile-settings']")).toBeTruthy(); + act(() => { + host.querySelector(".mobile-overlay .mobile-back")!.click(); + }); + const automation = [...host.querySelectorAll("button")].find((btn) => + btn.textContent?.includes("New automation"), + ); + await act(async () => { + automation!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector("[data-testid='new-automation-stub']")).toBeTruthy(); + const palette = [...host.querySelectorAll("button")].find((btn) => + btn.textContent?.includes("Command palette"), + ); + await act(async () => { + palette!.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector("[data-testid='palette-stub']")).toBeTruthy(); + }); + + it("opens view-only VNC and find-in-chat on a VM thread", async () => { + render({ + sessions: [session({ runtime: "vm", vmId: "vm-1" })], + }); + act(() => { + host.querySelector(".mobile-row")!.click(); + }); + expect(host.querySelector("[data-testid='mobile-vnc-toggle']")).toBeTruthy(); + act(() => { + host.querySelector("[data-testid='mobile-vnc-toggle']")!.click(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.querySelector("[data-testid='mobile-vnc']")).toBeTruthy(); + act(() => { + host.querySelector("[data-testid='mobile-vnc-toggle']")!.click(); + }); + const more = host.querySelector("[title='Thread']"); + act(() => { + more!.click(); + }); + const find = [...host.querySelectorAll("button")].find((btn) => + btn.textContent?.includes("Find in chat"), + ); + act(() => { + find!.click(); + }); + expect(host.querySelector("[data-testid='mobile-find']")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/components/layout/MobileShell.tsx b/apps/desktop/src/components/layout/MobileShell.tsx index 733447e..04efe1d 100644 --- a/apps/desktop/src/components/layout/MobileShell.tsx +++ b/apps/desktop/src/components/layout/MobileShell.tsx @@ -1,4 +1,13 @@ -import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { + Fragment, + lazy, + Suspense, + useEffect, + useLayoutEffect, + useRef, + useState, + type ReactNode, +} from "react"; import { AgentCanvasPane, useAgentCanvasFocus } from "@/components/workspace/AgentCanvasPane"; import { DEFAULT_DOCKER_IMAGE, @@ -36,6 +45,7 @@ import { subagentStatusLabel, subagentViewMessages, showThoughtTraces, + t, themeOption, isFoldableToolActivity, toolActivityGroups, @@ -78,6 +88,7 @@ import { sessionMetaLine, sessionNeedsYou, sessionShowsBrowser, + sessionShowsVnc, sessionStatusLabel, setUiShell, bindMobileViewport, @@ -88,6 +99,20 @@ import { loadMobileAgentFilter, saveMobileAgentFilter } from "@/lib/sidebarPrefs import { MobileBoardList, MobileBoardView } from "@/components/board/MobileBoard"; import { MobileProjectList } from "@/components/projects/MobileProject"; import { APP_VERSION } from "@/lib/appInfo"; +import { chatFindHits } from "@/lib/chatFind"; + +const SettingsPane = lazy(async () => ({ + default: (await import("@/components/settings/Settings")).Settings, +})); +const NewAutomationModal = lazy(async () => ({ + default: (await import("@/components/automations/NewAutomation")).NewAutomationModal, +})); +const SessionSwitcher = lazy(async () => ({ + default: (await import("@/components/layout/SessionSwitcher")).SessionSwitcher, +})); +const VncPane = lazy(async () => ({ + default: (await import("@/components/guest/VncPane")).VncPane, +})); import { batchableApprovals, needsYouHumanItems, type NeedsYouItem } from "@/lib/needsYou"; import { applyChatDensity, applyTheme } from "@/lib/theme"; import { useStore } from "@/state/store"; @@ -362,6 +387,7 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) { const [codePull, setCodePull] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [search, setSearch] = useState(""); + const [overlay, setOverlay] = useState(null); const sessions = mobileSessions(state.sessions); const designs = mobileDesigns(state.sessions); const games = mobileGames(state.sessions); @@ -579,6 +605,9 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) { onOpenCanvas={(id) => setDesignId(id)} onNewGame={() => setCompose("game")} onNewAsset={() => setCompose("assets")} + onSettings={() => setOverlay("settings")} + onNewAutomation={() => dispatch({ type: "newAutomation", open: true })} + onPalette={() => setOverlay("palette")} /> ) : null}
@@ -624,6 +653,37 @@ export function MobileShell(props: { onShell: (shell: UiShell) => void }) { )} + {overlay === "settings" ? ( +
+
+ +
+

{t("phone.more.settings")}

+
+
+
+ Loading…

}> + +
+
+
+ ) : null} + {overlay === "palette" ? ( + + setOverlay(null)} /> + + ) : null} + {state.newAutomationOpen ? ( + + + + ) : null} ); } @@ -918,6 +978,9 @@ function MobileMore(props: { onOpenCanvas: (sessionId: string) => void; onNewGame?: () => void; onNewAsset?: () => void; + onSettings?: () => void; + onNewAutomation?: () => void; + onPalette?: () => void; }) { const { state, dispatch } = useStore(); const theme = state.config?.theme ?? "ember"; @@ -1346,14 +1409,29 @@ function MobileMore(props: { ))} +
+

On this phone

+ + + +

Stays on the desktop

    - {["Live VNC", "Settings", "New automation", "Command palette", "Find in chat"].map( - (label) => ( -
  • {label}
  • - ), - )} +
  • {t("phone.desktopOnly")}
@@ -1503,6 +1581,9 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) { const [outbound, setOutbound] = useState(null); const [openSubagent, setOpenSubagent] = useState(null); const [browser, setBrowser] = useState(false); + const [vnc, setVnc] = useState(false); + const [findOpen, setFindOpen] = useState(false); + const [findQuery, setFindQuery] = useState(""); const [pane, setPane] = useState<"chat" | "diff" | "files" | "canvas">("chat"); const canvasFocus = useAgentCanvasFocus(session.id, session.agentCanvases); useEffect(() => { @@ -1518,6 +1599,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) { productMode: state.config?.productMode, }); const canWatchBrowser = sessionShowsBrowser(session); + const canWatchVnc = sessionShowsVnc(session); const pending = viewing ? [] : pendingApprovals([session]); const outboundVisible = viewing || !(outbound && !chatPromptLanded(session, outbound.content)) ? null : outbound; @@ -1528,6 +1610,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) { ? [...session.messages, outboundVisible] : session.messages ).filter((row) => !isHiddenChatMessage(row)); + const findHits = findOpen ? chatFindHits(messages, findQuery) : []; const newMarkerId = viewing ? undefined : visitMarkerId(state.chatNewVisit, session.id); const cloneStartId = viewing ? undefined : latestCloneStartMessageId(messages); const cloneProgressLive = state.cloneProgress[session.id]; @@ -1564,6 +1647,9 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) { setOutbound(null); setOpenSubagent(null); setBrowser(false); + setVnc(false); + setFindOpen(false); + setFindQuery(""); setPane("chat"); }, [session.id]); @@ -1640,12 +1726,31 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) { className={`mobile-icon-btn${browser ? " on" : ""}`} aria-label={browser ? "Hide browser" : "Open browser"} aria-pressed={browser} - onClick={() => setBrowser((open) => !open)} + onClick={() => { + setVnc(false); + setBrowser((open) => !open); + }} > {browser ? "Hide browser" : "Open browser"} ) : null} + {canWatchVnc && !viewing ? ( + + ) : null} {turning && !viewing ? ( ) : !viewing ? ( } title="Thread" toggleClassName="mobile-icon-btn"> + { + setFindOpen((open) => !open); + if (findOpen) setFindQuery(""); + }} + > + {t("phone.more.find")} + { void api @@ -1709,7 +1822,7 @@ function MobileChat(props: { session: AgentSession; onBack: () => void }) {